l-ai-agent-kaya/internal/tasksboard/loop.go
alex adb6c5ab8a
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
Initial Kaya board-mention agent daemon.
Thin WS client to l-ai-tasks that drafts short core RunAgent replies on @kaya mentions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 23:07:24 +03:00

202 lines
4.9 KiB
Go

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]) + "…"
}