239 lines
11 KiB
Go
239 lines
11 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. " +
|
|
"If this task is a discussion with the human, call set_discussion with discussion=true to stop further completion nudges, then end your turn so the discussion can continue. When the discussion is over and the task is ready to finish, call set_discussion with discussion=false to enable completion nudges again, then call ready_for_merge or suggest_discard as appropriate. " +
|
|
"If you already called ready_for_merge, suggest_discard or ask_human before this message, and it still applies, call it again now: only a call made after this message counts. 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)
|
|
}
|
|
|
|
func (m *Manager) setDiscussionTool(l *live) mcp.Tool {
|
|
return mcp.Tool{
|
|
Name: "set_discussion",
|
|
Description: "Set whether this task is a discussion. Every task starts with discussion off. Turn it on while discussing with the human to stop completion nudges. When the discussion is over and the task is ready to finish, turn it off again, then call ready_for_merge or suggest_discard as appropriate.",
|
|
InputSchema: json.RawMessage(`{"type":"object","properties":{"discussion":{"type":"boolean","description":"True stops completion nudges; false enables them again."}},"required":["discussion"]}`),
|
|
Handler: mcp.Typed(func(ctx context.Context, args struct {
|
|
Discussion bool `json:"discussion"`
|
|
}) (string, error) {
|
|
err := m.store.SetTaskDiscussion(ctx, l.taskID, args.Discussion)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return "", fmt.Errorf("%w: this task is closed", ErrConflict)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if args.Discussion {
|
|
return "Discussion on: completion nudges are stopped. When the discussion is over and the task is ready to finish, call set_discussion with discussion=false, then ready_for_merge or suggest_discard as appropriate.", nil
|
|
}
|
|
return "Discussion off: completion nudges are enabled again.", nil
|
|
}),
|
|
}
|
|
}
|
|
|
|
type readyArgs struct {
|
|
Overview string `json:"overview"`
|
|
Merge api.TaskReadyMerge `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 major decisions made (each choice, the alternative it beat, why, and what it cost), 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, decisions, not done, and future TODOs; leave out a section with nothing in it. Write each decision as its own bullet starting with (?), e.g. '- (?) **Render on the server** rather than on a client: …'; byre gives a (?) bullet a Discuss link, so the human can comment on it. Use (?) on any other bullet the human may want to question."},
|
|
"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: mcp.Typed(func(ctx context.Context, args readyArgs) (string, error) {
|
|
overview := strings.TrimSpace(args.Overview)
|
|
if overview == "" {
|
|
return "", fmt.Errorf("%w: overview must not be empty", ErrInvalid)
|
|
}
|
|
if args.Merge == "" {
|
|
args.Merge = api.ReadyMergeComplete
|
|
}
|
|
if !args.Merge.Valid() {
|
|
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: mcp.Typed(func(ctx context.Context, args discardArgs) (string, error) {
|
|
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: mcp.Typed(func(ctx context.Context, args struct{}) (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 (m *Manager) selfClose(c store.SelfClose) {
|
|
ctx := context.Background()
|
|
err := m.approveSelfClose(ctx, c)
|
|
if err != nil {
|
|
action := "merge"
|
|
if c.Ready == store.ReadyDiscard {
|
|
action = "discard"
|
|
}
|
|
slog.Error("workflow self close", "task", c.TaskID, "ready", c.Ready, "err", err)
|
|
m.fileError(ctx, c.TaskID, fmt.Sprintf("The workflow lets this task %[1]s itself, but byre couldn't %[1]s it: %[2]v. Do it by hand.", action, err))
|
|
return
|
|
}
|
|
slog.Info("workflow self close", "task", c.TaskID, "ready", c.Ready)
|
|
}
|
|
|
|
func (m *Manager) approveSelfClose(ctx context.Context, c store.SelfClose) error {
|
|
task, project, err := m.activeTask(ctx, c.TaskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
taken, err := m.store.TakeReady(ctx, c.TaskID, c.Ready)
|
|
if err != nil || !taken {
|
|
return err
|
|
}
|
|
if c.Ready == store.ReadyDiscard {
|
|
_, err = m.Discard(ctx, task, "byre")
|
|
return err
|
|
}
|
|
user, err := m.store.GetUser(ctx, c.By)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c.Ready == store.ReadyContinue {
|
|
_, err = m.MergeToMain(ctx, task, project, user, task.MergeTitle)
|
|
return err
|
|
}
|
|
_, err = m.Merge(ctx, task, project, user, task.MergeTitle)
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) promptReady(p store.ReadyPrompt) {
|
|
ctx := context.Background()
|
|
err := m.sendReadyPrompt(ctx, p)
|
|
if err != nil {
|
|
slog.Info("ready for merge prompt not sent", "task", p.TaskID, "snapshot", p.Snapshot, "session", p.SessionID, "err", err)
|
|
return
|
|
}
|
|
slog.Info("ready for merge prompted", "task", p.TaskID, "snapshot", p.Snapshot, "session", p.SessionID)
|
|
}
|
|
|
|
func (m *Manager) sendReadyPrompt(ctx context.Context, p store.ReadyPrompt) error {
|
|
prompt := readyPrompt
|
|
switch p.Kind {
|
|
case store.ReadyPromptUnchanged:
|
|
prompt = readyPromptUnchanged
|
|
case store.ReadyPromptFinishStage:
|
|
task, err := m.store.GetTask(ctx, p.TaskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
frozen, err := m.store.TaskWorkflow(ctx, p.TaskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
prompt = finishStagePrompt(frozen, *task.WorkflowStage)
|
|
}
|
|
|
|
unlock := m.lockDelivery(p.SessionID)
|
|
defer unlock()
|
|
_, err := m.Send(ctx, p.SessionID, "byre", Prompt{Text: prompt})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
seq, err := m.store.LastTaskSeq(ctx, p.TaskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return m.store.SetReadyPromptSeq(ctx, p.TaskID, seq)
|
|
}
|