Files
byre/internal/agents/tasks.go
T

186 lines
6.0 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) ([]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), &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), 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) string {
return 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))
}
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
}