diff --git a/.gitignore b/.gitignore index 4b0bc606..df6d41b4 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,13 @@ Thumbs.db mcp-servers.json config.json + +# Build artifacts +bin/ + +# Python files +*.py +requirements.txt + +# System files +system.prompt diff --git a/internal/config/config.go b/internal/config/config.go index 90437b53..740b7342 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,7 @@ type LLMConfig struct { CustomPromptFile string `json:"customPromptFile,omitempty"` ReplaceToolPrompt bool `json:"replaceToolPrompt,omitempty"` MaxAgentIterations int `json:"maxAgentIterations,omitempty"` // Maximum agent iterations (default: 20) + ShowThoughts *bool `json:"showThoughts,omitempty"` // Show agent thoughts in output (default: true) Providers map[string]LLMProviderConfig `json:"providers"` } @@ -241,7 +242,7 @@ func (c *Config) applySlackDefaults() { c.Slack.MessageHistory = 50 } if c.Slack.ThinkingMessage == "" { - c.Slack.ThinkingMessage = "Thinking..." + c.Slack.ThinkingMessage = ":thinking_face: _Thinking..._" } } diff --git a/internal/llm/langchain.go b/internal/llm/langchain.go index 3bd2848a..d5758fab 100644 --- a/internal/llm/langchain.go +++ b/internal/llm/langchain.go @@ -209,17 +209,17 @@ Assistant has access to the following tools: agents.WithPromptFormatInstructions(`To use a tool, please use the following format: Observation: [The result of the previous tool call. Only include this field if you just received a tool result.] -Thought: Do I need to use a tool? Yes +> Thought: Do I need to use a tool? Yes Justification: [Why you think you should invoke the tool that you are invoking] Action: [the action to take, should be one of [{{.tool_names}}]] -Action Input: [the input to the action. This should always be a single line JSON object. This should be raw json, no extra quotes or backticks. This field is mutually exclusive with the "AI:" field. There should be no text after this field.] +Action Input: [the input to the action. This should always be a single line JSON object. This should be raw json, no extra quotes or backticks. There should be no text after this field.] Only call one tool at a time, send your response, and wait for the result to be provided in the next message. IMPORTANT: Return ONLY the tool format with no explanations or formatting when using a tool. When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format: -Thought: Do I need to use a tool? No +> Thought: Do I need to use a tool? No AI: [your response here] This field is mutually exclusive with the "Action Input:" field. You must not return both fields in a response. `), // When testing with Gemini, it would often not actually invoke the tool, so we need this to make sure it actually does it diff --git a/internal/slack/agentCallbackHandler.go b/internal/slack/agentCallbackHandler.go index f51615b2..4e45d5e6 100644 --- a/internal/slack/agentCallbackHandler.go +++ b/internal/slack/agentCallbackHandler.go @@ -2,6 +2,9 @@ package slackbot import ( "context" + "encoding/json" + "strings" + "github.com/slack-go/slack" "github.com/tmc/langchaingo/callbacks" ) @@ -10,12 +13,387 @@ type sendMessageFunc func(message string) type agentCallbackHandler struct { callbacks.SimpleHandler sendMessage sendMessageFunc + showThoughts bool + storeFullMessage func(message string) // Callback to store full message in history } func (handler *agentCallbackHandler) HandleChainEnd(_ context.Context, outputs map[string]any) { if text, ok := outputs["text"]; ok { if textStr, ok := text.(string); ok { - handler.sendMessage(textStr) + // Store full message in history if callback provided + if handler.storeFullMessage != nil { + handler.storeFullMessage(textStr) + } + // Format based on showThoughts setting for display + formattedText := formatPlainAgentOutputWithVisibility(textStr, handler.showThoughts) + // Only send non-empty messages + if formattedText != "" { + handler.sendMessage(formattedText) + } } } } + +// formatAgentOutput processes the agent output to create a Block Kit message +func formatAgentOutput(text string) string { + lines := strings.Split(text, "\n") + var blocks []slack.Block + var contextElements []slack.MixedElement + var currentSection strings.Builder + inCodeBlock := false + codeBlockContent := strings.Builder{} + + // Helper function to add context block if elements exist + addContextBlock := func() { + if len(contextElements) > 0 { + blocks = append(blocks, slack.NewContextBlock("", contextElements...)) + contextElements = []slack.MixedElement{} + } + } + + // Helper function to add section block if content exists + addSectionBlock := func() { + if currentSection.Len() > 0 { + blocks = append(blocks, slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", currentSection.String(), false, false), + nil, nil, + )) + currentSection.Reset() + } + } + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Check for code blocks + if strings.HasPrefix(line, "```") { + if inCodeBlock { + // End of code block + addSectionBlock() // Add any pending section + blocks = append(blocks, slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", "```\n"+codeBlockContent.String()+"\n```", false, false), + nil, nil, + )) + codeBlockContent.Reset() + inCodeBlock = false + } else { + // Start of code block + addSectionBlock() // Add any pending section + inCodeBlock = true + } + continue + } + + if inCodeBlock { + if codeBlockContent.Len() > 0 { + codeBlockContent.WriteString("\n") + } + codeBlockContent.WriteString(line) + continue + } + + // Process thoughts and metadata as context blocks + if strings.HasPrefix(trimmedLine, "> Thought:") || strings.HasPrefix(trimmedLine, "Thought:") { + addSectionBlock() // Add any pending section + thoughtContent := strings.TrimPrefix(trimmedLine, "> ") + thoughtContent = strings.TrimPrefix(thoughtContent, "Thought:") + contextElements = append(contextElements, + slack.NewTextBlockObject("mrkdwn", "_Thought:_ "+strings.TrimSpace(thoughtContent), false, false)) + } else if strings.HasPrefix(trimmedLine, "Justification:") { + justContent := strings.TrimPrefix(trimmedLine, "Justification:") + contextElements = append(contextElements, + slack.NewTextBlockObject("mrkdwn", "_Justification:_ "+strings.TrimSpace(justContent), false, false)) + } else if strings.HasPrefix(trimmedLine, "Action:") { + actionContent := strings.TrimPrefix(trimmedLine, "Action:") + contextElements = append(contextElements, + slack.NewTextBlockObject("mrkdwn", "_Action:_ `"+strings.TrimSpace(actionContent)+"`", false, false)) + } else if strings.HasPrefix(trimmedLine, "Action Input:") { + // Action Input often contains JSON, keep it in code formatting + inputContent := strings.TrimPrefix(trimmedLine, "Action Input:") + contextElements = append(contextElements, + slack.NewTextBlockObject("mrkdwn", "_Action Input:_ `"+strings.TrimSpace(inputContent)+"`", false, false)) + } else if strings.HasPrefix(trimmedLine, "Observation:") { + // Add context blocks before observation + addContextBlock() + obsContent := strings.TrimPrefix(trimmedLine, "Observation:") + contextElements = append(contextElements, + slack.NewTextBlockObject("mrkdwn", "_Observation:_ "+strings.TrimSpace(obsContent), false, false)) + } else if strings.HasPrefix(trimmedLine, "AI:") { + // Finish any pending context blocks + addContextBlock() + // Remove the "AI: " prefix and treat as regular content + content := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "AI:")) + if content != "" { + // Check if this looks like a header (starts with # or is in all caps) + if strings.HasPrefix(content, "# ") || strings.HasPrefix(content, "## ") { + headerText := strings.TrimLeft(content, "# ") + addSectionBlock() // Add any pending section + blocks = append(blocks, slack.NewHeaderBlock( + slack.NewTextBlockObject("plain_text", headerText, false, false), + )) + } else { + if currentSection.Len() > 0 { + currentSection.WriteString("\n") + } + currentSection.WriteString(content) + } + } + } else { + // Regular content + addContextBlock() // Add any pending context blocks + + // Check for markdown headers + if strings.HasPrefix(trimmedLine, "# ") { + headerText := strings.TrimPrefix(trimmedLine, "# ") + addSectionBlock() // Add any pending section + blocks = append(blocks, slack.NewHeaderBlock( + slack.NewTextBlockObject("plain_text", headerText, false, false), + )) + } else if strings.HasPrefix(trimmedLine, "## ") { + headerText := strings.TrimPrefix(trimmedLine, "## ") + addSectionBlock() // Add any pending section + blocks = append(blocks, slack.NewHeaderBlock( + slack.NewTextBlockObject("plain_text", headerText, false, false), + )) + } else if strings.HasPrefix(trimmedLine, "### ") { + // For smaller headers, use bold text in section + headerText := strings.TrimPrefix(trimmedLine, "### ") + if currentSection.Len() > 0 { + currentSection.WriteString("\n") + } + currentSection.WriteString("*" + headerText + "*") + } else { + // Regular line + if line != "" || currentSection.Len() > 0 { + if currentSection.Len() > 0 && line != "" { + currentSection.WriteString("\n") + } + currentSection.WriteString(line) + } + } + } + } + + // Add any remaining content + addContextBlock() + addSectionBlock() + + // If we have blocks, create a Block Kit message + if len(blocks) > 0 { + // Add a divider between thoughts and response if we have context + hasContext := false + for _, block := range blocks { + if _, ok := block.(*slack.ContextBlock); ok { + hasContext = true + break + } + } + + if hasContext { + // Find where to insert divider (after last context block) + dividerIndex := -1 + for i, block := range blocks { + if _, ok := block.(*slack.ContextBlock); ok { + dividerIndex = i + } + } + + if dividerIndex >= 0 && dividerIndex < len(blocks)-1 { + // Insert divider after the last context block + newBlocks := make([]slack.Block, 0, len(blocks)+1) + newBlocks = append(newBlocks, blocks[:dividerIndex+1]...) + newBlocks = append(newBlocks, slack.NewDividerBlock()) + newBlocks = append(newBlocks, blocks[dividerIndex+1:]...) + blocks = newBlocks + } + } + + // Convert blocks to JSON format + blockData := map[string]interface{}{ + "text": text, // Fallback text + "blocks": blocks, + } + + // Marshal to JSON + jsonBytes, err := json.Marshal(blockData) + if err != nil { + // Fallback to plain formatting + return formatPlainAgentOutput(text) + } + + return string(jsonBytes) + } + + // Fallback to formatted text if no blocks were created + return formatPlainAgentOutput(text) +} + +// formatPlainAgentOutput formats agent output for better Slack readability +func formatPlainAgentOutput(text string) string { + lines := strings.Split(text, "\n") + var result []string + var metadataLines []string + inMetadata := false + + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Check if we're starting metadata section + if strings.HasPrefix(trimmedLine, "Thought:") || strings.HasPrefix(trimmedLine, "> Thought:") || + strings.HasPrefix(trimmedLine, "Justification:") || strings.HasPrefix(trimmedLine, "Action:") || + strings.HasPrefix(trimmedLine, "Action Input:") || strings.HasPrefix(trimmedLine, "Observation:") { + inMetadata = true + } + + if inMetadata { + // Format metadata lines with italics, emojis, and proper spacing + if strings.HasPrefix(trimmedLine, "> Thought:") { + thoughtContent := strings.TrimPrefix(trimmedLine, "> ") + thoughtContent = strings.TrimPrefix(thoughtContent, "Thought:") + metadataLines = append(metadataLines, ":brain: _Thought:_ "+strings.TrimSpace(thoughtContent)) + } else if strings.HasPrefix(trimmedLine, "Thought:") { + thoughtContent := strings.TrimPrefix(trimmedLine, "Thought:") + metadataLines = append(metadataLines, ":brain: _Thought:_ "+strings.TrimSpace(thoughtContent)) + } else if strings.HasPrefix(trimmedLine, "Justification:") { + justContent := strings.TrimPrefix(trimmedLine, "Justification:") + metadataLines = append(metadataLines, ":scales: _Justification:_ "+strings.TrimSpace(justContent)) + } else if strings.HasPrefix(trimmedLine, "Action:") { + actionContent := strings.TrimPrefix(trimmedLine, "Action:") + metadataLines = append(metadataLines, ":right-facing_fist: _Action:_ "+strings.TrimSpace(actionContent)) + } else if strings.HasPrefix(trimmedLine, "Action Input:") { + // Keep JSON in code blocks + metadataLines = append(metadataLines, ":arrow_right: _Action Input:_") + // Look for the JSON on the same line or next lines + jsonContent := strings.TrimPrefix(trimmedLine, "Action Input:") + jsonContent = strings.TrimSpace(jsonContent) + if jsonContent != "" { + metadataLines = append(metadataLines, "```") + metadataLines = append(metadataLines, jsonContent) + metadataLines = append(metadataLines, "```") + } else if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) != "" { + // JSON might be on next line + metadataLines = append(metadataLines, "```") + } + } else if strings.HasPrefix(trimmedLine, "Observation:") { + obsContent := strings.TrimPrefix(trimmedLine, "Observation:") + metadataLines = append(metadataLines, ":mag: _Observation:_ "+strings.TrimSpace(obsContent)) + } else if strings.HasPrefix(trimmedLine, "AI:") { + // End of metadata, start of response + inMetadata = false + // Add metadata with separator + if len(metadataLines) > 0 { + result = append(result, metadataLines...) + result = append(result, "───────────") // Visual separator + result = append(result, "") // Blank line + } + // Process AI response + content := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "AI:")) + if content != "" { + // Convert markdown headers to bold + if strings.HasPrefix(content, "## ") { + content = "*" + strings.TrimPrefix(content, "## ") + "*" + } else if strings.HasPrefix(content, "# ") { + content = "*" + strings.TrimPrefix(content, "# ") + "*" + } + result = append(result, content) + } + } else if trimmedLine == "```" && len(metadataLines) > 0 && metadataLines[len(metadataLines)-1] == "```" { + // Skip duplicate ``` markers + continue + } else if trimmedLine != "" { + // Part of metadata content + metadataLines = append(metadataLines, line) + } + } else { + // Regular content processing + if strings.HasPrefix(trimmedLine, "## ") { + // Convert ## headers to bold + result = append(result, "*"+strings.TrimPrefix(trimmedLine, "## ")+"*") + } else if strings.HasPrefix(trimmedLine, "# ") { + // Convert # headers to bold + result = append(result, "*"+strings.TrimPrefix(trimmedLine, "# ")+"*") + } else if strings.HasPrefix(trimmedLine, "### ") { + // Convert ### headers to bold + result = append(result, "*"+strings.TrimPrefix(trimmedLine, "### ")+"*") + } else { + // Keep line as-is + if line != "" || len(result) > 0 { + result = append(result, line) + } + } + } + } + + // If we ended while still in metadata (no AI: response), add the metadata + if inMetadata && len(metadataLines) > 0 { + result = append(result, metadataLines...) + } + + return strings.Join(result, "\n") +} + +// formatPlainAgentOutputWithVisibility formats agent output based on visibility settings +func formatPlainAgentOutputWithVisibility(text string, showThoughts bool) string { + if showThoughts { + // Use existing formatting that shows thoughts + return formatPlainAgentOutput(text) + } + + // Hide thoughts but keep the actual response + lines := strings.Split(text, "\n") + var result []string + inThoughts := false + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Check if we're in the thoughts/metadata section + if strings.HasPrefix(trimmedLine, "Thought:") || strings.HasPrefix(trimmedLine, "> Thought:") || + strings.HasPrefix(trimmedLine, "Justification:") || strings.HasPrefix(trimmedLine, "Action:") || + strings.HasPrefix(trimmedLine, "Action Input:") || strings.HasPrefix(trimmedLine, "Observation:") { + inThoughts = true + continue + } + + // Check if we've reached the actual response + if strings.HasPrefix(trimmedLine, "AI:") { + inThoughts = false + // Process AI response + content := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "AI:")) + if content != "" { + // Convert markdown headers to bold + if strings.HasPrefix(content, "## ") { + content = "*" + strings.TrimPrefix(content, "## ") + "*" + } else if strings.HasPrefix(content, "# ") { + content = "*" + strings.TrimPrefix(content, "# ") + "*" + } + result = append(result, content) + } + continue + } + + // If we're not in thoughts section, include the line + if !inThoughts { + // Regular content processing + if strings.HasPrefix(trimmedLine, "## ") { + result = append(result, "*"+strings.TrimPrefix(trimmedLine, "## ")+"*") + } else if strings.HasPrefix(trimmedLine, "# ") { + result = append(result, "*"+strings.TrimPrefix(trimmedLine, "# ")+"*") + } else if strings.HasPrefix(trimmedLine, "### ") { + result = append(result, "*"+strings.TrimPrefix(trimmedLine, "### ")+"*") + } else { + // Keep line as-is + if line != "" || len(result) > 0 { + result = append(result, line) + } + } + } + } + + // If result is empty, return empty string to avoid showing unnecessary messages + if len(result) == 0 { + return "" + } + + return strings.Join(result, "\n") +} diff --git a/internal/slack/client.go b/internal/slack/client.go index a6b898f9..2102470a 100644 --- a/internal/slack/client.go +++ b/internal/slack/client.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" @@ -25,15 +26,17 @@ import ( // Client represents the Slack client application. type Client struct { - logger *logging.Logger // Structured logger - userFrontend UserFrontend - mcpClients map[string]*mcp.Client - llmMCPBridge *handlers.LLMMCPBridge - llmRegistry *llm.ProviderRegistry // LLM provider registry - cfg *config.Config // Holds the application configuration - messageHistory map[string][]Message - historyLimit int - discoveredTools map[string]mcp.ToolInfo + logger *logging.Logger // Structured logger + userFrontend UserFrontend + mcpClients map[string]*mcp.Client + llmMCPBridge *handlers.LLMMCPBridge + llmRegistry *llm.ProviderRegistry // LLM provider registry + cfg *config.Config // Holds the application configuration + messageHistory map[string][]Message + historyLimit int + discoveredTools map[string]mcp.ToolInfo + participatedThreads map[string]bool // Track threads where bot has responded (key: "channel:threadTS") + showThoughts map[string]bool // Track showThoughts preference per channel/thread (key: "channel:threadTS") } // Message represents a message in the conversation history @@ -181,15 +184,17 @@ func NewClient(userFrontend UserFrontend, stdLogger *logging.Logger, mcpClients // --- Create and return Client instance --- return &Client{ - logger: clientLogger, - userFrontend: userFrontend, - mcpClients: mcpClients, - llmMCPBridge: llmMCPBridge, - llmRegistry: registry, - cfg: cfg, - messageHistory: make(map[string][]Message), - historyLimit: cfg.Slack.MessageHistory, // Store configured number of messages per channel - discoveredTools: discoveredTools, + logger: clientLogger, + userFrontend: userFrontend, + mcpClients: mcpClients, + llmMCPBridge: llmMCPBridge, + llmRegistry: registry, + cfg: cfg, + messageHistory: make(map[string][]Message), + historyLimit: cfg.Slack.MessageHistory, // Store configured number of messages per channel + discoveredTools: discoveredTools, + participatedThreads: make(map[string]bool), + showThoughts: make(map[string]bool), }, nil } @@ -219,6 +224,15 @@ func (c *Client) handleEvents() { c.userFrontend.Ack(*evt.Request) c.logger.InfoKV("Received EventsAPI event", "type", eventsAPIEvent.Type) c.handleEventMessage(eventsAPIEvent) + case socketmode.EventTypeSlashCommand: + cmd, ok := evt.Data.(slack.SlashCommand) + if !ok { + c.logger.WarnKV("Ignored unexpected SlashCommand event type", "type", fmt.Sprintf("%T", evt.Data)) + continue + } + c.userFrontend.Ack(*evt.Request) + c.logger.InfoKV("Received slash command", "command", cmd.Command, "user", cmd.UserID) + c.handleSlashCommandEvent(cmd) default: c.logger.DebugKV("Ignored event type", "type", evt.Type) } @@ -243,13 +257,14 @@ func (c *Client) handleEventMessage(event slackevents.EventsAPIEvent) { } // Use handleUserPrompt for app mentions too, for consistency - go c.handleUserPrompt(strings.TrimSpace(messageText), ev.Channel, ev.TimeStamp, userInfo.Profile.DisplayName) + go c.handleUserPrompt(strings.TrimSpace(messageText), ev.Channel, ev.TimeStamp, ev.TimeStamp, userInfo.Profile.DisplayName) case *slackevents.MessageEvent: isDirectMessage := strings.HasPrefix(ev.Channel, "D") isValidUser := c.userFrontend.IsValidUser(ev.User) isNotEdited := ev.SubType != "message_changed" isBot := ev.BotID != "" || ev.SubType == "bot_message" + isInParticipatedThread := c.hasParticipatedInThread(ev.Channel, ev.ThreadTimeStamp) userInfo, err := c.userFrontend.GetUserInfo(ev.User) if err != nil { @@ -257,10 +272,29 @@ func (c *Client) handleEventMessage(event slackevents.EventsAPIEvent) { return } - if isDirectMessage && isValidUser && isNotEdited && !isBot { - c.logger.InfoKV("Received direct message in channel", "channel", ev.Channel, "user", ev.User, "text", ev.Text) + // Process message if: + // 1. It's a direct message, OR + // 2. It's in a thread where the bot has participated + if (isDirectMessage || isInParticipatedThread) && isValidUser && isNotEdited && !isBot { + c.logger.InfoKV("Received message", "channel", ev.Channel, "user", ev.User, "text", ev.Text, + "isDM", isDirectMessage, "isThread", isInParticipatedThread, "threadTS", ev.ThreadTimeStamp) + + // For thread messages, use the thread timestamp; for channel messages, start a new thread + threadTS := ev.ThreadTimeStamp + if threadTS == "" && !isDirectMessage { + // If it's not a DM and not in a thread, this shouldn't happen based on our logic + // but if it does, use the message timestamp as thread start + threadTS = ev.TimeStamp + } - go c.handleUserPrompt(ev.Text, ev.Channel, ev.ThreadTimeStamp, userInfo.Profile.DisplayName) // Use goroutine to avoid blocking event loop + // For thread replies, we need the original message timestamp for reactions + messageTS := ev.TimeStamp + if threadTS == "" { + // This is a new message, use its timestamp for reactions + messageTS = ev.TimeStamp + } + + go c.handleUserPrompt(ev.Text, ev.Channel, threadTS, messageTS, userInfo.Profile.DisplayName) // Use goroutine to avoid blocking event loop } default: @@ -271,9 +305,15 @@ func (c *Client) handleEventMessage(event slackevents.EventsAPIEvent) { } } -// addToHistory adds a message to the channel history -func (c *Client) addToHistory(channelID, role, content string) { - history, exists := c.messageHistory[channelID] +// addToHistory adds a message to the channel/thread history +func (c *Client) addToHistory(channelID, threadTS, role, content string) { + // Use thread-aware key: for threads use "channel:threadTS", for non-threads just use channelID + historyKey := channelID + if threadTS != "" { + historyKey = fmt.Sprintf("%s:%s", channelID, threadTS) + } + + history, exists := c.messageHistory[historyKey] if !exists { history = []Message{} } @@ -291,14 +331,39 @@ func (c *Client) addToHistory(channelID, role, content string) { history = history[len(history)-c.historyLimit:] } - c.messageHistory[channelID] = history + c.messageHistory[historyKey] = history + c.logger.DebugKV("Added to history", "key", historyKey, "role", role, "contentLength", len(content)) +} + +// trackThreadParticipation marks a thread as participated by the bot +func (c *Client) trackThreadParticipation(channelID, threadTS string) { + if threadTS != "" { + threadKey := fmt.Sprintf("%s:%s", channelID, threadTS) + c.participatedThreads[threadKey] = true + c.logger.DebugKV("Tracked thread participation", "channel", channelID, "thread", threadTS) + } +} + +// hasParticipatedInThread checks if the bot has participated in a thread +func (c *Client) hasParticipatedInThread(channelID, threadTS string) bool { + if threadTS == "" { + return false + } + threadKey := fmt.Sprintf("%s:%s", channelID, threadTS) + return c.participatedThreads[threadKey] } // getContextFromHistory builds a context string from message history // //nolint:unused // Reserved for future use -func (c *Client) getContextFromHistory(channelID string) string { - history, exists := c.messageHistory[channelID] +func (c *Client) getContextFromHistory(channelID, threadTS string) string { + // Use thread-aware key: for threads use "channel:threadTS", for non-threads just use channelID + historyKey := channelID + if threadTS != "" { + historyKey = fmt.Sprintf("%s:%s", channelID, threadTS) + } + + history, exists := c.messageHistory[historyKey] if !exists || len(history) == 0 { return "" } @@ -325,22 +390,44 @@ func (c *Client) getContextFromHistory(channelID string) string { contextBuilder.WriteString("---\n") // Clearer end marker contextString := contextBuilder.String() - c.logger.DebugKV("Built conversation context", "channel", channelID, "context", contextString) // Log the built context + c.logger.DebugKV("Built conversation context", "key", historyKey, "messages", len(history)) // Log the built context return contextString } // handleUserPrompt sends the user's text to the configured LLM provider. -func (c *Client) handleUserPrompt(userPrompt, channelID, threadTS, userDisplayName string) { +func (c *Client) handleUserPrompt(userPrompt, channelID, threadTS, messageTS, userDisplayName string) { c.logger.DebugKV("Routing prompt via configured provider", "provider", c.cfg.LLM.Provider) c.logger.DebugKV("User prompt", "text", userPrompt) + // Handle commands (both slash-style and text commands) + trimmedPrompt := strings.TrimSpace(userPrompt) + lowerPrompt := strings.ToLower(trimmedPrompt) + + // Check for thinking mode commands (text or slash style) + if lowerPrompt == "think silent" || lowerPrompt == "think quietly" || + lowerPrompt == "/think_silent" || lowerPrompt == "/think_quietly" || + lowerPrompt == "!think_silent" || lowerPrompt == "!think_quietly" { + c.setThinkingMode(false, channelID, threadTS) + return + } + + if lowerPrompt == "think aloud" || lowerPrompt == "think loud" || + lowerPrompt == "/think_aloud" || lowerPrompt == "/think_loud" || + lowerPrompt == "!think_aloud" || lowerPrompt == "!think_loud" { + c.setThinkingMode(true, channelID, threadTS) + return + } + // Get context from history - contextHistory := c.getContextFromHistory(channelID) + contextHistory := c.getContextFromHistory(channelID, threadTS) - c.addToHistory(channelID, "user", userPrompt) // Add user message to history + c.addToHistory(channelID, threadTS, "user", userPrompt) // Add user message to history - // Show a temporary "typing" indicator - c.userFrontend.SendMessage(channelID, threadTS, c.cfg.Slack.ThinkingMessage) + // Add thinking emoji reaction + err := c.userFrontend.AddReaction(channelID, messageTS, "thinking_face") + if err != nil { + c.logger.ErrorKV("Failed to add thinking reaction", "error", err) + } if !c.cfg.LLM.UseAgent { // Prepare the final prompt with custom prompt as system instruction @@ -359,6 +446,8 @@ func (c *Client) handleUserPrompt(userPrompt, channelID, threadTS, userDisplayNa llmResponse, err := c.llmMCPBridge.CallLLM(finalPrompt, contextHistory) if err != nil { c.logger.ErrorKV("Error from LLM provider", "provider", c.cfg.LLM.Provider, "error", err) + // Remove thinking reaction on error + c.userFrontend.RemoveReaction(channelID, messageTS, "thinking_face") c.userFrontend.SendMessage(channelID, threadTS, fmt.Sprintf("Sorry, I encountered an error with the LLM provider ('%s'): %v", c.cfg.LLM.Provider, err)) return } @@ -366,24 +455,27 @@ func (c *Client) handleUserPrompt(userPrompt, channelID, threadTS, userDisplayNa c.logger.InfoKV("Received response from LLM", "provider", c.cfg.LLM.Provider, "length", len(llmResponse.Content)) // Process the LLM response through the MCP pipeline - c.processLLMResponseAndReply(llmResponse, userPrompt, channelID, threadTS) + c.processLLMResponseAndReply(llmResponse, userPrompt, channelID, threadTS, messageTS) } else { - sendMsg := func(msg string) { - c.addToHistory(channelID, "assistant", msg) // Original LLM response (tool call JSON) - c.userFrontend.SendMessage(channelID, threadTS, msg) - } - llmResponse, err := c.llmMCPBridge.CallLLMAgent( userDisplayName, c.cfg.LLM.CustomPrompt, userPrompt, contextHistory, &agentCallbackHandler{ - callbacks.SimpleHandler{}, - sendMsg, + SimpleHandler: callbacks.SimpleHandler{}, + sendMessage: func(msg string) { + c.userFrontend.SendMessage(channelID, threadTS, msg) + }, + showThoughts: c.getShowThoughts(channelID, threadTS), + storeFullMessage: func(msg string) { + c.addToHistory(channelID, threadTS, "assistant", msg) + }, }) if err != nil { c.logger.ErrorKV("Error from LLM provider", "provider", c.cfg.LLM.Provider, "error", err) + // Remove thinking reaction on error + c.userFrontend.RemoveReaction(channelID, messageTS, "thinking_face") c.userFrontend.SendMessage(channelID, threadTS, fmt.Sprintf("Sorry, I encountered an error with the LLM provider ('%s'): %v", c.cfg.LLM.Provider, err)) return } @@ -392,12 +484,16 @@ func (c *Client) handleUserPrompt(userPrompt, channelID, threadTS, userDisplayNa if llmResponse == "" { c.userFrontend.SendMessage(channelID, threadTS, "(LLM returned an empty response)") } + // Remove thinking reaction after response + c.userFrontend.RemoveReaction(channelID, messageTS, "thinking_face") + // Track that we've participated in this thread + c.trackThreadParticipation(channelID, threadTS) } } // processLLMResponseAndReply processes the LLM response, handles tool results with re-prompting, and sends the final reply. // Incorporates logic previously in LLMClient.ProcessToolResponse. -func (c *Client) processLLMResponseAndReply(llmResponse *llms.ContentChoice, userPrompt, channelID, threadTS string) { +func (c *Client) processLLMResponseAndReply(llmResponse *llms.ContentChoice, userPrompt, channelID, threadTS, messageTS string) { // Log the raw LLM response for debugging c.logger.DebugKV("Raw LLM response", "response", logging.TruncateForLog(fmt.Sprintf("%v", llmResponse), 500)) @@ -452,8 +548,8 @@ func (c *Client) processLLMResponseAndReply(llmResponse *llms.ContentChoice, use rePrompt := fmt.Sprintf("The user asked: '%s'\n\nI searched the knowledge base and found the following relevant information:\n```\n%s\n```\n\nPlease analyze and synthesize this retrieved information to provide a comprehensive response to the user's request. Use the detailed information from the search results according to your system instructions.", userPrompt, finalResponse) // Add history for non-comprehensive results - c.addToHistory(channelID, "assistant", llmResponse.Content) // Original LLM response (tool call JSON) - c.addToHistory(channelID, "tool", finalResponse) // Tool execution result + c.addToHistory(channelID, threadTS, "assistant", llmResponse.Content) // Original LLM response (tool call JSON) + c.addToHistory(channelID, threadTS, "tool", finalResponse) // Tool execution result c.logger.DebugKV("Re-prompting LLM", "prompt", rePrompt) @@ -470,7 +566,7 @@ func (c *Client) processLLMResponseAndReply(llmResponse *llms.ContentChoice, use finalRePrompt = rePrompt } - finalResStruct, repromptErr := c.llmMCPBridge.CallLLM(finalRePrompt, c.getContextFromHistory(channelID)) + finalResStruct, repromptErr := c.llmMCPBridge.CallLLM(finalRePrompt, c.getContextFromHistory(channelID, threadTS)) if repromptErr != nil { c.logger.ErrorKV("Error during LLM re-prompt", "error", repromptErr) // Fallback: Show the tool result and the error @@ -480,7 +576,7 @@ func (c *Client) processLLMResponseAndReply(llmResponse *llms.ContentChoice, use } } else { // No tool was executed, add assistant response to history - c.addToHistory(channelID, "assistant", finalResponse) + c.addToHistory(channelID, threadTS, "assistant", finalResponse) } // Send the final response back to Slack @@ -489,4 +585,96 @@ func (c *Client) processLLMResponseAndReply(llmResponse *llms.ContentChoice, use } else { c.userFrontend.SendMessage(channelID, threadTS, finalResponse) } + + // Remove thinking reaction after response + c.userFrontend.RemoveReaction(channelID, messageTS, "thinking_face") + + // Track that we've participated in this thread + c.trackThreadParticipation(channelID, threadTS) +} + +// handleSlashCommandEvent processes Slack slash command events +func (c *Client) handleSlashCommandEvent(cmd slack.SlashCommand) { + // Get thread key for per-conversation settings + channelID := cmd.ChannelID + threadKey := channelID + + switch cmd.Command { + case "/think_aloud": + c.showThoughts[threadKey] = true + c.userFrontend.SendMessage(channelID, "", ":brain: Thinking aloud mode enabled. I'll show my reasoning process.") + c.logger.InfoKV("Enabled thinking aloud via slash command", "channel", channelID, "user", cmd.UserID) + + case "/think_silent": + c.showThoughts[threadKey] = false + c.userFrontend.SendMessage(channelID, "", ":shushing_face: Silent thinking mode enabled. I'll keep my thoughts to myself.") + c.logger.InfoKV("Enabled silent thinking via slash command", "channel", channelID, "user", cmd.UserID) + + default: + c.userFrontend.SendMessage(channelID, "", fmt.Sprintf("Unknown command: %s", cmd.Command)) + } +} + +// handleSlashCommand processes slash commands like /think_aloud and /think_silent +func (c *Client) handleSlashCommand(command, channelID, threadTS, messageTS string) { + // Get thread key for per-conversation settings + threadKey := channelID + if threadTS != "" { + threadKey = fmt.Sprintf("%s:%s", channelID, threadTS) + } + + switch command { + case "/think_aloud": + c.showThoughts[threadKey] = true + c.userFrontend.SendMessage(channelID, threadTS, ":brain: Thinking aloud mode enabled. I'll show my reasoning process.") + c.logger.InfoKV("Enabled thinking aloud", "channel", channelID, "thread", threadTS) + + case "/think_silent", "/think_quietly": + c.showThoughts[threadKey] = false + c.userFrontend.SendMessage(channelID, threadTS, ":shushing_face: Silent thinking mode enabled. I'll keep my thoughts to myself.") + c.logger.InfoKV("Enabled silent thinking", "channel", channelID, "thread", threadTS) + + default: + c.userFrontend.SendMessage(channelID, threadTS, fmt.Sprintf("Unknown command: %s\nAvailable commands:\n• `/think_aloud` - Show my reasoning process\n• `/think_silent` - Hide my reasoning process", command)) + } +} + +// setThinkingMode sets the thinking mode for a channel/thread +func (c *Client) setThinkingMode(showThoughts bool, channelID, threadTS string) { + // Get thread key for per-conversation settings + threadKey := channelID + if threadTS != "" { + threadKey = fmt.Sprintf("%s:%s", channelID, threadTS) + } + + c.showThoughts[threadKey] = showThoughts + + if showThoughts { + c.userFrontend.SendMessage(channelID, threadTS, ":brain: Thinking aloud mode enabled. I'll show my reasoning process.") + c.logger.InfoKV("Enabled thinking aloud", "channel", channelID, "thread", threadTS) + } else { + c.userFrontend.SendMessage(channelID, threadTS, ":shushing_face: Silent thinking mode enabled. I'll keep my thoughts to myself.") + c.logger.InfoKV("Enabled silent thinking", "channel", channelID, "thread", threadTS) + } +} + +// getShowThoughts returns whether to show thoughts for a given channel/thread +func (c *Client) getShowThoughts(channelID, threadTS string) bool { + // Check thread-specific setting first + threadKey := channelID + if threadTS != "" { + threadKey = fmt.Sprintf("%s:%s", channelID, threadTS) + } + + if showThoughts, exists := c.showThoughts[threadKey]; exists { + return showThoughts + } + + // Fall back to config default + if c.cfg.LLM.ShowThoughts != nil { + return *c.cfg.LLM.ShowThoughts + } + + // Default to true if not configured + return true } diff --git a/internal/slack/formatter/detector.go b/internal/slack/formatter/detector.go index c4344b77..a3046d61 100644 --- a/internal/slack/formatter/detector.go +++ b/internal/slack/formatter/detector.go @@ -250,3 +250,117 @@ func FormatStructuredData(content string) string { return blockMessage } + +// ImageInfo represents information about a markdown image +type ImageInfo struct { + AltText string + URL string +} + +// ExtractMarkdownImages extracts markdown image links from text +func ExtractMarkdownImages(text string) []ImageInfo { + // Pattern to match markdown images: ![alt text](url) + // This pattern handles URLs that might contain parentheses + imagePattern := regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^\s]+)\)`) + matches := imagePattern.FindAllStringSubmatch(text, -1) + + var images []ImageInfo + for _, match := range matches { + if len(match) == 3 { + url := match[2] + // Clean up URL if it appears to be truncated or has extra characters + url = strings.TrimSpace(url) + + // If URL ends with a parenthesis that's likely part of the markdown, remove it + if strings.HasSuffix(url, ")") && strings.Count(url, "(") < strings.Count(url, ")") { + url = url[:len(url)-1] + } + + images = append(images, ImageInfo{ + AltText: match[1], + URL: url, + }) + } + } + return images +} + +// HasMarkdownImages checks if text contains markdown image links +func HasMarkdownImages(text string) bool { + imagePattern := regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^\s]+)\)`) + return imagePattern.MatchString(text) +} + +// ConvertMarkdownWithImages converts text with markdown images to Block Kit format +func ConvertMarkdownWithImages(text string) string { + images := ExtractMarkdownImages(text) + if len(images) == 0 { + return text + } + + // Remove image markdown from text + imagePattern := regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^\s]+)\)`) + textWithoutImages := imagePattern.ReplaceAllString(text, "") + + // Apply markdown formatting to the remaining text + formattedText := FormatMarkdown(textWithoutImages) + + // Create blocks + blocks := []map[string]interface{}{} + + // Add text content if not empty + trimmedText := strings.TrimSpace(formattedText) + if trimmedText != "" { + blocks = append(blocks, map[string]interface{}{ + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": trimmedText, + }, + }) + } + + // Add image blocks + for _, img := range images { + // Validate URL starts with http:// or https:// + if !strings.HasPrefix(img.URL, "http://") && !strings.HasPrefix(img.URL, "https://") { + // Skip invalid URLs + continue + } + + imageBlock := map[string]interface{}{ + "type": "image", + "image_url": img.URL, + "alt_text": img.AltText, + } + + // Add title if alt text is provided and not empty + if img.AltText != "" { + imageBlock["title"] = map[string]interface{}{ + "type": "plain_text", + "text": img.AltText, + } + } + + blocks = append(blocks, imageBlock) + } + + // If we have no valid image blocks after validation, return original text + if len(blocks) == 0 || (len(blocks) == 1 && blocks[0]["type"] == "section") { + return text + } + + // Create the final message + message := map[string]interface{}{ + "text": text, // Fallback text + "blocks": blocks, + } + + // Convert to JSON + jsonBytes, err := json.Marshal(message) + if err != nil { + return text // Fallback to original text if JSON marshaling fails + } + + return string(jsonBytes) +} diff --git a/internal/slack/formatter/formatter.go b/internal/slack/formatter/formatter.go index 7b15d6f5..abb89007 100644 --- a/internal/slack/formatter/formatter.go +++ b/internal/slack/formatter/formatter.go @@ -117,6 +117,11 @@ func FormatMessage(text string, options FormatOptions) []slack.MsgOption { if err := json.Unmarshal(blockJSON, &context); err == nil { slackBlock = context } + case "image": + var image slack.ImageBlock + if err := json.Unmarshal(blockJSON, &image); err == nil { + slackBlock = image + } // Add more block types as needed } diff --git a/internal/slack/stdioClient.go b/internal/slack/stdioClient.go index aee46f65..458e9a0d 100644 --- a/internal/slack/stdioClient.go +++ b/internal/slack/stdioClient.go @@ -124,3 +124,13 @@ func (client StdioClient) GetUserInfo(userID string) (*slack.User, error) { }, }, nil } + +func (client StdioClient) AddReaction(channelID, timestamp, reaction string) error { + fmt.Printf("ADD REACTION: %s to %s:%s\n", reaction, channelID, timestamp) + return nil +} + +func (client StdioClient) RemoveReaction(channelID, timestamp, reaction string) error { + fmt.Printf("REMOVE REACTION: %s from %s:%s\n", reaction, channelID, timestamp) + return nil +} diff --git a/internal/slack/userFrontend.go b/internal/slack/userFrontend.go index 7b52d725..d303fd01 100644 --- a/internal/slack/userFrontend.go +++ b/internal/slack/userFrontend.go @@ -24,6 +24,8 @@ type UserFrontend interface { GetLogger() *logging.Logger SendMessage(channelID, threadTS, text string) GetUserInfo(userID string) (*slack.User, error) + AddReaction(channelID, timestamp, reaction string) error + RemoveReaction(channelID, timestamp, reaction string) error } func getLogLevel(stdLogger *logging.Logger) logging.LogLevel { @@ -121,6 +123,28 @@ func (slackClient *SlackClient) GetUserInfo(userID string) (*slack.User, error) return user, nil } +// AddReaction adds an emoji reaction to a message +func (slackClient *SlackClient) AddReaction(channelID, timestamp, reaction string) error { + err := slackClient.AddReactionContext(context.Background(), reaction, slack.NewRefToMessage(channelID, timestamp)) + if err != nil { + slackClient.logger.ErrorKV("Failed to add reaction", "channel", channelID, "timestamp", timestamp, "reaction", reaction, "error", err) + return fmt.Errorf("failed to add reaction: %w", err) + } + slackClient.logger.DebugKV("Added reaction", "channel", channelID, "timestamp", timestamp, "reaction", reaction) + return nil +} + +// RemoveReaction removes an emoji reaction from a message +func (slackClient *SlackClient) RemoveReaction(channelID, timestamp, reaction string) error { + err := slackClient.RemoveReactionContext(context.Background(), reaction, slack.NewRefToMessage(channelID, timestamp)) + if err != nil { + slackClient.logger.ErrorKV("Failed to remove reaction", "channel", channelID, "timestamp", timestamp, "reaction", reaction, "error", err) + return fmt.Errorf("failed to remove reaction: %w", err) + } + slackClient.logger.DebugKV("Removed reaction", "channel", channelID, "timestamp", timestamp, "reaction", reaction) + return nil +} + // SendMessage sends a message back to Slack, replying in a thread if threadTS is provided. func (slackClient *SlackClient) SendMessage(channelID, threadTS, text string) { if text == "" { @@ -128,23 +152,6 @@ func (slackClient *SlackClient) SendMessage(channelID, threadTS, text string) { return } - // Delete "typing" indicator messages if any - // This is a simplistic approach - more sophisticated approaches might track message IDs - history, err := slackClient.GetConversationHistory(&slack.GetConversationHistoryParameters{ - ChannelID: channelID, - Limit: 10, - }) - if err == nil && history != nil { - for _, msg := range history.Messages { - if slackClient.IsBotUser(msg.User) && msg.Text == slackClient.thinkingMessage { - _, _, err := slackClient.DeleteMessage(channelID, msg.Timestamp) - if err != nil { - slackClient.logger.ErrorKV("Error deleting typing indicator message", "error", err) - } - break // Just delete the most recent one - } - } - } // Detect message type and format accordingly messageType := formatter.DetectMessageType(text) @@ -169,15 +176,25 @@ func (slackClient *SlackClient) SendMessage(channelID, threadTS, text string) { msgOptions = formatter.FormatMessage(formattedText, options) case formatter.MarkdownText, formatter.PlainText: - // Apply Markdown formatting and use default text formatting - formattedText := formatter.FormatMarkdown(text) - options := formatter.DefaultOptions() - options.ThreadTS = threadTS - msgOptions = formatter.FormatMessage(formattedText, options) + // Check if the text contains markdown images + if formatter.HasMarkdownImages(text) { + // Convert to Block Kit format with image blocks + formattedText := formatter.ConvertMarkdownWithImages(text) + options := formatter.DefaultOptions() + options.Format = formatter.BlockFormat + options.ThreadTS = threadTS + msgOptions = formatter.FormatMessage(formattedText, options) + } else { + // Apply Markdown formatting and use default text formatting + formattedText := formatter.FormatMarkdown(text) + options := formatter.DefaultOptions() + options.ThreadTS = threadTS + msgOptions = formatter.FormatMessage(formattedText, options) + } } // Send the message - _, _, err = slackClient.PostMessage(channelID, msgOptions...) + _, _, err := slackClient.PostMessage(channelID, msgOptions...) if err != nil { slackClient.logger.ErrorKV("Error posting message to channel", "channel", channelID, "error", err, "messageType", messageType) diff --git a/schema/config-schema.json b/schema/config-schema.json index 2c8589c9..60184d9e 100644 --- a/schema/config-schema.json +++ b/schema/config-schema.json @@ -60,6 +60,11 @@ "default": false, "description": "Replace default tool prompt entirely instead of prepending" }, + "showThoughts": { + "type": "boolean", + "default": true, + "description": "Show agent thoughts/reasoning in Slack output (thoughts are always kept in LLM history)" + }, "providers": { "type": "object", "properties": {