package tasksboard import ( "context" "fmt" "log/slog" "strings" "sync" "time" "l-ai-agent-kaya/internal/inference" "l-ai-agent-kaya/internal/profile" "l-ai-agent-kaya/internal/tasksws" ) const agentID = "kaya" // Loop listens to Tasks board WS and replies to @kaya mentions. type Loop struct { WSURL string Token string REST *Client Core inference.Runner Profile profile.Profile Model string ResponseTimeout time.Duration Log *slog.Logger mu sync.Mutex seen map[string]struct{} seenQ []string maxSeen int } func (l *Loop) log() *slog.Logger { if l.Log != nil { return l.Log } return slog.Default() } // Run blocks until ctx is cancelled. func (l *Loop) Run(ctx context.Context) { if l.seen == nil { l.seen = map[string]struct{}{} } if l.maxSeen <= 0 { l.maxSeen = 4096 } client := &tasksws.Client{ URL: l.WSURL, Token: l.Token, Agent: agentID, Log: l.log(), OnEvent: func(ctx context.Context, ev tasksws.Event) error { return l.handle(ctx, ev) }, } l.log().Info("tasks board loop starting", "ws", l.WSURL, "agent", agentID) client.Run(ctx) } func (l *Loop) handle(ctx context.Context, ev tasksws.Event) error { if ev.Type != "comment.added" { return nil } if ev.Comment == nil || ev.Task == nil { return nil } if !tasksws.MentionsSelf(ev, agentID) { return nil } if strings.EqualFold(ev.Comment.AuthorID, agentID) { return nil } commentID := ev.Comment.ID if commentID == "" { return nil } if !l.markSeen(commentID) { return nil } taskID := ev.Task.ID l.log().Info("mention reply", "comment_id", commentID, "task_id", taskID, "author", ev.Comment.AuthorID) replyCtx, cancel := context.WithTimeout(ctx, l.ResponseTimeout) defer cancel() task, err := l.REST.GetTask(replyCtx, taskID) if err != nil { return fmt.Errorf("get task: %w", err) } comments, err := l.REST.ListComments(replyCtx, taskID) if err != nil { return fmt.Errorf("list comments: %w", err) } userPrompt := buildUserPrompt(task, comments, commentID) timeoutSec := uint32(l.ResponseTimeout.Seconds()) if timeoutSec < 30 { timeoutSec = 30 } model := l.Model if model == "" && l.Profile.LLM.Response.Model != "" { model = l.Profile.LLM.Response.Model } if model == "" { model = "deepseek-v4-flash" } res, err := l.Core.RunAgent(replyCtx, inference.RunRequest{ Messages: []inference.ChatMessage{ {Role: "system", Content: l.Profile.SystemPrompt()}, {Role: "user", Content: userPrompt}, }, Model: model, AgentID: agentID, RequestID: "mention-reply:" + commentID, IdempotencyKey: "mention-reply:" + commentID, TimeoutSeconds: timeoutSec, Metadata: map[string]string{"trace_reason": "kaya_mention_reply", "comment_id": commentID, "task_id": taskID}, ToolsEnabled: false, }) if err != nil { return fmt.Errorf("RunAgent: %w", err) } body := strings.TrimSpace(res.Content) if body == "" { return fmt.Errorf("empty agent reply for comment %s", commentID) } _, err = l.REST.PostComment(replyCtx, taskID, body, "mention-reply:"+commentID) if err != nil { return fmt.Errorf("post comment: %w", err) } l.log().Info("mention reply posted", "comment_id", commentID, "task_id", taskID, "duration_ms", res.DurationMS) return nil } func (l *Loop) markSeen(id string) bool { l.mu.Lock() defer l.mu.Unlock() if _, ok := l.seen[id]; ok { return false } l.seen[id] = struct{}{} l.seenQ = append(l.seenQ, id) for len(l.seenQ) > l.maxSeen { old := l.seenQ[0] l.seenQ = l.seenQ[1:] delete(l.seen, old) } return true } func buildUserPrompt(task Task, comments []Comment, triggerID string) string { var b strings.Builder b.WriteString("Task context:\n") b.WriteString(fmt.Sprintf("- id: %s\n", task.ID)) b.WriteString(fmt.Sprintf("- title: %s\n", task.Title)) b.WriteString(fmt.Sprintf("- status: %s\n", task.Status)) b.WriteString(fmt.Sprintf("- priority: %d\n", task.Priority)) if task.Project != nil { b.WriteString(fmt.Sprintf("- project: %s (%s)\n", task.Project.Name, task.Project.Slug)) } if task.Assignee != nil { name := task.Assignee.DisplayName if name == "" { name = task.Assignee.ID } b.WriteString(fmt.Sprintf("- assignee: %s/%s (%s)\n", task.Assignee.Type, task.Assignee.ID, name)) } if desc := strings.TrimSpace(task.Description); desc != "" { b.WriteString("- description:\n") b.WriteString(trimRunes(desc, 4000)) b.WriteByte('\n') } b.WriteString("\nComment thread (oldest first):\n") for _, c := range comments { marker := "" if c.ID == triggerID { marker = " ← reply to this" } b.WriteString(fmt.Sprintf("[%s] %s/%s:%s\n%s\n\n", c.CreatedAt.UTC().Format(time.RFC3339), c.AuthorType, c.AuthorID, marker, trimRunes(c.Body, 2000), )) } b.WriteString("Write a helpful board comment reply to the marked mention.\n") return b.String() } func trimRunes(s string, max int) string { r := []rune(s) if len(r) <= max { return s } return string(r[:max]) + "…" }