diff --git a/internal/inference/core.go b/internal/inference/core.go index 8bc9a96..3202edc 100644 --- a/internal/inference/core.go +++ b/internal/inference/core.go @@ -53,6 +53,11 @@ func (r *CoreRunner) RunAgent(ctx context.Context, req RunRequest) (RunResult, e msgs = append(msgs, &corepb.StatelessMessage{Role: m.Role, Content: m.Content}) } + format := corepb.AgentOutputFormat_AGENT_OUTPUT_FORMAT_TEXT + if req.JSONSchema != "" { + format = corepb.AgentOutputFormat_AGENT_OUTPUT_FORMAT_JSON_SCHEMA + } + toolsEnabled := req.ToolsEnabled maxIters := uint32(1) maxCalls := uint32(0) @@ -72,7 +77,9 @@ func (r *CoreRunner) RunAgent(ctx context.Context, req RunRequest) (RunResult, e TotalTimeoutSeconds: req.TimeoutSeconds, }, ResponseContract: &corepb.AgentResponseContract{ - Format: corepb.AgentOutputFormat_AGENT_OUTPUT_FORMAT_TEXT, + Format: format, + JsonSchema: req.JSONSchema, + RepairInvalidOutput: req.Repair, }, RequestId: req.RequestID, IdempotencyKey: req.IdempotencyKey, diff --git a/internal/inference/types.go b/internal/inference/types.go index 575bd14..d22c4fb 100644 --- a/internal/inference/types.go +++ b/internal/inference/types.go @@ -13,6 +13,8 @@ type RunRequest struct { AgentID string RequestID string IdempotencyKey string + JSONSchema string + Repair bool TimeoutSeconds uint32 Metadata map[string]string ToolsEnabled bool diff --git a/internal/tasksboard/loop.go b/internal/tasksboard/loop.go index 5e4926c..0870227 100644 --- a/internal/tasksboard/loop.go +++ b/internal/tasksboard/loop.go @@ -2,6 +2,7 @@ package tasksboard import ( "context" + "encoding/json" "fmt" "log/slog" "strings" @@ -13,7 +14,13 @@ import ( "l-ai-agent-kaya/internal/tasksws" ) -const agentID = "kaya" +const ( + agentID = "kaya" + + floodMaxComments = 15 + floodWindow = 30 * time.Minute + floodMarker = "[tasks-flood]" +) // Loop listens to Tasks board WS and replies to @kaya mentions. type Loop struct { @@ -26,9 +33,9 @@ type Loop struct { ResponseTimeout time.Duration Log *slog.Logger - mu sync.Mutex - seen map[string]struct{} - seenQ []string + mu sync.Mutex + seen map[string]struct{} + seenQ []string maxSeen int } @@ -82,7 +89,7 @@ func (l *Loop) handle(ctx context.Context, ev tasksws.Event) error { } taskID := ev.Task.ID - l.log().Info("mention reply", "comment_id", commentID, "task_id", taskID, "author", ev.Comment.AuthorID) + 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() @@ -96,6 +103,42 @@ func (l *Loop) handle(ctx context.Context, ev tasksws.Event) error { 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", "task_id", taskID, "window", floodWindow, "max", floodMaxComments) + body := fmt.Sprintf( + "@mira %s flood: ≥%d comments in %s on this task — please tag the human to intervene. Agents are pausing auto-replies.", + 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 { @@ -109,9 +152,15 @@ func (l *Loop) handle(ctx context.Context, ev tasksws.Event) error { model = "deepseek-v4-flash" } + sys := l.Profile.SystemPrompt() + sys += " Avoid @mentioning other agents unless you need a concrete action — never @mention just to acknowledge." + 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: l.Profile.SystemPrompt()}, + {Role: "system", Content: sys}, {Role: "user", Content: userPrompt}, }, Model: model, @@ -138,6 +187,54 @@ func (l *Loop) handle(ctx context.Context, ev tasksws.Event) error { 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 only if you must add new substance. Reply false for acknowledgements/thanks/repeats." + res, err := l.Core.RunAgent(ctx, inference.RunRequest{ + Messages: []inference.ChatMessage{ + {Role: "system", Content: "You gate Kaya's board replies to agent @mentions. 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()