package agents import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strings" "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/store" ) const readyPrompt = "Your turn has ended and the review of your latest work has finished. If the task is ready to merge, call ready_for_merge with an overview of the whole task, then end your turn with one line. " + "If the work shouldn't land at all (the approach failed, the task turned out unnecessary or already done), call suggest_discard with your reasoning instead, then end your turn with one line. " + "If it isn't ready (work remains that you mean to do, or you're waiting on the human), call neither: say what's left in one line and end your turn. " + "If you're waiting on the human for a decision or input, ask it with ask_human now, even if an earlier message already put it in prose, so it reaches their inbox; only an open ask_human question already counts as asked. Don't change any files this turn." type readyArgs struct { Overview string `json:"overview"` } func (m *Manager) readyForMergeTool(l *live) mcp.Tool { return mcp.Tool{ Name: "ready_for_merge", Description: "Tell the human this task is ready to merge, with an overview of the whole task: all of the branch's work, not just what changed since your last call or the last discussion. " + "Write it for someone jumping between tasks who hasn't followed this one: a summary of what the change does, the tradeoffs and compromises made, what was not done, and follow-up work for later. " + "byre shows it in the task's transcript and passes it to the human's lead agent. " + "Call it only once the review of your most recent changes has come back: byre reviews the work after each turn that changes files and messages you when that review finishes, so never call it in the same turn as a change, or while a review of your latest work is pending.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "overview": {"type": "string", "description": "Markdown, with sections for the summary, tradeoffs and compromises, not done, and future TODOs; leave out a section with nothing in it."} }, "required": ["overview"] }`), Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { args := readyArgs{} err := json.Unmarshal(raw, &args) if err != nil { return "", fmt.Errorf("malformed ready_for_merge call: %w", err) } overview := strings.TrimSpace(args.Overview) if overview == "" { return "", fmt.Errorf("%w: overview must not be empty", ErrInvalid) } err = m.store.MarkReady(ctx, l.taskID, overview, agentActor) if errors.Is(err, store.ErrNotFound) { return "", fmt.Errorf("%w: this task is closed", ErrConflict) } if err != nil { return "", err } return "Recorded: the human sees this task as ready to merge, with your overview.", nil }, } } type discardArgs struct { Reason string `json:"reason"` } func (m *Manager) suggestDiscardTool(l *live) mcp.Tool { return mcp.Tool{ Name: "suggest_discard", Description: "Suggest that the human discard this task rather than merge it, with your reasoning: for example the approach didn't work, the task turned out unnecessary, or another task already did it. " + "Write the reasoning for someone jumping between tasks who hasn't followed this one: what was tried, why it shouldn't land, and anything worth keeping for a later attempt. " + "byre shows it in the task's transcript with a Discard button and passes it to the human's lead agent. It only suggests: the human decides, and the task stays open until they do.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "reason": {"type": "string", "description": "Markdown: why this task should be discarded rather than merged."} }, "required": ["reason"] }`), Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { args := discardArgs{} err := json.Unmarshal(raw, &args) if err != nil { return "", fmt.Errorf("malformed suggest_discard call: %w", err) } reason := strings.TrimSpace(args.Reason) if reason == "" { return "", fmt.Errorf("%w: reason must not be empty", ErrInvalid) } err = m.store.SuggestDiscard(ctx, l.taskID, reason, agentActor) if errors.Is(err, store.ErrNotFound) { return "", fmt.Errorf("%w: this task is closed", ErrConflict) } if err != nil { return "", err } return "Recorded: the human sees your suggestion to discard this task, with your reasoning.", nil }, } } func (m *Manager) promptReady(taskID string, from any) { ctx := context.Background() err := m.tryPromptReady(ctx, taskID, from == string(api.TaskStateReviewing)) if err != nil { slog.Error("prompt ready for merge", "task", taskID, "err", err) } } func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview bool) error { sess, found, err := m.liveSession(ctx, taskID) if err != nil || !found { return err } unlock := m.lockDelivery(sess.Id) defer unlock() task, err := m.store.GetTask(ctx, taskID) if err != nil { return err } if task.Status != api.TaskStatusActive || task.State != api.TaskStateIdle || task.MergeAfterReview { return nil } if task.WorkflowStage != nil { finished, err := m.store.WorkflowFinished(ctx, *task.WorkflowRun) if err != nil || !finished { return err } } sess, err = m.store.GetSession(ctx, sess.Id) if err != nil || sess.State == api.AgentSessionStateRunning || m.interrupted(sess.Id) { return err } latest, err := m.store.LastReviewable(ctx, taskID) if errors.Is(err, store.ErrNotFound) { return nil } if err != nil { return err } reviews, err := m.store.ListReviews(ctx, taskID) if err != nil { return err } var review *api.Review for _, r := range reviews { if r.SnapshotN == latest.N { review = &r } } if review == nil || (review.State != api.ReviewStateDone && review.State != api.ReviewStateSkipped) { return nil } if fromReview && review.State == api.ReviewStateDone && review.Result != nil { findings, _ := (*review.Result)["findings"].([]any) if len(findings) > 0 { return nil } } due, err := m.store.ReadyPromptDue(ctx, taskID) if err != nil || !due { return err } open, err := m.store.ListInbox(ctx, store.InboxFilter{TaskID: taskID, State: api.Open}) if err != nil || len(open) > 0 { return err } _, err = m.Send(ctx, sess.Id, "byre", readyPrompt, "") if err != nil { return err } seq, err := m.store.LastTaskSeq(ctx, taskID) if err != nil { return err } err = m.store.SetReadyPromptSeq(ctx, taskID, seq) if err != nil { return err } slog.Info("ready for merge prompted", "task", taskID, "snapshot", latest.N, "session", sess.Id) return nil }