All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
304 lines
8.4 KiB
Go
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]) + "…"
|
|
}
|