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}, base, actor) } func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model, actor string) (api.Task, error) { if creator.GitName == "" || creator.GitEmail == "" { return api.Task{}, ErrNoGitIdentity } src, found, err := git.ReadFile(project.RepoPath, project.DefaultBranch, todo.File) if err != nil { return api.Task{}, err } if !found { return api.Task{}, fmt.Errorf("%w: %s has no %s", store.ErrNotFound, project.DefaultBranch, todo.File) } item, found := todo.Find(todo.Parse(src), title) if !found { return api.Task{}, 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 api.Task{}, err } for _, t := range tasks { if t.Status == api.TaskStatusActive && t.TodoItem != nil && *t.TodoItem == title { return api.Task{}, fmt.Errorf("%w: task %s is already working on %q", ErrConflict, t.Id, title) } } task, err := m.createTask(ctx, project, api.Task{Title: item.Title, CreatedBy: creator.Login, TodoItem: &item.Title}, project.DefaultBranch, actor) if err != nil { return api.Task{}, err } _, err = m.create(ctx, task, creator, todoPrompt(item), model, actor, nil) if err != nil { return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } return task, 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, 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, actor) if err != nil { return api.Task{}, err } return t, nil } 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 }