Files
byre/internal/agents/ready.go
T

260 lines
10 KiB
Go

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"
"g.fc.run/ai/byre/internal/workflow"
)
const readyPromptAsk = "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 them; only an open ask_human question already counts as asked. Don't change any files this turn."
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. " +
readyPromptAsk
const readyPromptUnchanged = "Your turn has ended, and this task's branch has no changes from the default branch, so there is nothing to merge. " +
"If the task is finished, or shouldn't go further (it needed no changes, the approach failed, or it was already done), call suggest_discard with your reasoning, then end your turn with one line. " +
readyPromptAsk
func finishStagePrompt(frozen store.FrozenWorkflow, stage string) string {
waiting := "the workflow can't finish"
next := workflow.Next(frozen.Definition, stage)
if len(next) > 0 {
waiting = fmt.Sprintf("%s can't start", quoteList(next))
}
return fmt.Sprintf("Your turn has ended and the review of your latest work has finished, but you haven't called finish_stage, so stage %q of workflow %q hasn't finished and %s. "+
"If the stage's work is done, call finish_stage with its output, then end your turn with one line. If work remains, do it now and then call finish_stage. "+
"If you're waiting on the human for a decision or input, ask it with ask_human and end your turn.", stage, frozen.Name, waiting)
}
type readyArgs struct {
Overview string `json:"overview"`
Merge string `json:"merge"`
}
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, with buttons to merge and complete the task or merge and continue it, 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."},
"merge": {"type": "string", "enum": ["complete", "continue"], "description": "The button byre highlights: complete (the default) lands the work and closes the task; continue lands the work so far and keeps the task open, for when you or the human mean to do more on 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)
}
if args.Merge == "" {
args.Merge = "complete"
}
if args.Merge != "complete" && args.Merge != "continue" {
return "", fmt.Errorf("%w: merge must be complete or continue", ErrInvalid)
}
err = m.store.MarkReady(ctx, l.taskID, overview, args.Merge, 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) cancelReadyTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "cancel_ready",
Description: "Withdraw your standing ready_for_merge or suggest_discard, when it no longer holds: for example the human raised a problem, or you found more work. " +
"The human then no longer sees the task as ready to merge or as suggested for discard; call ready_for_merge or suggest_discard again once it holds.",
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
err := m.store.ClearReady(ctx, l.taskID, agentActor, "")
if errors.Is(err, store.ErrNotFound) {
return "", fmt.Errorf("%w: this task has no standing ready_for_merge or suggest_discard", ErrConflict)
}
if err != nil {
return "", err
}
return "Withdrawn: the human no longer sees this task as ready to merge or suggested for discard.", nil
},
}
}
func readyState(s api.TaskState) bool {
return s == api.TaskStateIdle || s == api.TaskStateFinished || s == api.TaskStateReadyToMerge || s == api.TaskStateDiscardSuggested
}
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 || !readyState(task.State) {
return nil
}
stagePrompt := ""
if task.WorkflowStage != nil {
finished, err := m.store.WorkflowFinished(ctx, *task.WorkflowRun)
if err != nil {
return err
}
if !finished {
output, _, err := m.store.StageResult(ctx, taskID)
if err != nil || output != "" {
return err
}
frozen, err := m.store.TaskWorkflow(ctx, taskID)
if err != nil {
return err
}
stagePrompt = finishStagePrompt(frozen, *task.WorkflowStage)
}
}
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.snapshotReviews(ctx, taskID, latest.N)
if err != nil {
return err
}
if len(reviews) == 0 {
project, err := m.store.GetProject(ctx, task.ProjectId)
if err != nil || project.ReviewWorkflow != "" {
return err
}
}
settled, err := m.reviewsSettled(ctx, reviews)
if err != nil || !settled {
return err
}
for _, r := range reviews {
if r.State == api.ReviewStateFailed && task.Background {
m.auditError(ctx, task, fmt.Sprintf("Review %q of snapshot %d failed: %s. The audit can't go on without you.", r.Stage, latest.N, r.Error))
return nil
}
if r.State != api.ReviewStateDone && r.State != api.ReviewStateSkipped {
return nil
}
if fromReview && r.Result != nil {
findings, _ := (*r.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
}
prompt := readyPrompt
if len(reviews) == 1 && reviews[0].State == api.ReviewStateSkipped {
prompt = readyPromptUnchanged
}
if stagePrompt != "" {
prompt = stagePrompt
}
_, err = m.Send(ctx, sess.Id, "byre", prompt, "", "", "")
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
}