l-ai-agent-kaya/internal/tasksboard/loop.go
alex 3a2a0c1c34
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Enable kaya-board tools on Tasks mention replies.
Match Telegram chat tool surface (journal, tasks, steam/discord/node) so diary requests hit write_journal instead of a board-only comment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:45:03 +03:00

304 lines
8.4 KiB
Go

package tasksboard
import (
"context"
"encoding/json"
"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"
floodMaxComments = 25
floodWindow = 10 * time.Minute
floodMarker = "[tasks-flood]"
)
// 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, "author_type", ev.Comment.AuthorType)
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)
}
mentionBody := ev.Comment.Body
if mentionBody == "" {
for _, c := range comments {
if c.ID == commentID {
mentionBody = c.Body
break
}
}
}
if strings.Contains(mentionBody, floodMarker) {
return nil
}
if recentCommentCount(comments, floodWindow) >= floodMaxComments {
l.log().Warn("tasks board flood limit — pausing (Mira coordinates)", "task_id", taskID)
body := fmt.Sprintf(
"@mira %s flood: ≥%d comments in %s on this task — sister agents paused; please coordinate / tag the human.",
floodMarker, floodMaxComments, floodWindow,
)
_, err := l.REST.PostComment(replyCtx, taskID, body, "flood-escalate:"+taskID)
return err
}
authorType := strings.ToLower(ev.Comment.AuthorType)
if authorType == "agent" {
ok, err := l.shouldReplyToAgentMention(replyCtx, task, commentID, mentionBody)
if err != nil {
return err
}
if !ok {
l.log().Info("tasks board skip agent→agent mention", "task_id", taskID, "comment_id", commentID)
return nil
}
}
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"
}
sys := l.Profile.SystemPrompt()
sys += " Avoid @mentioning other agents unless you need a concrete action — never @mention just to acknowledge."
sys += " CRITICAL: Do NOT call add_comment — this process posts your reply text as the board comment."
sys += " If asked to journal / diary a merged task: call write_journal with a short factual entry (what shipped + PR URL if present), then still write a brief board reply."
if authorType == "human" {
sys += " A human mentioned you — always give a real reply."
}
res, err := l.Core.RunAgent(replyCtx, inference.RunRequest{
Messages: []inference.ChatMessage{
{Role: "system", Content: sys},
{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: true,
ToolProfile: "kaya-board",
MaxToolIterations: 10,
MaxTotalToolCalls: 18,
})
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, "tools_used", res.ToolsUsed)
return nil
}
func recentCommentCount(comments []Comment, window time.Duration) int {
cutoff := time.Now().UTC().Add(-window)
n := 0
for _, c := range comments {
if c.CreatedAt.IsZero() || !c.CreatedAt.Before(cutoff) {
n++
}
}
return n
}
const decideSchema = `{"type":"object","required":["reply"],"properties":{"reply":{"type":"boolean"},"reason":{"type":"string"}}}`
func (l *Loop) shouldReplyToAgentMention(ctx context.Context, task Task, mentionID, mentionBody string) (bool, error) {
model := l.Model
if model == "" {
model = "deepseek-v4-flash"
}
user := "Task: " + task.Title + "\nTrigger comment from another agent:\n" + trimRunes(mentionBody, 800) +
"\n\nReply true if you must act (e.g. Mira asked you to journal via write_journal, or you need tools). Default false for thanks/acks/repeats."
res, err := l.Core.RunAgent(ctx, inference.RunRequest{
Messages: []inference.ChatMessage{
{Role: "system", Content: "You gate Kaya's board replies to agent @mentions. Prefer false. Output JSON only."},
{Role: "user", Content: user},
},
Model: model,
AgentID: agentID,
RequestID: "mention-decide:" + mentionID,
IdempotencyKey: "mention-decide:" + mentionID,
JSONSchema: decideSchema,
Repair: true,
TimeoutSeconds: 45,
ToolsEnabled: false,
Metadata: map[string]string{"trace_reason": "kaya_tasks_mention_decide"},
})
if err != nil {
return false, err
}
var out struct {
Reply bool `json:"reply"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(res.Content)), &out); err != nil {
l.log().Warn("tasks board decide parse failed; skipping", "error", err)
return false, nil
}
return out.Reply, 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]) + "…"
}