Files
byre/internal/agents/tasks.go
T

190 lines
6.3 KiB
Go

package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/todo"
)
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base, actor string) (api.Task, error) {
return m.createTask(ctx, project, api.Task{Title: title, CreatedBy: createdBy}, nil, base, actor)
}
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model, actor, workflowName string, discuss bool) ([]api.Task, error) {
if creator.GitName == "" || creator.GitEmail == "" {
return nil, ErrNoGitIdentity
}
src, found, err := git.ReadFile(project.RepoPath, project.DefaultBranch, todo.File)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("%w: %s has no %s", store.ErrNotFound, project.DefaultBranch, todo.File)
}
item, found := todo.Find(todo.Parse(src), title)
if !found {
return nil, fmt.Errorf("%w: %s on %s has no item %q", store.ErrNotFound, todo.File, project.DefaultBranch, title)
}
tasks, err := m.store.ListTasks(ctx, project.Id)
if err != nil {
return nil, err
}
for _, t := range tasks {
if t.Status == api.TaskStatusActive && t.TodoItem != nil && *t.TodoItem == title {
return nil, fmt.Errorf("%w: task %s is already working on %q", ErrConflict, t.Id, title)
}
}
started := []api.Task{}
if workflowName != "" {
started, err = m.StartWorkflow(ctx, project, creator, item.Title, project.DefaultBranch, actor, workflowName, todoPrompt(item, discuss), &item.Title)
if err != nil {
return nil, err
}
} else {
task, err := m.createTask(ctx, project, api.Task{Title: item.Title, CreatedBy: creator.Login, TodoItem: &item.Title}, nil, project.DefaultBranch, actor)
if err != nil {
return nil, err
}
_, err = m.create(ctx, task, creator, todoPrompt(item, discuss), model, actor, nil)
if err != nil {
return nil, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
started = append(started, task)
}
if actor == creator.Login {
m.setStartedTodo(creator.Login, true)
}
return started, nil
}
func todoPrompt(item api.TodoItem, discuss bool) string {
p := fmt.Sprintf("Work on this item from %[1]s at the repo root:\n\n%[2]s\n\nWhen the work is done, remove this item from %[1]s in this worktree, so the removal lands in the same merge as the work.",
todo.File, todo.Markdown(item))
if !discuss {
return p
}
return p + "\n\nThe human wants to talk this item through before any of it is built. Read what you need to understand it, then ask them about its open questions and your proposed approach with ask_human, and change nothing until they have answered."
}
func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Task, frozen *store.FrozenWorkflow, base, actor string) (api.Task, error) {
t.Id = store.NewID()
t.ProjectId = project.Id
if t.MergeTitle == "" {
t.MergeTitle = t.Title
}
t.Branch = "byre/" + t.Id
t.WorktreePath = filepath.Join(m.worktreeRoot, project.Id, t.Id)
t.Status = api.TaskStatusActive
t.CreatedAt = time.Now().UTC()
var err error
t.BaseRef, err = git.AddWorktree(project.RepoPath, t.WorktreePath, t.Branch, base)
if err != nil {
return api.Task{}, err
}
err = m.store.CreateTask(ctx, t, frozen, actor)
if err != nil {
return api.Task{}, err
}
return m.store.GetTask(ctx, t.Id)
}
type createTaskArgs struct {
Title string `json:"title"`
Prompt string `json:"prompt"`
Base string `json:"base"`
Model string `json:"model"`
}
func (m *Manager) createTaskTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "create_task",
Description: "Create a new byre task in this project, with its own branch and worktree, and start an agent on it with prompt as its first message. " +
"Use it to run independent pieces of work in parallel. The new agent sees nothing of your context, so the prompt must be a complete brief. " +
"base picks the starting commit: default_branch starts from the project's default branch; this_task starts from this task's HEAD, " +
"which includes your committed work but not uncommitted edits (byre commits at the end of each turn). " +
"Returns at once with the new task's id; the new agent runs on its own.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"title": {"type": "string", "description": "Short task title, shown in the task list."},
"prompt": {"type": "string", "description": "The new agent's first message: a complete, self-contained brief."},
"base": {"type": "string", "enum": ["default_branch", "this_task"]},
"model": {"type": "string", "description": "Model for the new agent. Defaults to this session's model."}
},
"required": ["title", "prompt", "base"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.createTaskFromAgent(ctx, l, raw)
},
}
}
func (m *Manager) createTaskFromAgent(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
args := createTaskArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed create_task call: %w", err)
}
if strings.TrimSpace(args.Title) == "" || strings.TrimSpace(args.Prompt) == "" {
return "", errors.New("title and prompt are required")
}
sess, err := m.store.GetSession(ctx, l.id)
if err != nil {
return "", err
}
project, err := m.store.GetProject(ctx, l.projectID)
if err != nil {
return "", err
}
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return "", err
}
base := ""
switch args.Base {
case "default_branch":
base = project.DefaultBranch
case "this_task":
base, err = git.Head(l.dir)
if err != nil {
return "", err
}
default:
return "", fmt.Errorf("base must be default_branch or this_task, not %q", args.Base)
}
model := args.Model
if model == "" {
model = sess.Model
}
task, err := m.CreateTask(ctx, project, args.Title, creator.Login, base, creator.Login)
if err != nil {
return "", err
}
_, err = m.create(ctx, task, creator, args.Prompt, model, agentActor, nil)
if err != nil {
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
return fmt.Sprintf("Task %s %q created from %s and its agent started.", task.Id, task.Title, task.BaseRef), nil
}