All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
Thin WS client to l-ai-tasks that drafts short core RunAgent replies on @kaya mentions. Co-authored-by: Cursor <cursoragent@cursor.com>
202 lines
4.9 KiB
Go
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]) + "…"
|
|
}
|