diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 08be9dcba..e0d4645c8 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -341,6 +341,7 @@ export const slackPlugin: ChannelPlugin = { accountId: accountId ?? undefined, }, cfg, + toolContext, ); } diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 4ea178a54..7e07e347f 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -112,6 +112,12 @@ function buildFetchSchema() { around: Type.Optional(Type.String()), fromMe: Type.Optional(Type.Boolean()), includeArchived: Type.Optional(Type.Boolean()), + threadId: Type.Optional( + Type.String({ + description: + "Thread ID (ts) to read replies from. Required for reading Slack thread messages.", + }), + ), }; } diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 00d567428..76e064dfe 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -214,13 +214,22 @@ export async function handleSlackAction( typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); - const threadId = readStringParam(params, "threadId"); + const explicitThreadId = readStringParam(params, "threadId"); + // Auto-inject threadId from context when reading from current channel in a thread. + // For reads, always inject if we're in a thread context and reading from the same channel. + let threadId = explicitThreadId ?? undefined; + if (!threadId && context?.currentThreadTs && context?.currentChannelId) { + const parsedTarget = parseSlackTarget(channelId, { defaultKind: "channel" }); + if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) { + threadId = context.currentThreadTs; + } + } const result = await readSlackMessages(channelId, { ...readOpts, limit, before: before ?? undefined, after: after ?? undefined, - threadId: threadId ?? undefined, + threadId, }); const messages = result.messages.map((message) => withNormalizedTimestamp( diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index ba96023ce..aad1d8c47 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -231,6 +231,10 @@ export async function runPreparedReply( isNewSession && threadStarterBody ? `[Thread starter - for context]\n${threadStarterBody}` : undefined; + const threadRepliesBody = ctx.ThreadRepliesBody?.trim(); + const threadRepliesNote = threadRepliesBody + ? `[Thread replies - for context]\n${threadRepliesBody}` + : undefined; const skillResult = await ensureSkillSnapshot({ sessionEntry, sessionStore, @@ -245,7 +249,9 @@ export async function runPreparedReply( sessionEntry = skillResult.sessionEntry ?? sessionEntry; currentSystemSent = skillResult.systemSent; const skillsSnapshot = skillResult.skillsSnapshot; - const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n"); + const prefixedBody = [threadStarterNote, threadRepliesNote, prefixedBodyBase] + .filter(Boolean) + .join("\n\n"); const mediaNote = buildInboundMediaNote(ctx); const mediaReplyHint = mediaNote ? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body." diff --git a/src/auto-reply/reply/inbound-context.ts b/src/auto-reply/reply/inbound-context.ts index 1af20f7e9..4e30e3d19 100644 --- a/src/auto-reply/reply/inbound-context.ts +++ b/src/auto-reply/reply/inbound-context.ts @@ -29,6 +29,7 @@ export function finalizeInboundContext>( normalized.CommandBody = normalizeTextField(normalized.CommandBody); normalized.Transcript = normalizeTextField(normalized.Transcript); normalized.ThreadStarterBody = normalizeTextField(normalized.ThreadStarterBody); + normalized.ThreadRepliesBody = normalizeTextField(normalized.ThreadRepliesBody); const chatType = normalizeChatType(normalized.ChatType); if (chatType && (opts.forceChatType || normalized.ChatType !== chatType)) { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 242cee232..156b1cc05 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -58,6 +58,8 @@ export type MsgContext = { ForwardedFromSignature?: string; ForwardedDate?: number; ThreadStarterBody?: string; + /** Thread replies context (recent messages in the thread, excluding starter and current). */ + ThreadRepliesBody?: string; ThreadLabel?: string; MediaPath?: string; MediaUrl?: string; diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index ca8aa6fb8..e2c1be539 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -137,6 +137,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap accountId: accountId ?? undefined, }, cfg, + toolContext, ); } diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 2674e2d50..c9d4a396c 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -90,6 +90,12 @@ export type SlackThreadStarter = { files?: SlackFile[]; }; +export type SlackThreadReply = { + text: string; + userId?: string; + ts?: string; +}; + const THREAD_STARTER_CACHE = new Map(); export async function resolveSlackThreadStarter(params: { @@ -122,3 +128,50 @@ export async function resolveSlackThreadStarter(params: { return null; } } + +/** + * Fetches recent thread replies (excluding the thread starter and a specific message). + * Returns messages in chronological order (oldest first). + */ +export async function resolveSlackThreadReplies(params: { + channelId: string; + threadTs: string; + client: SlackWebClient; + /** Message ts to exclude (usually the current inbound message). */ + excludeTs?: string; + /** Maximum number of replies to fetch (default: 10). */ + limit?: number; +}): Promise { + const limit = params.limit ?? 10; + try { + const response = (await params.client.conversations.replies({ + channel: params.channelId, + ts: params.threadTs, + // Fetch more than limit to account for exclusions. + limit: limit + 2, + inclusive: true, + })) as { messages?: Array<{ text?: string; user?: string; ts?: string }> }; + const messages = response?.messages ?? []; + // Filter out the thread starter (first message) and the excluded message. + const replies = messages + .filter((msg) => { + if (!msg.ts) return false; + // Exclude thread starter (ts === threadTs). + if (msg.ts === params.threadTs) return false; + // Exclude the specified message (usually the current inbound). + if (params.excludeTs && msg.ts === params.excludeTs) return false; + return true; + }) + .map((msg) => ({ + text: (msg.text ?? "").trim(), + userId: msg.user, + ts: msg.ts, + })) + .filter((reply) => reply.text.length > 0); + // Return in chronological order, limited to the specified count. + // conversations.replies returns messages in chronological order by default. + return replies.slice(-limit); + } catch { + return []; + } +} diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 8a2a9e111..f58be6328 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -44,7 +44,11 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; -import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js"; +import { + resolveSlackMedia, + resolveSlackThreadReplies, + resolveSlackThreadStarter, +} from "../media.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -452,6 +456,7 @@ export async function prepareSlackMessage(params: { systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; let threadStarterBody: string | undefined; + let threadRepliesBody: string | undefined; let threadLabel: string | undefined; let threadStarterMedia: Awaited> = null; if (isThreadReply && threadTs) { @@ -489,6 +494,34 @@ export async function prepareSlackMessage(params: { } else { threadLabel = `Slack thread ${roomLabel}`; } + + // Fetch recent thread replies (excluding starter and current message). + const threadReplies = await resolveSlackThreadReplies({ + channelId: message.channel, + threadTs, + client: ctx.app.client, + excludeTs: message.ts, + limit: ctx.historyLimit > 0 ? ctx.historyLimit : 10, + }); + if (threadReplies.length > 0) { + const formattedReplies = await Promise.all( + threadReplies.map(async (reply) => { + const replyUser = reply.userId ? await ctx.resolveUserName(reply.userId) : null; + const replyName = replyUser?.name ?? reply.userId ?? "Unknown"; + const replyWithId = `${reply.text}\n[slack message id: ${reply.ts ?? "unknown"} channel: ${message.channel}]`; + return formatInboundEnvelope({ + channel: "Slack", + from: roomLabel, + timestamp: reply.ts ? Math.round(Number(reply.ts) * 1000) : undefined, + body: replyWithId, + chatType: "channel", + senderLabel: replyName, + envelope: envelopeOptions, + }); + }), + ); + threadRepliesBody = formattedReplies.join("\n\n"); + } } // Use thread starter media if current message has none @@ -516,6 +549,7 @@ export async function prepareSlackMessage(params: { MessageThreadId: threadContext.messageThreadId, ParentSessionKey: threadKeys.parentSessionKey, ThreadStarterBody: threadStarterBody, + ThreadRepliesBody: threadRepliesBody, ThreadLabel: threadLabel, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined,