Files
kandev/apps/backend/internal/slack/runtime.go
T
João Salavisa 7e0b46c227 feat(slack): cookie-mode integration that triages threads via a utility agent (#775)
* feat(slack): add cookie-mode integration that triages messages via utility agent

Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.

Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
  right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(slack): address PR review feedback round 9

- Drop dead `var _ = time.Second` no-op and its misleading comment in
  trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
  fails, so the poller self-recovers from credential rotation without
  requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
  singleton migration so promoted-row selection is deterministic on
  `updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
  `VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
  `!kandev` triage as v1, with bot install / User OAuth / reaction &
  slash & shortcut triggers moved under Future scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:03:42 +01:00

303 lines
11 KiB
Go

package slack
import (
"context"
"errors"
"fmt"
"strings"
"go.uber.org/zap"
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
"github.com/kandev/kandev/internal/common/logger"
utilitymodels "github.com/kandev/kandev/internal/utility/models"
utilityservice "github.com/kandev/kandev/internal/utility/service"
"github.com/kandev/kandev/internal/utility/template"
)
// agentSystemPrompt is the persona used when a utility agent's prompt template
// is empty (or doesn't reference any Slack-specific variables). It tells the
// agent it's acting as a triage assistant, names the MCP tools it has, and
// asks for a concise final reply suitable for posting back to Slack.
//
// When the user provides a custom prompt that references Slack template
// variables (e.g. {{SlackInstruction}}, {{SlackThread}}), the template wins
// and we don't append anything — the user is in full control.
const defaultSlackSystemPrompt = `You are a Kandev triage assistant. A user just sent a request from Slack — an instruction plus the surrounding thread for context. Your job:
1. Use the Kandev MCP tools available to you (list_workspaces_kandev, list_workflows_kandev, list_workflow_steps_kandev, list_repositories_kandev, create_task_kandev, list_agents_kandev, list_executor_profiles_kandev) to figure out which Kandev workspace + workflow + column + repository the request belongs in. **Always call list_workspaces_kandev first** — Slack is install-wide, so you must pick the destination Kandev workspace from the list rather than assume one. Then list_repositories_kandev for that workspace before deciding which repo to attach — never guess a repository_id.
2. Create a Kandev task that captures the request, with a clear title and a description that gives the future agent everything it needs (the user's instruction and any thread context that matters).
3. Reply briefly with what you did — the task title, the workspace it landed in, and a short rationale. Your reply will be posted back into Slack as your final message, so write naturally for a human reader.
Don't ask the user follow-up questions in the reply — your reply is posted in-thread without a chance for them to respond mid-run. If something is ambiguous, pick the most likely option and explain your choice.`
// HostUtilityRunner is the slice of hostutility.Manager we depend on.
type HostUtilityRunner interface {
ExecutePromptWithMCP(
ctx context.Context,
agentType, model, mode, prompt string,
mcpServers []agentctlutil.MCPServerDTO,
) (HostPromptResult, error)
}
// HostPromptResult mirrors hostutility.PromptResult — duplicated here so the
// slack package doesn't import hostutility (which has heavy transitive deps).
type HostPromptResult struct {
Response string
Model string
PromptTokens int
ResponseTokens int
DurationMs int
}
// UtilityRegistry is the slice of utility.Service the runner needs: enough to
// look up an agent and prepare a resolved prompt request that handles the
// "empty agent_id/model falls back to user defaults" path the rest of Kandev
// already implements.
type UtilityRegistry interface {
GetAgentByID(ctx context.Context, id string) (*utilitymodels.UtilityAgent, error)
PreparePromptRequest(
ctx context.Context,
utilityID string,
tmplCtx *template.Context,
defaults *utilityservice.DefaultUtilitySettings,
sessionless bool,
) (*utilityservice.PromptRequest, error)
}
// UserDefaults is the slice of user.Service the runner uses to resolve
// per-user default agent/model when the chosen utility agent leaves them
// empty (the normal state for built-in agents).
type UserDefaults interface {
GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error)
}
// MCPDescriptor describes a single MCP server to wire into the agent's
// session. Mirrors the agentctlutil.MCPServerDTO shape.
type MCPDescriptor struct {
Name string
URL string
}
// Runner runs the utility agent for a Slack match.
type Runner struct {
utility UtilityRegistry
userDefaults UserDefaults
host HostUtilityRunner
mcpServers []MCPDescriptor
log *logger.Logger
}
// NewRunner builds a runner.
func NewRunner(utility UtilityRegistry, userDefaults UserDefaults, host HostUtilityRunner, mcpServers []MCPDescriptor, log *logger.Logger) *Runner {
return &Runner{utility: utility, userDefaults: userDefaults, host: host, mcpServers: mcpServers, log: log}
}
// ErrNoUtilityAgent is returned when the workspace's Slack config doesn't
// reference a utility agent.
var ErrNoUtilityAgent = errors.New("slack: no utility agent configured")
// RunForMatch implements AgentRunner. Slack is install-wide singleton, so
// there's no workspace_id to pass — the agent picks the destination Kandev
// workspace itself via list_workspaces_kandev.
func (r *Runner) RunForMatch(
ctx context.Context,
cfg *SlackConfig,
msg SlackMessage,
instruction, permalink string,
thread []SlackMessage,
) (string, error) {
if cfg.UtilityAgentID == "" {
return "", ErrNoUtilityAgent
}
agent, err := r.utility.GetAgentByID(ctx, cfg.UtilityAgentID)
if err != nil {
return "", fmt.Errorf("load utility agent: %w", err)
}
defaults := r.resolveDefaults(ctx)
tmplCtx := r.buildTemplateContext(msg, instruction, permalink, thread)
prepared, err := r.utility.PreparePromptRequest(ctx, cfg.UtilityAgentID, tmplCtx, defaults, true)
if err != nil {
return "", fmt.Errorf("prepare prompt: %w", err)
}
if prepared.AgentCLI == "" {
return "", fmt.Errorf("no agent_id resolved for utility agent %q (set one on the agent or pick a default in /settings/utility-agents)", agent.Name)
}
prompt := r.composePrompt(agent.Prompt, prepared.ResolvedPrompt, msg, instruction, permalink, thread)
mcpDTOs := r.mcpDTOs()
r.log.Info("slack: invoking utility agent",
zap.String("utility_agent", agent.Name),
zap.String("agent_cli", prepared.AgentCLI),
zap.String("model", prepared.Model),
zap.Int("mcp_servers", len(mcpDTOs)),
zap.String("trigger_ts", msg.TS))
res, err := r.host.ExecutePromptWithMCP(ctx, prepared.AgentCLI, prepared.Model, "", prompt, mcpDTOs)
if err != nil {
return "", fmt.Errorf("execute utility prompt: %w", err)
}
reply := strings.TrimSpace(res.Response)
if reply == "" {
r.log.Warn("slack: agent returned empty response",
zap.String("trigger_ts", msg.TS))
reply = "(Kandev triage agent finished without producing text — check the kanban board.)"
}
return reply, nil
}
// resolveDefaults reads the user-default agent/model. A read failure here
// just means we'll surface a clearer "no agent_id resolved" error later
// rather than crashing the whole flow on a settings-DB blip.
func (r *Runner) resolveDefaults(ctx context.Context) *utilityservice.DefaultUtilitySettings {
if r.userDefaults == nil {
return nil
}
agentID, model, err := r.userDefaults.GetDefaultUtilitySettings(ctx)
if err != nil {
r.log.Warn("slack: read default utility settings failed", zap.Error(err))
return nil
}
return &utilityservice.DefaultUtilitySettings{AgentID: agentID, Model: model}
}
// buildTemplateContext maps Slack data into the existing template Context's
// Custom map so users can reference {{SlackInstruction}}, {{SlackThread}}, etc.
// in their utility agent's prompt template. UserPrompt is set too so prompts
// shared with other utility agents (which use {{UserPrompt}}) work without
// modification.
func (r *Runner) buildTemplateContext(trigger SlackMessage, instruction, permalink string, thread []SlackMessage) *template.Context {
return &template.Context{
UserPrompt: instruction,
Custom: map[string]string{
"SlackInstruction": instruction,
"SlackThread": formatThread(thread),
"SlackPermalink": permalink,
"SlackUser": senderLabel(trigger),
"SlackChannelID": trigger.ChannelID,
"SlackTS": trigger.TS,
},
}
}
// slackTemplateVars are the placeholders authored Slack-triage templates
// reference. Detecting any of these (not just "the resolver substituted
// anything") in the raw template is what tells us the user designed the
// prompt for Slack rather than reusing a generic utility-agent template
// that happens to use {{UserPrompt}}.
var slackTemplateVars = []string{
"{{SlackInstruction}}",
"{{SlackThread}}",
"{{SlackPermalink}}",
"{{SlackUser}}",
"{{SlackChannelID}}",
"{{SlackTS}}",
}
func referencesSlackVars(rawTemplate string) bool {
for _, v := range slackTemplateVars {
if strings.Contains(rawTemplate, v) {
return true
}
}
return false
}
// composePrompt picks between the resolved template (when the user authored
// one and it references at least one Slack-specific placeholder) and the
// legacy "default system prompt + appended Slack context block" layout.
//
// We test for Slack-specific placeholders explicitly, not just "the resolver
// substituted anything" — a generic utility-agent prompt that uses
// {{UserPrompt}} would otherwise hijack the Slack-owned path and the agent
// would lose the MCP-tool guidance baked into the default system prompt.
// When the raw template has no Slack vars, we fall back so users with empty
// or generic templates still get a working triage prompt.
func (r *Runner) composePrompt(rawTemplate, resolved string, trigger SlackMessage, instruction, permalink string, thread []SlackMessage) string {
tmpl := strings.TrimSpace(rawTemplate)
resolvedTrim := strings.TrimSpace(resolved)
if tmpl != "" && referencesSlackVars(rawTemplate) {
return resolvedTrim
}
system := resolvedTrim
if system == "" {
system = defaultSlackSystemPrompt
}
return appendSlackContextBlock(system, trigger, instruction, permalink, thread)
}
func appendSlackContextBlock(system string, trigger SlackMessage, instruction, permalink string, thread []SlackMessage) string {
var b strings.Builder
b.WriteString(system)
b.WriteString("\n\n--- Slack context ---\n")
if permalink != "" {
b.WriteString("Slack thread: ")
b.WriteString(permalink)
b.WriteString("\n")
}
if instruction != "" {
b.WriteString("Request from ")
b.WriteString(senderLabel(trigger))
b.WriteString(":\n")
b.WriteString(instruction)
b.WriteString("\n")
}
if len(thread) > 0 {
b.WriteString("\nThread context:\n")
b.WriteString(formatThread(thread))
}
return strings.TrimRight(b.String(), "\n")
}
func formatThread(thread []SlackMessage) string {
if len(thread) == 0 {
return ""
}
var b strings.Builder
for _, m := range thread {
who := m.UserName
if who == "" {
who = m.UserID
}
if who == "" {
who = "(unknown)"
}
b.WriteString(who)
b.WriteString(" (")
b.WriteString(m.TS)
b.WriteString("): ")
b.WriteString(strings.TrimSpace(m.Text))
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func (r *Runner) mcpDTOs() []agentctlutil.MCPServerDTO {
if len(r.mcpServers) == 0 {
return nil
}
out := make([]agentctlutil.MCPServerDTO, 0, len(r.mcpServers))
for _, m := range r.mcpServers {
out = append(out, agentctlutil.MCPServerDTO{
Name: m.Name,
Type: "http",
URL: m.URL,
})
}
return out
}
func senderLabel(m SlackMessage) string {
switch {
case m.UserName != "":
return "@" + m.UserName
case m.UserID != "":
return "<@" + m.UserID + ">"
default:
return "the user"
}
}