@herdctl/discord 1.1.1 → 1.2.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 +14 -1
- package/dist/__tests__/attachments.test.d.ts +8 -0
- package/dist/__tests__/attachments.test.d.ts.map +1 -0
- package/dist/__tests__/attachments.test.js +439 -0
- package/dist/__tests__/attachments.test.js.map +1 -0
- package/dist/__tests__/discord-connector.test.js +4 -1
- package/dist/__tests__/discord-connector.test.js.map +1 -1
- package/dist/__tests__/embeds.test.d.ts +2 -0
- package/dist/__tests__/embeds.test.d.ts.map +1 -0
- package/dist/__tests__/embeds.test.js +47 -0
- package/dist/__tests__/embeds.test.js.map +1 -0
- package/dist/__tests__/logger.test.js +4 -1
- package/dist/__tests__/logger.test.js.map +1 -1
- package/dist/__tests__/manager.test.js +1193 -28
- package/dist/__tests__/manager.test.js.map +1 -1
- package/dist/__tests__/message-normalizer.test.d.ts +2 -0
- package/dist/__tests__/message-normalizer.test.d.ts.map +1 -0
- package/dist/__tests__/message-normalizer.test.js +83 -0
- package/dist/__tests__/message-normalizer.test.js.map +1 -0
- package/dist/__tests__/runtime-parity.test.d.ts +2 -0
- package/dist/__tests__/runtime-parity.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-parity.test.js +157 -0
- package/dist/__tests__/runtime-parity.test.js.map +1 -0
- package/dist/auto-mode-handler.d.ts.map +1 -1
- package/dist/auto-mode-handler.js +9 -0
- package/dist/auto-mode-handler.js.map +1 -1
- package/dist/commands/__tests__/command-manager.test.js +63 -3
- package/dist/commands/__tests__/command-manager.test.js.map +1 -1
- package/dist/commands/__tests__/extended-commands.test.d.ts +2 -0
- package/dist/commands/__tests__/extended-commands.test.d.ts.map +1 -0
- package/dist/commands/__tests__/extended-commands.test.js +159 -0
- package/dist/commands/__tests__/extended-commands.test.js.map +1 -0
- package/dist/commands/__tests__/help.test.js +5 -6
- package/dist/commands/__tests__/help.test.js.map +1 -1
- package/dist/commands/__tests__/reset.test.js +14 -6
- package/dist/commands/__tests__/reset.test.js.map +1 -1
- package/dist/commands/__tests__/status.test.js +27 -25
- package/dist/commands/__tests__/status.test.js.map +1 -1
- package/dist/commands/cancel.d.ts +3 -0
- package/dist/commands/cancel.d.ts.map +1 -0
- package/dist/commands/cancel.js +7 -0
- package/dist/commands/cancel.js.map +1 -0
- package/dist/commands/command-manager.d.ts +4 -1
- package/dist/commands/command-manager.d.ts.map +1 -1
- package/dist/commands/command-manager.js +65 -3
- package/dist/commands/command-manager.js.map +1 -1
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +33 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/help.d.ts +1 -1
- package/dist/commands/help.d.ts.map +1 -1
- package/dist/commands/help.js +26 -12
- package/dist/commands/help.js.map +1 -1
- package/dist/commands/index.d.ts +12 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +12 -1
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/new.d.ts +3 -0
- package/dist/commands/new.d.ts.map +1 -0
- package/dist/commands/new.js +22 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/ping.d.ts +3 -0
- package/dist/commands/ping.d.ts.map +1 -0
- package/dist/commands/ping.js +22 -0
- package/dist/commands/ping.js.map +1 -0
- package/dist/commands/reset.d.ts +1 -1
- package/dist/commands/reset.d.ts.map +1 -1
- package/dist/commands/reset.js +13 -13
- package/dist/commands/reset.js.map +1 -1
- package/dist/commands/retry.d.ts +3 -0
- package/dist/commands/retry.d.ts.map +1 -0
- package/dist/commands/retry.js +25 -0
- package/dist/commands/retry.js.map +1 -0
- package/dist/commands/session.d.ts +3 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/session.js +47 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.d.ts.map +1 -0
- package/dist/commands/skill.js +44 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/skills.d.ts +3 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +30 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +25 -18
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/stop.d.ts +3 -0
- package/dist/commands/stop.d.ts.map +1 -0
- package/dist/commands/stop.js +25 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/commands/tools.d.ts +3 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +30 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/commands/types.d.ts +71 -1
- package/dist/commands/types.d.ts.map +1 -1
- package/dist/commands/usage.d.ts +3 -0
- package/dist/commands/usage.d.ts.map +1 -0
- package/dist/commands/usage.js +58 -0
- package/dist/commands/usage.js.map +1 -0
- package/dist/discord-connector.d.ts +10 -1
- package/dist/discord-connector.d.ts.map +1 -1
- package/dist/discord-connector.js +153 -8
- package/dist/discord-connector.js.map +1 -1
- package/dist/embeds.d.ts +47 -0
- package/dist/embeds.d.ts.map +1 -0
- package/dist/embeds.js +121 -0
- package/dist/embeds.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/manager.d.ts +53 -24
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +1031 -217
- package/dist/manager.js.map +1 -1
- package/dist/mention-handler.d.ts.map +1 -1
- package/dist/mention-handler.js +27 -0
- package/dist/mention-handler.js.map +1 -1
- package/dist/message-normalizer.d.ts +40 -0
- package/dist/message-normalizer.d.ts.map +1 -0
- package/dist/message-normalizer.js +99 -0
- package/dist/message-normalizer.js.map +1 -0
- package/dist/types.d.ts +80 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/voice-transcriber.d.ts +31 -0
- package/dist/voice-transcriber.d.ts.map +1 -0
- package/dist/voice-transcriber.js +44 -0
- package/dist/voice-transcriber.js.map +1 -0
- package/package.json +3 -3
package/dist/manager.js
CHANGED
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @module manager
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
15
|
+
import { ChatSessionManager, StreamingResponder, splitMessage, } from "@herdctl/chat";
|
|
16
|
+
import { createFileSenderDef, getToolInputSummary, TOOL_EMOJIS, } from "@herdctl/core";
|
|
14
17
|
import { DiscordConnector } from "./discord-connector.js";
|
|
18
|
+
import { buildErrorEmbed, buildResultSummaryEmbed, buildRunCardEmbed, buildStatusEmbed, buildToolResultEmbed, } from "./embeds.js";
|
|
19
|
+
import { formatContextForPrompt } from "./mention-handler.js";
|
|
20
|
+
import { normalizeDiscordMessage } from "./message-normalizer.js";
|
|
21
|
+
import { transcribeAudio } from "./voice-transcriber.js";
|
|
15
22
|
/**
|
|
16
23
|
* DiscordManager handles Discord connections for agents
|
|
17
24
|
*
|
|
@@ -24,6 +31,10 @@ import { DiscordConnector } from "./discord-connector.js";
|
|
|
24
31
|
export class DiscordManager {
|
|
25
32
|
ctx;
|
|
26
33
|
connectors = new Map();
|
|
34
|
+
activeJobsByChannel = new Map();
|
|
35
|
+
lastPromptByChannel = new Map();
|
|
36
|
+
lastUsageByChannel = new Map();
|
|
37
|
+
cumulativeUsageByAgent = new Map();
|
|
27
38
|
initialized = false;
|
|
28
39
|
constructor(ctx) {
|
|
29
40
|
this.ctx = ctx;
|
|
@@ -93,6 +104,22 @@ export class DiscordManager {
|
|
|
93
104
|
sessionManager,
|
|
94
105
|
stateDir,
|
|
95
106
|
logger: createAgentLogger(`[discord:${agent.qualifiedName}]`),
|
|
107
|
+
commandActions: {
|
|
108
|
+
stopRun: (channelId) => this.stopChannelRun(agent.qualifiedName, channelId),
|
|
109
|
+
retryRun: (channelId) => this.retryChannelRun(agent.qualifiedName, channelId),
|
|
110
|
+
runSkill: (channelId, skillName, input) => this.runChannelSkill(agent.qualifiedName, channelId, skillName, input),
|
|
111
|
+
listSkills: async () => this.discoverAgentSkills(agent),
|
|
112
|
+
getUsage: async (channelId) => this.getChannelUsage(agent.qualifiedName, channelId),
|
|
113
|
+
getCumulativeUsage: async () => this.getAgentCumulativeUsage(agent.qualifiedName),
|
|
114
|
+
getAgentConfig: async () => this.getAgentConfigSummary(agent),
|
|
115
|
+
getSessionInfo: async (channelId) => this.getChannelRunInfo(agent.qualifiedName, channelId),
|
|
116
|
+
},
|
|
117
|
+
commandRegistration: discordConfig.command_registration
|
|
118
|
+
? {
|
|
119
|
+
scope: discordConfig.command_registration.scope,
|
|
120
|
+
guildId: discordConfig.command_registration.guild_id,
|
|
121
|
+
}
|
|
122
|
+
: { scope: "global" },
|
|
96
123
|
});
|
|
97
124
|
this.connectors.set(agent.qualifiedName, connector);
|
|
98
125
|
logger.debug(`Created Discord connector for agent '${agent.qualifiedName}'`);
|
|
@@ -270,6 +297,7 @@ export class DiscordManager {
|
|
|
270
297
|
const logger = this.ctx.getLogger();
|
|
271
298
|
const emitter = this.ctx.getEmitter();
|
|
272
299
|
logger.info(`Discord message for agent '${qualifiedName}': ${event.prompt.substring(0, 50)}...`);
|
|
300
|
+
this.lastPromptByChannel.set(this.getChannelKey(qualifiedName, event.metadata.channelId), event.prompt);
|
|
273
301
|
// Get the agent configuration (lookup by qualifiedName)
|
|
274
302
|
const config = this.ctx.getConfig();
|
|
275
303
|
const agent = config?.agents.find((a) => a.qualifiedName === qualifiedName);
|
|
@@ -291,7 +319,13 @@ export class DiscordManager {
|
|
|
291
319
|
result_summary: false,
|
|
292
320
|
errors: true,
|
|
293
321
|
typing_indicator: true,
|
|
322
|
+
acknowledge_emoji: "👀",
|
|
323
|
+
assistant_messages: "answers",
|
|
324
|
+
progress_indicator: true,
|
|
294
325
|
};
|
|
326
|
+
// Resolve output modes
|
|
327
|
+
const assistantMessages = outputConfig.assistant_messages ?? "answers";
|
|
328
|
+
const showProgressIndicator = outputConfig.progress_indicator !== false;
|
|
295
329
|
// Get existing session for this channel (for conversation continuity)
|
|
296
330
|
const connector = this.connectors.get(qualifiedName);
|
|
297
331
|
let existingSessionId;
|
|
@@ -312,156 +346,446 @@ export class DiscordManager {
|
|
|
312
346
|
// Continue processing - session failure shouldn't block message handling
|
|
313
347
|
}
|
|
314
348
|
}
|
|
315
|
-
//
|
|
349
|
+
// Buffer for files uploaded by the agent via MCP tool.
|
|
350
|
+
// Files are queued here and attached to the next answer message,
|
|
351
|
+
// so they appear below the text (not as standalone messages above it).
|
|
352
|
+
const pendingFiles = [];
|
|
353
|
+
// Create file sender definition for this message context
|
|
354
|
+
let injectedMcpServers;
|
|
355
|
+
const workingDir = this.resolveWorkingDirectory(agent);
|
|
356
|
+
if (connector && workingDir) {
|
|
357
|
+
const fileSenderContext = {
|
|
358
|
+
workingDirectory: workingDir,
|
|
359
|
+
uploadFile: async (params) => {
|
|
360
|
+
// Queue the file — it will be attached to the next answer message
|
|
361
|
+
pendingFiles.push({
|
|
362
|
+
buffer: params.fileBuffer,
|
|
363
|
+
filename: params.filename,
|
|
364
|
+
});
|
|
365
|
+
const fileId = `buffered-${randomUUID()}`;
|
|
366
|
+
logger.debug(`Buffered file '${params.filename}' for attachment to next answer message`);
|
|
367
|
+
return { fileId };
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
const fileSenderDef = createFileSenderDef(fileSenderContext);
|
|
371
|
+
injectedMcpServers = { [fileSenderDef.name]: fileSenderDef };
|
|
372
|
+
}
|
|
373
|
+
// Create streaming responder for incremental message delivery.
|
|
374
|
+
// The reply closure drains pending files and attaches them to the message.
|
|
316
375
|
const streamer = new StreamingResponder({
|
|
317
|
-
reply: (content) =>
|
|
376
|
+
reply: async (content) => {
|
|
377
|
+
if (pendingFiles.length > 0) {
|
|
378
|
+
const files = pendingFiles.splice(0);
|
|
379
|
+
await event.reply({
|
|
380
|
+
content,
|
|
381
|
+
files: files.map((f) => ({ attachment: f.buffer, name: f.filename })),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
await event.reply(content);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
318
388
|
logger: logger,
|
|
319
389
|
agentName: qualifiedName,
|
|
320
390
|
maxMessageLength: 2000, // Discord's limit
|
|
321
391
|
maxBufferSize: 1500,
|
|
322
392
|
platformName: "Discord",
|
|
323
393
|
});
|
|
324
|
-
// Start typing indicator while processing (
|
|
325
|
-
const stopTyping = outputConfig.typing_indicator ? event.startTyping() : () => { };
|
|
394
|
+
// Start typing indicator while processing (if not disabled via output.typing_indicator)
|
|
395
|
+
const stopTyping = outputConfig.typing_indicator !== false ? event.startTyping() : () => { };
|
|
326
396
|
// Track if we've stopped typing to avoid multiple calls
|
|
327
397
|
let typingStopped = false;
|
|
398
|
+
// Add acknowledgement reaction if configured (non-fatal — don't abort message handling)
|
|
399
|
+
const ackEmoji = outputConfig.acknowledge_emoji;
|
|
400
|
+
if (ackEmoji) {
|
|
401
|
+
try {
|
|
402
|
+
await event.addReaction(ackEmoji);
|
|
403
|
+
}
|
|
404
|
+
catch (reactionError) {
|
|
405
|
+
logger.warn(`Failed to add ack reaction: ${reactionError.message}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Attachment state — declared here so the finally block can clean up
|
|
409
|
+
let attachmentDownloadedPaths = [];
|
|
410
|
+
const attachmentConfig = agent.chat?.discord?.attachments;
|
|
411
|
+
// Progress embed state — declared here so the finally block can clean up
|
|
412
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
413
|
+
const progressState = { handle: null };
|
|
414
|
+
const traceLines = [];
|
|
415
|
+
const pushTraceLine = (line) => {
|
|
416
|
+
traceLines.push(line);
|
|
417
|
+
if (traceLines.length > 24) {
|
|
418
|
+
traceLines.splice(0, traceLines.length - 24);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
328
421
|
try {
|
|
422
|
+
// Handle voice messages: transcribe audio before triggering the agent
|
|
423
|
+
let prompt = event.prompt;
|
|
424
|
+
if (!existingSessionId && event.context.messages.length > 0) {
|
|
425
|
+
const priorContext = formatContextForPrompt(event.context);
|
|
426
|
+
if (priorContext) {
|
|
427
|
+
prompt = [
|
|
428
|
+
"Recent conversation context from this Discord channel:",
|
|
429
|
+
priorContext,
|
|
430
|
+
"",
|
|
431
|
+
`Current user message: ${prompt}`,
|
|
432
|
+
].join("\n");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const voiceConfig = agent.chat?.discord?.voice;
|
|
436
|
+
if (event.metadata.isVoiceMessage) {
|
|
437
|
+
if (!voiceConfig?.enabled) {
|
|
438
|
+
await event.reply("Voice messages are not enabled for this agent. Please send a text message instead.");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const apiKey = process.env[voiceConfig.api_key_env ?? "OPENAI_API_KEY"];
|
|
442
|
+
if (!apiKey) {
|
|
443
|
+
logger.error(`Voice transcription API key not found in env var '${voiceConfig.api_key_env}'`);
|
|
444
|
+
await event.reply("Voice transcription is misconfigured. Please contact an administrator.");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (!event.metadata.voiceAttachmentUrl) {
|
|
448
|
+
await event.reply("Could not find audio attachment in voice message.");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
logger.debug("Downloading voice message audio...");
|
|
453
|
+
const audioResponse = await fetch(event.metadata.voiceAttachmentUrl, {
|
|
454
|
+
signal: AbortSignal.timeout(30_000),
|
|
455
|
+
});
|
|
456
|
+
if (!audioResponse.ok) {
|
|
457
|
+
throw new Error(`Failed to download audio: ${audioResponse.status}`);
|
|
458
|
+
}
|
|
459
|
+
const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
|
|
460
|
+
const filename = event.metadata.voiceAttachmentName ?? "voice-message.ogg";
|
|
461
|
+
logger.debug("Transcribing voice message...");
|
|
462
|
+
const transcription = await transcribeAudio(audioBuffer, filename, {
|
|
463
|
+
apiKey,
|
|
464
|
+
model: voiceConfig.model,
|
|
465
|
+
language: voiceConfig.language,
|
|
466
|
+
});
|
|
467
|
+
prompt = `[Voice message transcription]: ${transcription.text}`;
|
|
468
|
+
logger.info(`Voice message transcribed: "${prompt.substring(0, 80)}..."`);
|
|
469
|
+
// Echo the transcription to the channel so everyone can read the voice message
|
|
470
|
+
await event.reply({
|
|
471
|
+
embeds: [
|
|
472
|
+
{
|
|
473
|
+
description: transcription.text,
|
|
474
|
+
color: 0x95a5a6, // subtle grey
|
|
475
|
+
footer: { text: "Voice transcription" },
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
catch (transcribeError) {
|
|
481
|
+
const errMsg = transcribeError instanceof Error ? transcribeError.message : String(transcribeError);
|
|
482
|
+
logger.error(`Voice transcription failed: ${errMsg}`);
|
|
483
|
+
await event.reply(`Failed to transcribe voice message: ${errMsg}`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Handle file attachments: download, process, and prepend to prompt
|
|
488
|
+
if (event.metadata.attachments &&
|
|
489
|
+
event.metadata.attachments.length > 0 &&
|
|
490
|
+
attachmentConfig?.enabled) {
|
|
491
|
+
const result = await DiscordManager.processAttachments(event.metadata.attachments, attachmentConfig, workingDir, logger);
|
|
492
|
+
attachmentDownloadedPaths = result.downloadedPaths;
|
|
493
|
+
if (result.skippedFiles.length > 0) {
|
|
494
|
+
for (const skipped of result.skippedFiles) {
|
|
495
|
+
logger.debug(`Skipped attachment ${skipped.name}: ${skipped.reason}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (result.promptSections.length > 0) {
|
|
499
|
+
// When agent runs in Docker, translate host paths to container paths
|
|
500
|
+
// (working_directory is mounted at /workspace inside the container)
|
|
501
|
+
let sections = result.promptSections;
|
|
502
|
+
if (agent.docker?.enabled && workingDir) {
|
|
503
|
+
sections = sections.map((s) => s.replaceAll(workingDir, "/workspace"));
|
|
504
|
+
}
|
|
505
|
+
const attachmentBlock = [
|
|
506
|
+
"The user sent the following file attachment(s) with their message:",
|
|
507
|
+
"",
|
|
508
|
+
...sections,
|
|
509
|
+
"",
|
|
510
|
+
"---",
|
|
511
|
+
"",
|
|
512
|
+
`User message: ${prompt}`,
|
|
513
|
+
].join("\n");
|
|
514
|
+
prompt = attachmentBlock;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
329
517
|
// Track pending tool_use blocks so we can pair them with results
|
|
330
518
|
const pendingToolUses = new Map();
|
|
331
519
|
let embedsSent = 0;
|
|
520
|
+
// Deduplicate assistant messages by finalized snapshot. Claude Code can emit
|
|
521
|
+
// intermediate snapshots (stop_reason: null) before the final assistant message.
|
|
522
|
+
// We skip intermediates and deliver the first finalized snapshot per message.id.
|
|
523
|
+
const deliveredAssistantIds = new Set();
|
|
524
|
+
// Capture the result text from the SDK's "result" message as a fallback
|
|
525
|
+
// When all assistant messages are tool-only (no text), this is the last resort
|
|
526
|
+
let resultText;
|
|
527
|
+
let sentAnswer = false;
|
|
528
|
+
let streamedDeltaSinceFinal = false;
|
|
529
|
+
// Progress indicator: track tool names for in-place-updating embed
|
|
530
|
+
const toolNamesRun = [];
|
|
531
|
+
let lastProgressUpdate = 0;
|
|
532
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
533
|
+
let liveAnswerHandle = null;
|
|
534
|
+
let liveAnswerText = "";
|
|
535
|
+
let latestStatusText = "Preparing run…";
|
|
536
|
+
const refreshRunCard = async (status) => {
|
|
537
|
+
if (!showProgressIndicator) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const now = Date.now();
|
|
541
|
+
if (status === "running" && now - lastProgressUpdate < 1500) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
lastProgressUpdate = now;
|
|
545
|
+
const header = toolNamesRun.length > 0 ? `Running · ${toolNamesRun.join(" → ")}` : "Running";
|
|
546
|
+
const message = status === "running" ? `${header}\n${latestStatusText}` : latestStatusText;
|
|
547
|
+
const embedPayload = {
|
|
548
|
+
embeds: [
|
|
549
|
+
buildRunCardEmbed({
|
|
550
|
+
agentName: qualifiedName,
|
|
551
|
+
status,
|
|
552
|
+
message: message.length > 4000 ? `…${message.slice(-3997)}` : message,
|
|
553
|
+
traceLines,
|
|
554
|
+
}),
|
|
555
|
+
],
|
|
556
|
+
};
|
|
557
|
+
try {
|
|
558
|
+
if (!progressState.handle) {
|
|
559
|
+
progressState.handle = await event.replyWithRef(embedPayload);
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
await progressState.handle.edit(embedPayload);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (progressError) {
|
|
566
|
+
logger.warn(`Failed to update run card: ${progressError.message}`);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const showToolResults = outputConfig.tool_results;
|
|
570
|
+
const enableDeltaStreaming = assistantMessages === "all";
|
|
332
571
|
// Execute job via FleetManager.trigger() through the context
|
|
333
572
|
// Pass resume option for conversation continuity
|
|
334
573
|
// The onMessage callback streams output incrementally to Discord
|
|
335
574
|
const result = await this.ctx.trigger(qualifiedName, undefined, {
|
|
336
575
|
triggerType: "discord",
|
|
337
|
-
prompt
|
|
576
|
+
prompt,
|
|
338
577
|
resume: existingSessionId,
|
|
578
|
+
injectedMcpServers,
|
|
579
|
+
onJobCreated: async (jobId) => {
|
|
580
|
+
this.activeJobsByChannel.set(this.getChannelKey(qualifiedName, event.metadata.channelId), jobId);
|
|
581
|
+
},
|
|
339
582
|
onMessage: async (message) => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
583
|
+
for (const normalized of normalizeDiscordMessage(message)) {
|
|
584
|
+
if (normalized.kind === "assistant_delta") {
|
|
585
|
+
if (!enableDeltaStreaming) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
streamedDeltaSinceFinal = true;
|
|
589
|
+
liveAnswerText += normalized.delta;
|
|
590
|
+
if (!liveAnswerText.trim()) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const payload = { content: liveAnswerText };
|
|
594
|
+
try {
|
|
595
|
+
if (!liveAnswerHandle) {
|
|
596
|
+
liveAnswerHandle = await event.replyWithRef(payload);
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
await liveAnswerHandle.edit(payload);
|
|
600
|
+
}
|
|
601
|
+
sentAnswer = true;
|
|
602
|
+
}
|
|
603
|
+
catch (deltaError) {
|
|
604
|
+
logger.warn(`Failed delta streaming update: ${deltaError.message}`);
|
|
605
|
+
}
|
|
606
|
+
continue;
|
|
349
607
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
608
|
+
if (normalized.kind === "assistant_final") {
|
|
609
|
+
for (const block of normalized.toolUses) {
|
|
610
|
+
if (block.id) {
|
|
611
|
+
pendingToolUses.set(block.id, {
|
|
612
|
+
name: block.name,
|
|
613
|
+
input: block.input,
|
|
614
|
+
startTime: Date.now(),
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
if (block.name && showProgressIndicator) {
|
|
618
|
+
const emoji = TOOL_EMOJIS[block.name] ?? "\u{1F527}";
|
|
619
|
+
const displayName = `${emoji} ${block.name}`;
|
|
620
|
+
toolNamesRun.push(displayName);
|
|
621
|
+
if (toolNamesRun.length > 50) {
|
|
622
|
+
toolNamesRun.splice(0, toolNamesRun.length - 50);
|
|
623
|
+
}
|
|
624
|
+
const inputSummary = getToolInputSummary(block.name, block.input);
|
|
625
|
+
pushTraceLine(`${emoji} ${block.name}${inputSummary ? ` · ${inputSummary.slice(0, 60)}` : ""}`);
|
|
626
|
+
latestStatusText = `Executing ${block.name}`;
|
|
627
|
+
await refreshRunCard("running");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (normalized.messageId && normalized.stopReason === null) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (normalized.messageId) {
|
|
634
|
+
if (deliveredAssistantIds.has(normalized.messageId)) {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
deliveredAssistantIds.add(normalized.messageId);
|
|
638
|
+
}
|
|
639
|
+
const content = normalized.content;
|
|
640
|
+
if (!content) {
|
|
641
|
+
streamedDeltaSinceFinal = false;
|
|
642
|
+
continue;
|
|
359
643
|
}
|
|
644
|
+
if (assistantMessages === "answers") {
|
|
645
|
+
if (normalized.toolUses.length === 0) {
|
|
646
|
+
await streamer.addMessageAndSend(content);
|
|
647
|
+
sentAnswer = true;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
else if (streamedDeltaSinceFinal && enableDeltaStreaming) {
|
|
651
|
+
// Sync final content into the live delta message to avoid duplicates.
|
|
652
|
+
liveAnswerText = content;
|
|
653
|
+
try {
|
|
654
|
+
if (!liveAnswerHandle) {
|
|
655
|
+
liveAnswerHandle = await event.replyWithRef({ content });
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
await liveAnswerHandle.edit({ content });
|
|
659
|
+
}
|
|
660
|
+
sentAnswer = true;
|
|
661
|
+
}
|
|
662
|
+
catch (syncError) {
|
|
663
|
+
logger.warn(`Failed to sync final streamed answer: ${syncError.message}`);
|
|
664
|
+
await streamer.addMessageAndSend(content);
|
|
665
|
+
sentAnswer = true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
await streamer.addMessageAndSend(content);
|
|
670
|
+
sentAnswer = true;
|
|
671
|
+
}
|
|
672
|
+
streamedDeltaSinceFinal = false;
|
|
673
|
+
continue;
|
|
360
674
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
:
|
|
372
|
-
|
|
373
|
-
|
|
675
|
+
if (normalized.kind === "tool_results" && showToolResults) {
|
|
676
|
+
for (const toolResult of normalized.results) {
|
|
677
|
+
const toolUse = toolResult.toolUseId
|
|
678
|
+
? pendingToolUses.get(toolResult.toolUseId)
|
|
679
|
+
: undefined;
|
|
680
|
+
if (toolResult.toolUseId) {
|
|
681
|
+
pendingToolUses.delete(toolResult.toolUseId);
|
|
682
|
+
}
|
|
683
|
+
const toolName = toolUse?.name ?? "Tool";
|
|
684
|
+
const output = toolResult.output.trim();
|
|
685
|
+
const preview = output.length > 0 ? output.replace(/\s+/g, " ").slice(0, 90) : "";
|
|
686
|
+
pushTraceLine(`${toolResult.isError ? "✖" : "✓"} ${toolName}${preview ? ` · ${preview}` : ""}`);
|
|
687
|
+
latestStatusText = `${toolResult.isError ? "Error from" : "Completed"} ${toolName}`;
|
|
688
|
+
await refreshRunCard("running");
|
|
689
|
+
// Oversized output is attached as a file instead of flooding chat.
|
|
690
|
+
const maxOutputChars = outputConfig.tool_result_max_length ?? 900;
|
|
691
|
+
if (output.length > maxOutputChars) {
|
|
692
|
+
await streamer.flush();
|
|
693
|
+
const filename = `${toolName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "tool"}-output.txt`;
|
|
694
|
+
const previewEmbed = buildToolResultEmbed({
|
|
695
|
+
toolUse: toolUse ?? null,
|
|
696
|
+
toolResult: {
|
|
697
|
+
output: output.slice(0, Math.min(300, maxOutputChars)),
|
|
698
|
+
isError: toolResult.isError,
|
|
699
|
+
},
|
|
700
|
+
agentName: qualifiedName,
|
|
701
|
+
maxOutputChars: Math.min(300, maxOutputChars),
|
|
702
|
+
});
|
|
703
|
+
await event.reply({
|
|
704
|
+
embeds: [previewEmbed],
|
|
705
|
+
files: [{ attachment: Buffer.from(output, "utf8"), name: filename }],
|
|
706
|
+
});
|
|
707
|
+
embedsSent++;
|
|
708
|
+
}
|
|
374
709
|
}
|
|
375
|
-
|
|
376
|
-
// Flush any buffered text before sending embed to preserve ordering
|
|
377
|
-
await streamer.flush();
|
|
378
|
-
await event.reply({ embeds: [embed] });
|
|
379
|
-
embedsSent++;
|
|
710
|
+
continue;
|
|
380
711
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
? "Compacting context..."
|
|
388
|
-
: `Status: ${sysMessage.status}`;
|
|
389
|
-
await streamer.flush();
|
|
390
|
-
await event.reply({
|
|
391
|
-
embeds: [
|
|
392
|
-
{
|
|
393
|
-
title: "\u2699\uFE0F System",
|
|
394
|
-
description: statusText,
|
|
395
|
-
color: DiscordManager.EMBED_COLOR_SYSTEM,
|
|
396
|
-
},
|
|
397
|
-
],
|
|
398
|
-
});
|
|
399
|
-
embedsSent++;
|
|
712
|
+
if (normalized.kind === "system_status" && outputConfig.system_status) {
|
|
713
|
+
latestStatusText =
|
|
714
|
+
normalized.status === "compacting" ? "Compacting context…" : normalized.status;
|
|
715
|
+
pushTraceLine(`ℹ ${latestStatusText}`);
|
|
716
|
+
await refreshRunCard("running");
|
|
717
|
+
continue;
|
|
400
718
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (resultMessage.duration_ms !== undefined) {
|
|
407
|
-
fields.push({
|
|
408
|
-
name: "Duration",
|
|
409
|
-
value: DiscordManager.formatDuration(resultMessage.duration_ms),
|
|
410
|
-
inline: true,
|
|
411
|
-
});
|
|
719
|
+
if (normalized.kind === "tool_progress" && outputConfig.system_status) {
|
|
720
|
+
latestStatusText = normalized.content;
|
|
721
|
+
pushTraceLine(`ℹ ${normalized.content}`);
|
|
722
|
+
await refreshRunCard("running");
|
|
723
|
+
continue;
|
|
412
724
|
}
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
725
|
+
if (normalized.kind === "auth_status" && outputConfig.system_status) {
|
|
726
|
+
latestStatusText = normalized.content;
|
|
727
|
+
pushTraceLine(`${normalized.isError ? "✖" : "ℹ"} ${normalized.content}`);
|
|
728
|
+
if (normalized.isError) {
|
|
729
|
+
await streamer.flush();
|
|
730
|
+
await event.reply({
|
|
731
|
+
embeds: [buildStatusEmbed(normalized.content, "error", qualifiedName)],
|
|
732
|
+
});
|
|
733
|
+
embedsSent++;
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
await refreshRunCard("running");
|
|
737
|
+
}
|
|
738
|
+
continue;
|
|
419
739
|
}
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
740
|
+
if (normalized.kind === "result") {
|
|
741
|
+
if (normalized.resultText) {
|
|
742
|
+
resultText = normalized.resultText;
|
|
743
|
+
}
|
|
744
|
+
const now = new Date().toISOString();
|
|
745
|
+
this.lastUsageByChannel.set(this.getChannelKey(qualifiedName, event.metadata.channelId), {
|
|
746
|
+
timestamp: now,
|
|
747
|
+
numTurns: normalized.numTurns,
|
|
748
|
+
durationMs: normalized.durationMs,
|
|
749
|
+
totalCostUsd: normalized.totalCostUsd,
|
|
750
|
+
inputTokens: normalized.usage?.input_tokens,
|
|
751
|
+
outputTokens: normalized.usage?.output_tokens,
|
|
752
|
+
isError: normalized.isError,
|
|
425
753
|
});
|
|
754
|
+
this.accumulateUsage(qualifiedName, {
|
|
755
|
+
durationMs: normalized.durationMs,
|
|
756
|
+
totalCostUsd: normalized.totalCostUsd,
|
|
757
|
+
inputTokens: normalized.usage?.input_tokens,
|
|
758
|
+
outputTokens: normalized.usage?.output_tokens,
|
|
759
|
+
isError: normalized.isError,
|
|
760
|
+
timestamp: now,
|
|
761
|
+
});
|
|
762
|
+
latestStatusText = normalized.isError ? "Task failed" : "Task complete";
|
|
763
|
+
await refreshRunCard(normalized.isError ? "error" : "success");
|
|
764
|
+
if (outputConfig.result_summary) {
|
|
765
|
+
await streamer.flush();
|
|
766
|
+
await event.reply({
|
|
767
|
+
embeds: [
|
|
768
|
+
buildResultSummaryEmbed({
|
|
769
|
+
agentName: qualifiedName,
|
|
770
|
+
isError: normalized.isError,
|
|
771
|
+
durationMs: normalized.durationMs,
|
|
772
|
+
numTurns: normalized.numTurns,
|
|
773
|
+
totalCostUsd: normalized.totalCostUsd,
|
|
774
|
+
usage: normalized.usage,
|
|
775
|
+
}),
|
|
776
|
+
],
|
|
777
|
+
});
|
|
778
|
+
embedsSent++;
|
|
779
|
+
}
|
|
780
|
+
continue;
|
|
426
781
|
}
|
|
427
|
-
if (
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
name: "Tokens",
|
|
432
|
-
value: `${inputTokens.toLocaleString()} in / ${outputTokens.toLocaleString()} out`,
|
|
433
|
-
inline: true,
|
|
782
|
+
if (normalized.kind === "error" && outputConfig.errors) {
|
|
783
|
+
await streamer.flush();
|
|
784
|
+
await event.reply({
|
|
785
|
+
embeds: [buildErrorEmbed(normalized.message, qualifiedName)],
|
|
434
786
|
});
|
|
787
|
+
embedsSent++;
|
|
435
788
|
}
|
|
436
|
-
const isError = resultMessage.is_error === true;
|
|
437
|
-
await streamer.flush();
|
|
438
|
-
await event.reply({
|
|
439
|
-
embeds: [
|
|
440
|
-
{
|
|
441
|
-
title: isError ? "\u274C Task Failed" : "\u2705 Task Complete",
|
|
442
|
-
color: isError
|
|
443
|
-
? DiscordManager.EMBED_COLOR_ERROR
|
|
444
|
-
: DiscordManager.EMBED_COLOR_SUCCESS,
|
|
445
|
-
fields,
|
|
446
|
-
},
|
|
447
|
-
],
|
|
448
|
-
});
|
|
449
|
-
embedsSent++;
|
|
450
|
-
}
|
|
451
|
-
// Show SDK error messages
|
|
452
|
-
if (message.type === "error" && outputConfig.errors) {
|
|
453
|
-
const errorText = typeof message.content === "string" ? message.content : "An unknown error occurred";
|
|
454
|
-
await streamer.flush();
|
|
455
|
-
await event.reply({
|
|
456
|
-
embeds: [
|
|
457
|
-
{
|
|
458
|
-
title: "\u274C Error",
|
|
459
|
-
description: errorText.length > 4000 ? `${errorText.substring(0, 4000)}...` : errorText,
|
|
460
|
-
color: DiscordManager.EMBED_COLOR_ERROR,
|
|
461
|
-
},
|
|
462
|
-
],
|
|
463
|
-
});
|
|
464
|
-
embedsSent++;
|
|
465
789
|
}
|
|
466
790
|
},
|
|
467
791
|
});
|
|
@@ -471,18 +795,60 @@ export class DiscordManager {
|
|
|
471
795
|
stopTyping();
|
|
472
796
|
typingStopped = true;
|
|
473
797
|
}
|
|
798
|
+
// Fall back to SDK result text if no answer turns produced text
|
|
799
|
+
if (!sentAnswer && !streamer.hasSentMessages() && resultText) {
|
|
800
|
+
logger.debug("No answer turns produced text — using SDK result text as fallback");
|
|
801
|
+
await streamer.addMessageAndSend(resultText);
|
|
802
|
+
sentAnswer = true;
|
|
803
|
+
}
|
|
474
804
|
// Flush any remaining buffered content
|
|
475
805
|
await streamer.flush();
|
|
806
|
+
// Send any remaining buffered files that weren't attached to an answer.
|
|
807
|
+
// This handles the case where the agent uploaded files but produced no text answer.
|
|
808
|
+
if (pendingFiles.length > 0) {
|
|
809
|
+
const files = pendingFiles.splice(0);
|
|
810
|
+
logger.debug(`Sending ${files.length} remaining buffered file(s) as standalone message`);
|
|
811
|
+
await event.reply({
|
|
812
|
+
files: files.map((f) => ({ attachment: f.buffer, name: f.filename })),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
476
815
|
logger.debug(`Discord job completed: ${result.jobId} for agent '${qualifiedName}'${result.sessionId ? ` (session: ${result.sessionId})` : ""}`);
|
|
477
|
-
|
|
478
|
-
|
|
816
|
+
if (progressState.handle) {
|
|
817
|
+
try {
|
|
818
|
+
await progressState.handle.edit({
|
|
819
|
+
embeds: [
|
|
820
|
+
buildRunCardEmbed({
|
|
821
|
+
agentName: qualifiedName,
|
|
822
|
+
status: result.success ? "success" : "error",
|
|
823
|
+
message: result.success ? "Task complete" : "Task failed",
|
|
824
|
+
traceLines,
|
|
825
|
+
}),
|
|
826
|
+
],
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
catch (progressError) {
|
|
830
|
+
logger.warn(`Failed to finalize run card: ${progressError.message}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// If no text messages were sent, send an appropriate fallback.
|
|
834
|
+
// When embedsSent > 0 but no text was delivered, the user saw tool/result embeds
|
|
835
|
+
// but may have missed the final answer. Show a brief completion indicator.
|
|
836
|
+
if (!sentAnswer && !streamer.hasSentMessages() && embedsSent === 0) {
|
|
479
837
|
if (result.success) {
|
|
480
|
-
await event.reply(
|
|
838
|
+
await event.reply({
|
|
839
|
+
embeds: [
|
|
840
|
+
buildStatusEmbed("Task completed — no additional output to share.", "info", qualifiedName),
|
|
841
|
+
],
|
|
842
|
+
});
|
|
481
843
|
}
|
|
482
844
|
else {
|
|
483
845
|
// Job failed without streaming any messages - send error details
|
|
484
846
|
const errorMessage = result.errorDetails?.message ?? result.error?.message ?? "An unknown error occurred";
|
|
485
|
-
await event.reply(
|
|
847
|
+
await event.reply({
|
|
848
|
+
embeds: [
|
|
849
|
+
buildErrorEmbed(`${errorMessage}\n\nThe task could not be completed.`, qualifiedName),
|
|
850
|
+
],
|
|
851
|
+
});
|
|
486
852
|
}
|
|
487
853
|
// Stop typing after sending fallback message (if not already stopped)
|
|
488
854
|
if (!typingStopped) {
|
|
@@ -518,9 +884,26 @@ export class DiscordManager {
|
|
|
518
884
|
catch (error) {
|
|
519
885
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
520
886
|
logger.error(`Discord message handling failed for agent '${qualifiedName}': ${err.message}`);
|
|
887
|
+
if (progressState.handle) {
|
|
888
|
+
try {
|
|
889
|
+
await progressState.handle.edit({
|
|
890
|
+
embeds: [
|
|
891
|
+
buildRunCardEmbed({
|
|
892
|
+
agentName: qualifiedName,
|
|
893
|
+
status: "error",
|
|
894
|
+
message: `Task failed · ${err.message}`,
|
|
895
|
+
traceLines,
|
|
896
|
+
}),
|
|
897
|
+
],
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
catch (progressError) {
|
|
901
|
+
logger.warn(`Failed to finalize failed run card: ${progressError.message}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
521
904
|
// Send user-friendly error message using the formatted error method
|
|
522
905
|
try {
|
|
523
|
-
await event.reply(this.formatErrorMessage(err));
|
|
906
|
+
await event.reply(this.formatErrorMessage(err, qualifiedName));
|
|
524
907
|
}
|
|
525
908
|
catch (replyError) {
|
|
526
909
|
logger.error(`Failed to send error reply: ${replyError.message}`);
|
|
@@ -535,102 +918,33 @@ export class DiscordManager {
|
|
|
535
918
|
});
|
|
536
919
|
}
|
|
537
920
|
finally {
|
|
921
|
+
this.activeJobsByChannel.delete(this.getChannelKey(qualifiedName, event.metadata.channelId));
|
|
538
922
|
// Safety net: stop typing indicator if not already stopped
|
|
539
923
|
// (Should already be stopped after sending messages, but this ensures cleanup on errors)
|
|
540
924
|
if (!typingStopped) {
|
|
541
925
|
stopTyping();
|
|
542
926
|
}
|
|
927
|
+
// Remove acknowledgement reaction now that processing is complete
|
|
928
|
+
if (ackEmoji) {
|
|
929
|
+
try {
|
|
930
|
+
await event.removeReaction(ackEmoji);
|
|
931
|
+
}
|
|
932
|
+
catch (reactionError) {
|
|
933
|
+
logger.warn(`Failed to remove ack reaction: ${reactionError.message}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// Clean up downloaded attachment files if configured
|
|
937
|
+
if (attachmentDownloadedPaths.length > 0 &&
|
|
938
|
+
attachmentConfig?.cleanup_after_processing !== false) {
|
|
939
|
+
try {
|
|
940
|
+
await DiscordManager.cleanupAttachments(attachmentDownloadedPaths, logger);
|
|
941
|
+
}
|
|
942
|
+
catch (cleanupError) {
|
|
943
|
+
logger.warn(`Failed to cleanup attachments: ${cleanupError.message}`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
543
946
|
}
|
|
544
947
|
}
|
|
545
|
-
// =============================================================================
|
|
546
|
-
// Tool Embed Support
|
|
547
|
-
// =============================================================================
|
|
548
|
-
/** Maximum characters for tool output in Discord embed fields */
|
|
549
|
-
static TOOL_OUTPUT_MAX_CHARS = 900;
|
|
550
|
-
/** Embed colors */
|
|
551
|
-
static EMBED_COLOR_DEFAULT = 0x5865f2; // Discord blurple
|
|
552
|
-
static EMBED_COLOR_ERROR = 0xef4444; // Red
|
|
553
|
-
static EMBED_COLOR_SYSTEM = 0x95a5a6; // Gray
|
|
554
|
-
static EMBED_COLOR_SUCCESS = 0x57f287; // Green
|
|
555
|
-
/**
|
|
556
|
-
* Format duration in milliseconds to a human-readable string
|
|
557
|
-
*/
|
|
558
|
-
static formatDuration(ms) {
|
|
559
|
-
if (ms < 1000)
|
|
560
|
-
return `${ms}ms`;
|
|
561
|
-
const seconds = Math.floor(ms / 1000);
|
|
562
|
-
if (seconds < 60)
|
|
563
|
-
return `${seconds}s`;
|
|
564
|
-
const minutes = Math.floor(seconds / 60);
|
|
565
|
-
const remainingSeconds = seconds % 60;
|
|
566
|
-
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
567
|
-
}
|
|
568
|
-
/**
|
|
569
|
-
* Build a Discord embed for a tool call result
|
|
570
|
-
*
|
|
571
|
-
* Combines the tool_use info (name, input) with the tool_result
|
|
572
|
-
* (output, error status) into a compact Discord embed.
|
|
573
|
-
*
|
|
574
|
-
* @param toolUse - The tool_use block info (name, input, startTime)
|
|
575
|
-
* @param toolResult - The tool result (output, isError)
|
|
576
|
-
* @param maxOutputChars - Maximum characters for output (defaults to TOOL_OUTPUT_MAX_CHARS)
|
|
577
|
-
*/
|
|
578
|
-
buildToolEmbed(toolUse, toolResult, maxOutputChars) {
|
|
579
|
-
const toolName = toolUse?.name ?? "Tool";
|
|
580
|
-
const emoji = TOOL_EMOJIS[toolName] ?? "\u{1F527}"; // wrench fallback
|
|
581
|
-
const isError = toolResult.isError;
|
|
582
|
-
// Build description from input summary
|
|
583
|
-
const inputSummary = toolUse ? getToolInputSummary(toolUse.name, toolUse.input) : undefined;
|
|
584
|
-
let description;
|
|
585
|
-
if (inputSummary) {
|
|
586
|
-
if (toolName === "Bash" || toolName === "bash") {
|
|
587
|
-
description = `\`> ${inputSummary}\``;
|
|
588
|
-
}
|
|
589
|
-
else {
|
|
590
|
-
description = `\`${inputSummary}\``;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
// Build inline fields
|
|
594
|
-
const fields = [];
|
|
595
|
-
if (toolUse) {
|
|
596
|
-
const durationMs = Date.now() - toolUse.startTime;
|
|
597
|
-
fields.push({
|
|
598
|
-
name: "Duration",
|
|
599
|
-
value: DiscordManager.formatDuration(durationMs),
|
|
600
|
-
inline: true,
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
const outputLength = toolResult.output.length;
|
|
604
|
-
fields.push({
|
|
605
|
-
name: "Output",
|
|
606
|
-
value: outputLength >= 1000
|
|
607
|
-
? `${(outputLength / 1000).toFixed(1)}k chars`
|
|
608
|
-
: `${outputLength} chars`,
|
|
609
|
-
inline: true,
|
|
610
|
-
});
|
|
611
|
-
// Add truncated output as a field if non-empty
|
|
612
|
-
const trimmedOutput = toolResult.output.trim();
|
|
613
|
-
if (trimmedOutput.length > 0) {
|
|
614
|
-
const maxChars = maxOutputChars ?? DiscordManager.TOOL_OUTPUT_MAX_CHARS;
|
|
615
|
-
let outputText = trimmedOutput;
|
|
616
|
-
if (outputText.length > maxChars) {
|
|
617
|
-
outputText =
|
|
618
|
-
outputText.substring(0, maxChars) +
|
|
619
|
-
`\n... (${outputLength.toLocaleString()} chars total)`;
|
|
620
|
-
}
|
|
621
|
-
fields.push({
|
|
622
|
-
name: isError ? "Error" : "Result",
|
|
623
|
-
value: `\`\`\`\n${outputText}\n\`\`\``,
|
|
624
|
-
inline: false,
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
return {
|
|
628
|
-
title: `${emoji} ${toolName}`,
|
|
629
|
-
description,
|
|
630
|
-
color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_DEFAULT,
|
|
631
|
-
fields,
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
948
|
/**
|
|
635
949
|
* Handle errors from Discord connectors
|
|
636
950
|
*
|
|
@@ -651,6 +965,345 @@ export class DiscordManager {
|
|
|
651
965
|
timestamp: new Date().toISOString(),
|
|
652
966
|
});
|
|
653
967
|
}
|
|
968
|
+
getChannelKey(qualifiedName, channelId) {
|
|
969
|
+
return `${qualifiedName}:${channelId}`;
|
|
970
|
+
}
|
|
971
|
+
async stopChannelRun(qualifiedName, channelId) {
|
|
972
|
+
const key = this.getChannelKey(qualifiedName, channelId);
|
|
973
|
+
const jobId = this.activeJobsByChannel.get(key);
|
|
974
|
+
if (!jobId) {
|
|
975
|
+
return {
|
|
976
|
+
success: false,
|
|
977
|
+
message: "No active run found for this channel.",
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
const fleetManager = this.ctx.getEmitter();
|
|
982
|
+
await fleetManager.cancelJob(jobId);
|
|
983
|
+
this.activeJobsByChannel.delete(key);
|
|
984
|
+
return {
|
|
985
|
+
success: true,
|
|
986
|
+
message: `Stop requested for job \`${jobId}\`.`,
|
|
987
|
+
jobId,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
return {
|
|
992
|
+
success: false,
|
|
993
|
+
message: `Failed to stop active run: ${error.message}`,
|
|
994
|
+
jobId,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
async retryChannelRun(qualifiedName, channelId) {
|
|
999
|
+
const key = this.getChannelKey(qualifiedName, channelId);
|
|
1000
|
+
const lastPrompt = this.lastPromptByChannel.get(key);
|
|
1001
|
+
if (!lastPrompt) {
|
|
1002
|
+
return {
|
|
1003
|
+
success: false,
|
|
1004
|
+
message: "No previous prompt found to retry in this channel.",
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return this.runChannelPrompt(qualifiedName, channelId, lastPrompt);
|
|
1008
|
+
}
|
|
1009
|
+
// ===========================================================================
|
|
1010
|
+
// Synthetic Event Helpers
|
|
1011
|
+
// ===========================================================================
|
|
1012
|
+
toTextChannel(channel) {
|
|
1013
|
+
return channel;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Build a DiscordMessageEvent from scratch for programmatic triggers
|
|
1017
|
+
* (e.g. /retry). This keeps the boilerplate in one place so any new
|
|
1018
|
+
* fields on DiscordMessageEvent only need updating here.
|
|
1019
|
+
*/
|
|
1020
|
+
createSyntheticMessageEvent(params) {
|
|
1021
|
+
const { qualifiedName, prompt, channelId, channel, textChannel, logger } = params;
|
|
1022
|
+
return {
|
|
1023
|
+
agentName: qualifiedName,
|
|
1024
|
+
prompt,
|
|
1025
|
+
context: {
|
|
1026
|
+
messages: [],
|
|
1027
|
+
prompt,
|
|
1028
|
+
wasMentioned: false,
|
|
1029
|
+
},
|
|
1030
|
+
metadata: {
|
|
1031
|
+
guildId: typeof channel.isDMBased === "function" && channel.isDMBased()
|
|
1032
|
+
? null
|
|
1033
|
+
: typeof channel.guildId === "string"
|
|
1034
|
+
? channel.guildId
|
|
1035
|
+
: null,
|
|
1036
|
+
channelId,
|
|
1037
|
+
messageId: `synthetic-${randomUUID()}`,
|
|
1038
|
+
userId: "system",
|
|
1039
|
+
username: "system",
|
|
1040
|
+
wasMentioned: false,
|
|
1041
|
+
mode: "auto",
|
|
1042
|
+
},
|
|
1043
|
+
reply: async (content) => {
|
|
1044
|
+
await textChannel.send(content);
|
|
1045
|
+
},
|
|
1046
|
+
replyWithRef: async (content) => {
|
|
1047
|
+
const sent = await textChannel.send(content);
|
|
1048
|
+
return {
|
|
1049
|
+
edit: async (c) => {
|
|
1050
|
+
await sent.edit(c);
|
|
1051
|
+
},
|
|
1052
|
+
delete: async () => {
|
|
1053
|
+
await sent.delete();
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
},
|
|
1057
|
+
startTyping: () => {
|
|
1058
|
+
let typingInterval = null;
|
|
1059
|
+
if (textChannel.sendTyping) {
|
|
1060
|
+
void textChannel.sendTyping().catch((err) => {
|
|
1061
|
+
logger.debug(`Synthetic typing indicator failed: ${err.message}`);
|
|
1062
|
+
});
|
|
1063
|
+
typingInterval = setInterval(() => {
|
|
1064
|
+
void textChannel.sendTyping?.().catch((err) => {
|
|
1065
|
+
logger.debug(`Synthetic typing refresh failed: ${err.message}`);
|
|
1066
|
+
});
|
|
1067
|
+
}, 8000);
|
|
1068
|
+
}
|
|
1069
|
+
return () => {
|
|
1070
|
+
if (typingInterval) {
|
|
1071
|
+
clearInterval(typingInterval);
|
|
1072
|
+
typingInterval = null;
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
},
|
|
1076
|
+
addReaction: async () => { },
|
|
1077
|
+
removeReaction: async () => { },
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
async getChannelRunInfo(qualifiedName, channelId) {
|
|
1081
|
+
const key = this.getChannelKey(qualifiedName, channelId);
|
|
1082
|
+
return {
|
|
1083
|
+
activeJobId: this.activeJobsByChannel.get(key),
|
|
1084
|
+
lastPrompt: this.lastPromptByChannel.get(key),
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
async getChannelUsage(qualifiedName, channelId) {
|
|
1088
|
+
return this.lastUsageByChannel.get(this.getChannelKey(qualifiedName, channelId)) ?? null;
|
|
1089
|
+
}
|
|
1090
|
+
accumulateUsage(qualifiedName, run) {
|
|
1091
|
+
const existing = this.cumulativeUsageByAgent.get(qualifiedName);
|
|
1092
|
+
if (existing) {
|
|
1093
|
+
existing.totalRuns++;
|
|
1094
|
+
if (run.isError)
|
|
1095
|
+
existing.totalFailures++;
|
|
1096
|
+
else
|
|
1097
|
+
existing.totalSuccesses++;
|
|
1098
|
+
existing.totalCostUsd += run.totalCostUsd ?? 0;
|
|
1099
|
+
existing.totalInputTokens += run.inputTokens ?? 0;
|
|
1100
|
+
existing.totalOutputTokens += run.outputTokens ?? 0;
|
|
1101
|
+
existing.totalDurationMs += run.durationMs ?? 0;
|
|
1102
|
+
existing.lastRunAt = run.timestamp;
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
this.cumulativeUsageByAgent.set(qualifiedName, {
|
|
1106
|
+
totalRuns: 1,
|
|
1107
|
+
totalSuccesses: run.isError ? 0 : 1,
|
|
1108
|
+
totalFailures: run.isError ? 1 : 0,
|
|
1109
|
+
totalCostUsd: run.totalCostUsd ?? 0,
|
|
1110
|
+
totalInputTokens: run.inputTokens ?? 0,
|
|
1111
|
+
totalOutputTokens: run.outputTokens ?? 0,
|
|
1112
|
+
totalDurationMs: run.durationMs ?? 0,
|
|
1113
|
+
firstRunAt: run.timestamp,
|
|
1114
|
+
lastRunAt: run.timestamp,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
async getAgentCumulativeUsage(qualifiedName) {
|
|
1119
|
+
return (this.cumulativeUsageByAgent.get(qualifiedName) ?? {
|
|
1120
|
+
totalRuns: 0,
|
|
1121
|
+
totalSuccesses: 0,
|
|
1122
|
+
totalFailures: 0,
|
|
1123
|
+
totalCostUsd: 0,
|
|
1124
|
+
totalInputTokens: 0,
|
|
1125
|
+
totalOutputTokens: 0,
|
|
1126
|
+
totalDurationMs: 0,
|
|
1127
|
+
firstRunAt: "",
|
|
1128
|
+
lastRunAt: "",
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
async runChannelSkill(qualifiedName, channelId, skillName, input) {
|
|
1132
|
+
const agent = this.getAgentByQualifiedName(qualifiedName);
|
|
1133
|
+
if (!agent) {
|
|
1134
|
+
return {
|
|
1135
|
+
success: false,
|
|
1136
|
+
message: `Agent '${qualifiedName}' is not available.`,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
const availableSkills = await this.discoverAgentSkills(agent);
|
|
1140
|
+
if (availableSkills.length === 0) {
|
|
1141
|
+
return {
|
|
1142
|
+
success: false,
|
|
1143
|
+
message: "No skills are configured/discovered for this agent. Configure `chat.discord.skills` or ensure skills exist in the agent working directory.",
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
const matchedSkill = availableSkills.find((s) => s.name === skillName);
|
|
1147
|
+
if (!matchedSkill) {
|
|
1148
|
+
return {
|
|
1149
|
+
success: false,
|
|
1150
|
+
message: `Unknown skill \`${skillName}\`. Use \`/skills\` to see available skills.`,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
const instruction = [
|
|
1154
|
+
`Use the "${matchedSkill.name}" skill to handle this request.`,
|
|
1155
|
+
"",
|
|
1156
|
+
input?.trim() ? input.trim() : "No additional input was provided.",
|
|
1157
|
+
].join("\n");
|
|
1158
|
+
const result = await this.runChannelPrompt(qualifiedName, channelId, instruction);
|
|
1159
|
+
if (result.success) {
|
|
1160
|
+
return {
|
|
1161
|
+
...result,
|
|
1162
|
+
message: `Skill \`${matchedSkill.name}\` started. Output will be posted in this channel.`,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
return result;
|
|
1166
|
+
}
|
|
1167
|
+
async runChannelPrompt(qualifiedName, channelId, prompt) {
|
|
1168
|
+
const logger = this.ctx.getLogger();
|
|
1169
|
+
const key = this.getChannelKey(qualifiedName, channelId);
|
|
1170
|
+
const activeJobId = this.activeJobsByChannel.get(key);
|
|
1171
|
+
if (activeJobId) {
|
|
1172
|
+
return {
|
|
1173
|
+
success: false,
|
|
1174
|
+
message: `A run is already active in this channel (\`${activeJobId}\`).`,
|
|
1175
|
+
jobId: activeJobId,
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
const connector = this.connectors.get(qualifiedName);
|
|
1179
|
+
if (!connector?.client?.isReady()) {
|
|
1180
|
+
return {
|
|
1181
|
+
success: false,
|
|
1182
|
+
message: "Run cannot start because the Discord connector is not connected.",
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
const channel = await connector.client.channels.fetch(channelId);
|
|
1186
|
+
if (!channel ||
|
|
1187
|
+
!("isTextBased" in channel) ||
|
|
1188
|
+
typeof channel.isTextBased !== "function" ||
|
|
1189
|
+
!channel.isTextBased() ||
|
|
1190
|
+
!("send" in channel) ||
|
|
1191
|
+
typeof channel.send !== "function") {
|
|
1192
|
+
return {
|
|
1193
|
+
success: false,
|
|
1194
|
+
message: "Run failed because this channel is not text-capable.",
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
const textChannel = this.toTextChannel(channel);
|
|
1198
|
+
const syntheticEvent = this.createSyntheticMessageEvent({
|
|
1199
|
+
qualifiedName,
|
|
1200
|
+
prompt,
|
|
1201
|
+
channelId,
|
|
1202
|
+
channel,
|
|
1203
|
+
textChannel,
|
|
1204
|
+
logger,
|
|
1205
|
+
});
|
|
1206
|
+
this.lastPromptByChannel.set(key, prompt);
|
|
1207
|
+
void this.handleMessage(qualifiedName, syntheticEvent)
|
|
1208
|
+
.catch(async (error) => {
|
|
1209
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1210
|
+
logger.error(`Background slash run failed for '${qualifiedName}': ${err.message}`);
|
|
1211
|
+
try {
|
|
1212
|
+
await textChannel.send(this.formatErrorMessage(err, qualifiedName));
|
|
1213
|
+
}
|
|
1214
|
+
catch (replyError) {
|
|
1215
|
+
logger.error(`Failed to send slash failure message: ${replyError.message}`);
|
|
1216
|
+
}
|
|
1217
|
+
})
|
|
1218
|
+
.finally(() => {
|
|
1219
|
+
this.activeJobsByChannel.delete(key);
|
|
1220
|
+
});
|
|
1221
|
+
return {
|
|
1222
|
+
success: true,
|
|
1223
|
+
message: "Run started. Output will be posted in this channel.",
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
async discoverAgentSkills(agent) {
|
|
1227
|
+
const discovered = new Map();
|
|
1228
|
+
// Step 1: if skills are explicitly configured, use them as-is.
|
|
1229
|
+
// - undefined → not configured, fall through to auto-discovery
|
|
1230
|
+
// - [] → explicitly disabled, return empty
|
|
1231
|
+
// - [...] → use exactly those skills
|
|
1232
|
+
const configuredSkills = this.getConfiguredDiscordSkills(agent);
|
|
1233
|
+
if (configuredSkills !== undefined) {
|
|
1234
|
+
return configuredSkills
|
|
1235
|
+
.filter((s) => s.name.trim().length > 0)
|
|
1236
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1237
|
+
}
|
|
1238
|
+
// Step 2: auto-discover from agent working directory paths.
|
|
1239
|
+
const workingDir = typeof agent.working_directory === "string"
|
|
1240
|
+
? agent.working_directory
|
|
1241
|
+
: agent.working_directory?.root;
|
|
1242
|
+
if (!workingDir) {
|
|
1243
|
+
return Array.from(discovered.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
1244
|
+
}
|
|
1245
|
+
const candidateDirs = [
|
|
1246
|
+
join(workingDir, ".claude", "skills"),
|
|
1247
|
+
join(workingDir, ".codex", "skills"),
|
|
1248
|
+
join(workingDir, "skills"),
|
|
1249
|
+
];
|
|
1250
|
+
for (const dir of candidateDirs) {
|
|
1251
|
+
try {
|
|
1252
|
+
const entries = await readdir(resolve(dir), { withFileTypes: true });
|
|
1253
|
+
for (const entry of entries) {
|
|
1254
|
+
if (!entry.isDirectory())
|
|
1255
|
+
continue;
|
|
1256
|
+
const skillName = entry.name;
|
|
1257
|
+
const skillPath = join(dir, skillName, "SKILL.md");
|
|
1258
|
+
try {
|
|
1259
|
+
const raw = await readFile(skillPath, "utf8");
|
|
1260
|
+
const lines = raw.split("\n").map((line) => line.trim());
|
|
1261
|
+
const description = lines.find((line) => line.length > 0 && !line.startsWith("#"))?.slice(0, 120) ??
|
|
1262
|
+
undefined;
|
|
1263
|
+
discovered.set(skillName, { name: skillName, description });
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
// Ignore directories without readable SKILL.md
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
catch {
|
|
1271
|
+
// Directory does not exist or is unreadable; skip.
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return Array.from(discovered.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
1275
|
+
}
|
|
1276
|
+
getAgentByQualifiedName(qualifiedName) {
|
|
1277
|
+
const config = this.ctx.getConfig();
|
|
1278
|
+
return config?.agents.find((agent) => agent.qualifiedName === qualifiedName);
|
|
1279
|
+
}
|
|
1280
|
+
getConfiguredDiscordSkills(agent) {
|
|
1281
|
+
const discord = agent.chat?.discord;
|
|
1282
|
+
if (!discord || !("skills" in discord)) {
|
|
1283
|
+
return undefined; // not configured → auto-discover
|
|
1284
|
+
}
|
|
1285
|
+
const skills = discord.skills;
|
|
1286
|
+
if (!Array.isArray(skills)) {
|
|
1287
|
+
return undefined;
|
|
1288
|
+
}
|
|
1289
|
+
// [] → explicitly empty (no skills); [...] → those exact skills
|
|
1290
|
+
return skills
|
|
1291
|
+
.filter((skill) => typeof skill?.name === "string")
|
|
1292
|
+
.map((skill) => ({ name: skill.name, description: skill.description }));
|
|
1293
|
+
}
|
|
1294
|
+
async getAgentConfigSummary(agent) {
|
|
1295
|
+
return {
|
|
1296
|
+
runtime: agent.runtime,
|
|
1297
|
+
model: agent.model,
|
|
1298
|
+
permissionMode: agent.permission_mode,
|
|
1299
|
+
workingDirectory: typeof agent.working_directory === "string"
|
|
1300
|
+
? agent.working_directory
|
|
1301
|
+
: agent.working_directory?.root,
|
|
1302
|
+
allowedTools: agent.allowed_tools,
|
|
1303
|
+
deniedTools: agent.denied_tools,
|
|
1304
|
+
mcpServers: agent.mcp_servers ? Object.keys(agent.mcp_servers) : [],
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
654
1307
|
// ===========================================================================
|
|
655
1308
|
// Response Formatting and Splitting
|
|
656
1309
|
// ===========================================================================
|
|
@@ -660,12 +1313,21 @@ export class DiscordManager {
|
|
|
660
1313
|
* Format an error message for Discord display
|
|
661
1314
|
*
|
|
662
1315
|
* Creates a user-friendly error message with guidance on how to proceed.
|
|
1316
|
+
* Returns an embed when agentName is provided, plain text otherwise.
|
|
663
1317
|
*
|
|
664
1318
|
* @param error - The error that occurred
|
|
665
|
-
* @
|
|
1319
|
+
* @param agentName - Optional agent name for embed footer
|
|
1320
|
+
* @returns Formatted error message string or embed payload
|
|
666
1321
|
*/
|
|
667
|
-
formatErrorMessage(error) {
|
|
668
|
-
|
|
1322
|
+
formatErrorMessage(error, agentName) {
|
|
1323
|
+
if (agentName) {
|
|
1324
|
+
return {
|
|
1325
|
+
embeds: [
|
|
1326
|
+
buildErrorEmbed(`${error.message}\n\nTry again or use \`/reset\` to start a new session.`, agentName),
|
|
1327
|
+
],
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
return `**Error:** ${error.message}\n\nTry again or use \`/reset\` to start a new session.`;
|
|
669
1331
|
}
|
|
670
1332
|
/**
|
|
671
1333
|
* Split a response into chunks that fit Discord's 2000 character limit
|
|
@@ -691,5 +1353,157 @@ export class DiscordManager {
|
|
|
691
1353
|
await reply(chunk);
|
|
692
1354
|
}
|
|
693
1355
|
}
|
|
1356
|
+
// ===========================================================================
|
|
1357
|
+
// Utility Methods
|
|
1358
|
+
// ===========================================================================
|
|
1359
|
+
/**
|
|
1360
|
+
* Resolve the agent's working directory to an absolute path string
|
|
1361
|
+
*/
|
|
1362
|
+
resolveWorkingDirectory(agent) {
|
|
1363
|
+
if (!agent.working_directory) {
|
|
1364
|
+
return undefined;
|
|
1365
|
+
}
|
|
1366
|
+
if (typeof agent.working_directory === "string") {
|
|
1367
|
+
return agent.working_directory;
|
|
1368
|
+
}
|
|
1369
|
+
return agent.working_directory.root;
|
|
1370
|
+
}
|
|
1371
|
+
// ===========================================================================
|
|
1372
|
+
// Attachment Processing
|
|
1373
|
+
// ===========================================================================
|
|
1374
|
+
/** Maximum characters to inline for text/code file content */
|
|
1375
|
+
static TEXT_INLINE_MAX_CHARS = 50_000;
|
|
1376
|
+
/**
|
|
1377
|
+
* Check if a content type matches a MIME pattern (supports wildcards like "image/*")
|
|
1378
|
+
*/
|
|
1379
|
+
static matchesMimePattern(contentType, pattern) {
|
|
1380
|
+
const ct = contentType.toLowerCase().split(";")[0].trim();
|
|
1381
|
+
const pat = pattern.toLowerCase().trim();
|
|
1382
|
+
if (pat === ct)
|
|
1383
|
+
return true;
|
|
1384
|
+
if (pat.endsWith("/*")) {
|
|
1385
|
+
const prefix = pat.slice(0, -1); // "image/*" → "image/"
|
|
1386
|
+
return ct.startsWith(prefix);
|
|
1387
|
+
}
|
|
1388
|
+
return false;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Process file attachments: download, categorize, and prepare prompt sections.
|
|
1392
|
+
*
|
|
1393
|
+
* - Text/code files are inlined directly into the prompt
|
|
1394
|
+
* - Images and PDFs are saved to disk so the agent can use its Read tool
|
|
1395
|
+
*
|
|
1396
|
+
* Returns prompt sections to prepend, paths of downloaded files for cleanup,
|
|
1397
|
+
* and a list of skipped files with reasons.
|
|
1398
|
+
*/
|
|
1399
|
+
static async processAttachments(attachments, config, workingDir, logger) {
|
|
1400
|
+
const promptSections = [];
|
|
1401
|
+
const downloadedPaths = [];
|
|
1402
|
+
const skippedFiles = [];
|
|
1403
|
+
const maxBytes = config.max_file_size_mb * 1024 * 1024;
|
|
1404
|
+
// Limit to max_files_per_message
|
|
1405
|
+
const toProcess = attachments.slice(0, config.max_files_per_message);
|
|
1406
|
+
if (attachments.length > config.max_files_per_message) {
|
|
1407
|
+
const skipped = attachments.slice(config.max_files_per_message);
|
|
1408
|
+
for (const a of skipped) {
|
|
1409
|
+
skippedFiles.push({ name: a.name, reason: "exceeded max_files_per_message" });
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
// Create one collision-resistant directory per message processing run.
|
|
1413
|
+
const messageDownloadDir = randomUUID();
|
|
1414
|
+
for (const attachment of toProcess) {
|
|
1415
|
+
// Check allowed types
|
|
1416
|
+
const allowed = config.allowed_types.some((pattern) => DiscordManager.matchesMimePattern(attachment.contentType, pattern));
|
|
1417
|
+
if (!allowed) {
|
|
1418
|
+
skippedFiles.push({
|
|
1419
|
+
name: attachment.name,
|
|
1420
|
+
reason: `type ${attachment.contentType} not in allowed_types`,
|
|
1421
|
+
});
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
// Check file size
|
|
1425
|
+
if (attachment.size > maxBytes) {
|
|
1426
|
+
skippedFiles.push({
|
|
1427
|
+
name: attachment.name,
|
|
1428
|
+
reason: `size ${attachment.size} exceeds ${config.max_file_size_mb}MB limit`,
|
|
1429
|
+
});
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
try {
|
|
1433
|
+
if (attachment.category === "text") {
|
|
1434
|
+
// Text/code: download and inline
|
|
1435
|
+
const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) });
|
|
1436
|
+
if (!response.ok) {
|
|
1437
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1438
|
+
}
|
|
1439
|
+
let text = await response.text();
|
|
1440
|
+
if (text.length > DiscordManager.TEXT_INLINE_MAX_CHARS) {
|
|
1441
|
+
text = `${text.substring(0, DiscordManager.TEXT_INLINE_MAX_CHARS)}\n... [truncated at ${DiscordManager.TEXT_INLINE_MAX_CHARS} chars]`;
|
|
1442
|
+
}
|
|
1443
|
+
promptSections.push(`--- File: ${attachment.name} (${attachment.contentType}) ---\n${text}\n--- End of ${attachment.name} ---`);
|
|
1444
|
+
}
|
|
1445
|
+
else {
|
|
1446
|
+
// Image/PDF: download to disk
|
|
1447
|
+
if (!workingDir) {
|
|
1448
|
+
skippedFiles.push({
|
|
1449
|
+
name: attachment.name,
|
|
1450
|
+
reason: "no working_directory configured for binary attachments",
|
|
1451
|
+
});
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
const response = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) });
|
|
1455
|
+
if (!response.ok) {
|
|
1456
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1457
|
+
}
|
|
1458
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1459
|
+
const downloadDir = join(workingDir, config.download_dir, messageDownloadDir);
|
|
1460
|
+
await mkdir(downloadDir, { recursive: true });
|
|
1461
|
+
const filePath = join(downloadDir, `${attachment.id}-${basename(attachment.name)}`);
|
|
1462
|
+
await writeFile(filePath, buffer);
|
|
1463
|
+
downloadedPaths.push(filePath);
|
|
1464
|
+
const typeLabel = attachment.category === "image" ? "Image" : "PDF";
|
|
1465
|
+
promptSections.push(`[${typeLabel} attached: ${filePath}] (Use the Read tool to view this file)`);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
catch (err) {
|
|
1469
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1470
|
+
logger.warn(`Failed to process attachment ${attachment.name}: ${errMsg}`);
|
|
1471
|
+
skippedFiles.push({
|
|
1472
|
+
name: attachment.name,
|
|
1473
|
+
reason: `download/processing failed: ${errMsg}`,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return { promptSections, downloadedPaths, skippedFiles };
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Clean up downloaded attachment files after processing
|
|
1481
|
+
*/
|
|
1482
|
+
static async cleanupAttachments(paths, logger) {
|
|
1483
|
+
const parentDirs = new Set();
|
|
1484
|
+
for (const filePath of paths) {
|
|
1485
|
+
try {
|
|
1486
|
+
await rm(filePath);
|
|
1487
|
+
// Track parent directory for cleanup
|
|
1488
|
+
const parent = dirname(filePath);
|
|
1489
|
+
if (parent !== filePath && parent !== ".") {
|
|
1490
|
+
parentDirs.add(parent);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
catch (err) {
|
|
1494
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1495
|
+
logger.debug(`Failed to clean up attachment file ${filePath}: ${errMsg}`);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
// Try to remove empty timestamp directories
|
|
1499
|
+
for (const dir of parentDirs) {
|
|
1500
|
+
try {
|
|
1501
|
+
await rm(dir, { recursive: true });
|
|
1502
|
+
}
|
|
1503
|
+
catch {
|
|
1504
|
+
// Directory may not be empty or already removed — ignore
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
694
1508
|
}
|
|
695
1509
|
//# sourceMappingURL=manager.js.map
|