Files
byre/internal/agents/subtasks.go
T

436 lines
15 KiB
Go
Raw Normal View History

2026-10-16 10:36:38 -07:00
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"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"
)
func (m *Manager) subtaskTools(l *live) []mcp.Tool {
tool := func(name, description, schema string, run func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error)) mcp.Tool {
return mcp.Tool{
Name: name,
Description: description,
InputSchema: json.RawMessage(schema),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
sess, err := m.store.GetSession(ctx, l.id)
if err != nil {
return "", err
}
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
if err != nil {
return "", err
}
return run(ctx, creator, raw)
},
}
}
taskOnly := `{"type": "object", "properties": {"task_id": {"type": "string"}}, "required": ["task_id"]}`
return []mcp.Tool{
tool("create_subtask",
"Create a subtask of this task: a byre task with its own branch and worktree, started from this task's HEAD (your committed work; byre commits at the end of each turn), "+
"with an agent on it that gets prompt as its first message. Use it to run a piece of work in parallel or to get another model's view. "+
"The new agent sees nothing of your context, so the prompt must be a complete brief. "+
"Returns at once with the subtask's id. The final message of each of its turns reaches you as a message from byre; "+
"manage it with list_subtasks, read_subtask, message_subtask, interrupt_subtask, merge_subtask and discard_subtask. "+
"This task can't be merged or discarded while it has active subtasks.",
`{"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."},
"model": {"type": "string", "description": "Model for the new agent: gpt-* models run on Codex, every other model on Claude Code. Defaults to this session's model."}
}, "required": ["title", "prompt"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
Title string `json:"title"`
Prompt string `json:"prompt"`
Model string `json:"model"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed create_subtask call: %w", err)
}
if strings.TrimSpace(args.Title) == "" || strings.TrimSpace(args.Prompt) == "" {
return "", errors.New("title and prompt are required")
}
project, err := m.store.GetProject(ctx, l.projectID)
if err != nil {
return "", err
}
base, err := git.Head(l.dir)
if err != nil {
return "", err
}
task, err := m.createTask(ctx, project, api.Task{Title: args.Title, CreatedBy: creator.Login, ParentTaskId: &l.taskID}, nil, base, creator.Login)
if err != nil {
return "", err
}
_, err = m.create(ctx, task, creator, "", args.Prompt, m.modelOr(args.Model, l), agentActor, nil)
2026-10-16 10:36:38 -07:00
if err != nil {
return "", fmt.Errorf("subtask %s created, but its agent did not start: %w", task.Id, err)
}
return fmt.Sprintf("Subtask %s %q created from %s, branch %s, worktree %s, and its agent started.", task.Id, task.Title, task.BaseRef, task.Branch, task.WorktreePath), nil
}),
tool("list_subtasks",
"List this task's active subtasks: id, title, the agent's state and model, branch and worktree.",
`{"type": "object", "properties": {}}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
subs, err := m.store.ActiveSubtasks(ctx, l.taskID)
if err != nil {
return "", err
}
if len(subs) == 0 {
return "No active subtasks.", nil
}
b := &strings.Builder{}
for _, t := range subs {
agent := "no agent"
sess, found, err := m.liveSession(ctx, t.Id)
if err != nil {
return "", err
}
if found {
agent = fmt.Sprintf("agent %s on %s", sess.State, sess.Model)
}
fmt.Fprintf(b, "- %s %q: %s; branch %s; worktree %s", t.Id, t.Title, agent, t.Branch, t.WorktreePath)
if t.MergingIntoParent {
b.WriteString("; waiting for you to merge it")
}
b.WriteString("\n")
}
return b.String(), nil
}),
tool("read_subtask",
"Read a subtask's transcript: messages, tool calls (briefly), tool results (cut short), turn ends and inbox filings, each with its event seq. "+
"Without after_seq it returns the last limit events; with it, the events after that seq.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"after_seq": {"type": "integer"},
"limit": {"type": "integer", "description": "At most this many events, default 100, at most 500."}
}, "required": ["task_id"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
AfterSeq *int64 `json:"after_seq"`
Limit int `json:"limit"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
task, err := m.subtask(ctx, l, args.TaskID, false)
if err != nil {
return "", err
}
return m.transcriptText(ctx, task.Id, args.AfterSeq, args.Limit)
}),
tool("message_subtask",
"Send a message to a subtask's agent. It starts a turn when the agent is idle, starts the agent when the subtask has none, "+
"and reaches it mid-turn when it is working.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"text": {"type": "string"}
}, "required": ["task_id", "text"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
Text string `json:"text"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
if strings.TrimSpace(args.Text) == "" {
return "", errors.New("text is empty")
}
task, err := m.subtask(ctx, l, args.TaskID, true)
if err != nil {
return "", err
}
err = m.messageTask(ctx, task, creator, agentActor, args.Text)
if err != nil {
return "", err
}
return fmt.Sprintf("Sent to %s's agent.", task.Id), nil
}),
tool("interrupt_subtask", "Interrupt the turn a subtask's agent is running.", taskOnly,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
task, err := m.subtaskArg(ctx, l, raw)
if err != nil {
return "", err
}
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return "", err
}
if !found {
return "", fmt.Errorf("%w: subtask %s has no agent", ErrConflict, task.Id)
}
_, err = m.Interrupt(ctx, sess.Id)
if err != nil {
return "", err
}
return "Interrupted.", nil
}),
tool("merge_subtask",
"Merge a subtask. target parent: byre sends you a message asking you to merge the subtask's branch into this task's branch, "+
"and closes the subtask once its branch is in this branch's history at the end of one of your turns. "+
"target main: the subtask's agent merges the default branch in, then byre squashes it onto the default branch, pushes it and closes it, as the human's Merge does. "+
"Refused while the subtask's agent is mid-turn.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"target": {"type": "string", "enum": ["parent", "main"]}
}, "required": ["task_id", "target"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
TaskID string `json:"task_id"`
Target string `json:"target"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", err
}
task, err := m.subtask(ctx, l, args.TaskID, true)
if err != nil {
return "", err
}
switch args.Target {
case "parent":
_, err = m.MergeIntoParent(ctx, task, creator)
if err != nil {
return "", err
}
return fmt.Sprintf("Merge of %s into this task requested: byre's message asking you to merge %s follows once this turn ends.", task.Id, task.Branch), nil
case "main":
project, err := m.store.GetProject(ctx, task.ProjectId)
if err != nil {
return "", err
}
task, err = m.Merge(ctx, task, project, creator, task.MergeTitle)
if err != nil {
return "", err
}
if task.MergeQueuedAt != nil {
return fmt.Sprintf("Merge of %s held until the merge in progress in the project is done.", task.Id), nil
}
return fmt.Sprintf("Merge of %s started.", task.Id), nil
}
return "", fmt.Errorf("%w: target must be parent or main, not %q", ErrInvalid, args.Target)
}),
tool("discard_subtask",
"Discard a subtask: its agent gets one turn to write the task summary, then byre stops it and deletes its worktree and branch without merging. This destroys its unmerged work.",
taskOnly,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
task, err := m.subtaskArg(ctx, l, raw)
if err != nil {
return "", err
}
_, err = m.Discard(ctx, task, creator.Login)
if err != nil {
return "", err
}
return fmt.Sprintf("Discard of %s started: its agent writes the task summary, then byre closes it.", task.Id), nil
}),
}
}
func (m *Manager) subtaskArg(ctx context.Context, l *live, raw json.RawMessage) (api.Task, error) {
args := struct {
TaskID string `json:"task_id"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return api.Task{}, err
}
return m.subtask(ctx, l, args.TaskID, true)
}
func (m *Manager) subtask(ctx context.Context, l *live, taskID string, active bool) (api.Task, error) {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
return api.Task{}, fmt.Errorf("task %s: %w", taskID, err)
}
if task.ParentTaskId == nil || *task.ParentTaskId != l.taskID {
return api.Task{}, fmt.Errorf("%w: task %s is not a subtask of this task", ErrInvalid, taskID)
}
if active && task.Status != api.TaskStatusActive {
return api.Task{}, fmt.Errorf("%w: subtask %s is %s", ErrConflict, task.Id, task.Status)
}
return task, nil
}
func (m *Manager) checkNoSubtasks(ctx context.Context, task api.Task) error {
subs, err := m.store.ActiveSubtasks(ctx, task.Id)
if err != nil {
return err
}
if len(subs) == 0 {
return nil
}
names := []string{}
for _, t := range subs {
names = append(names, fmt.Sprintf("%s %q", t.Id, t.Title))
}
return fmt.Errorf("%w: task %s has %d active subtasks; merge or discard them first: %s", ErrConflict, task.Id, len(subs), strings.Join(names, ", "))
}
func (m *Manager) messageTask(ctx context.Context, task api.Task, user api.User, actor, text string) error {
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return err
}
if !found {
_, err = m.create(ctx, task, user, sess.Member, text, "", actor, nil)
2026-10-16 10:36:38 -07:00
return err
}
2026-10-18 00:12:42 -07:00
_, _, err = m.Message(ctx, sess.Id, actor, text, "")
2026-10-16 10:36:38 -07:00
return err
}
func (m *Manager) MergeIntoParent(ctx context.Context, task api.Task, user api.User) (api.Task, error) {
if task.ParentTaskId == nil {
return api.Task{}, fmt.Errorf("%w: task %s is not a subtask", ErrInvalid, task.Id)
}
err := m.checkNoSubtasks(ctx, task)
if err != nil {
return api.Task{}, err
}
parent, _, err := m.activeTask(ctx, *task.ParentTaskId)
if err != nil {
return api.Task{}, err
}
sess, found, err := m.liveSession(ctx, task.Id)
if err != nil {
return api.Task{}, err
}
if found && sess.State == api.AgentSessionStateRunning {
return api.Task{}, fmt.Errorf("%w: subtask %s's agent is mid-turn; wait for the turn to end or interrupt it", ErrConflict, task.Id)
}
if task.Merging || task.MergeQueuedAt != nil {
2026-10-16 10:36:38 -07:00
return api.Task{}, fmt.Errorf("%w: subtask %s is merging into the default branch", ErrConflict, task.Id)
}
err = m.store.SetParentMerge(ctx, task.Id, user.Login)
switch {
case errors.Is(err, store.ErrNotFound):
return api.Task{}, fmt.Errorf("%w: subtask %s is closed", ErrConflict, task.Id)
case errors.Is(err, store.ErrDiscardPending), errors.Is(err, store.ErrMerging), errors.Is(err, store.ErrParentMergeRequested):
2026-10-16 10:36:38 -07:00
return api.Task{}, fmt.Errorf("%w: subtask %s: %w", ErrConflict, task.Id, err)
case err != nil:
return api.Task{}, err
}
prompt := mergeInPrompt(task.Branch) + fmt.Sprintf(" This merges in your subtask %q (%s); byre closes the subtask once its branch is in this branch's history at the end of a turn.", task.Title, task.Id)
err = m.messageTask(ctx, parent, user, "byre", prompt)
if err != nil {
cerr := m.store.ClearParentMerge(ctx, task.Id)
if cerr != nil {
slog.Error("clear parent merge", "task", task.Id, "err", cerr)
}
return api.Task{}, err
}
m.clearMergeAfterReview(ctx, task.Id)
2026-10-16 10:36:38 -07:00
return m.store.GetTask(ctx, task.Id)
}
func (m *Manager) closeMergedSubtasks(parentID, dir string) {
ctx := context.Background()
subs, err := m.store.ActiveSubtasks(ctx, parentID)
if err != nil {
slog.Error("list subtasks", "task", parentID, "err", err)
return
}
for _, t := range subs {
if !t.MergingIntoParent {
continue
}
err = m.closeMergedSubtask(ctx, t, parentID, dir)
if err != nil {
slog.Error("close merged subtask", "task", t.Id, "parent", parentID, "err", err)
}
}
}
func (m *Manager) closeMergedSubtask(ctx context.Context, t api.Task, parentID, dir string) error {
unlock := m.lockTask(t.Id)
defer unlock()
t, err := m.store.GetTask(ctx, t.Id)
if err != nil || t.Status != api.TaskStatusActive {
return err
}
2026-10-16 10:36:38 -07:00
merged, err := git.IsAncestor(dir, t.Branch, "HEAD")
if err != nil || !merged {
return err
}
err = m.checkNoSubtasks(ctx, t)
if err != nil {
return err
}
login, err := m.store.ParentMergeBy(ctx, t.Id)
if err != nil {
return err
}
head, err := git.Head(dir)
if err != nil {
return err
}
project, err := m.store.GetProject(ctx, t.ProjectId)
if err != nil {
return err
}
err = m.RemoveTask(ctx, t, project, login)
if err != nil {
return err
}
_, err = m.store.MergeTaskIntoParent(ctx, t.Id, parentID, head, login)
if err != nil {
return err
}
slog.Info("subtask merged into parent", "task", t.Id, "parent", parentID, "commit", head)
return nil
}
func (m *Manager) reportToParent(taskID, result string) {
ctx := context.Background()
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
slog.Error("load subtask to report", "task", taskID, "err", err)
return
}
if task.ParentTaskId == nil || task.MergingIntoParent {
return
}
parent, err := m.store.GetTask(ctx, *task.ParentTaskId)
if err != nil {
slog.Error("load parent to report to", "task", taskID, "err", err)
return
}
if parent.Status != api.TaskStatusActive {
return
}
user, err := m.store.GetUser(ctx, task.CreatedBy)
if err != nil {
slog.Error("load subtask creator", "task", taskID, "err", err)
return
}
text := fmt.Sprintf("Your subtask %q (%s, branch %s) finished a turn:\n\n%s", task.Title, task.Id, task.Branch, quote(result))
err = m.messageTask(ctx, parent, user, "byre", text)
if err != nil {
slog.Error("report subtask turn to parent", "task", taskID, "parent", parent.Id, "err", err)
}
}