Add team tasks: several agents in one worktree sharing a board

This commit is contained in:
Ian Gulliver
2026-10-18 08:48:57 -07:00
parent 7f62df19d2
commit 6bcde13b2f
31 changed files with 1392 additions and 439 deletions
+85 -24
View File
@@ -141,7 +141,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
tool("send_message",
"Send a message to a task's agent; it starts a turn. Starts the agent when the task has none. "+
"If the agent is mid-turn, the message is queued like the human's: the agent reads it with its next tool call's result, "+
"or as its next turn if it makes no further tool call, and gets it only once.",
"or as its next turn if it makes no further tool call, and gets it only once. "+
"On a team task, the message is posted to the team's board instead, so every agent of the team gets it the same way; model is refused there.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"text": {"type": "string"},
@@ -168,8 +169,18 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if err != nil {
return "", err
}
if sess.Member != "" {
if args.Model != "" {
return "", fmt.Errorf("%w: model can't be given for a team task; its agents keep their own models", ErrInvalid)
}
err = m.Post(ctx, task, leadActor(user.Login), args.Text)
if err != nil {
return "", err
}
return fmt.Sprintf("Posted to %s's board; every agent of its team gets it.", task.Id), nil
}
if !found {
_, err = m.create(ctx, task, user, args.Text, args.Model, leadActor(user.Login), nil)
_, err = m.create(ctx, task, user, "", args.Text, args.Model, leadActor(user.Login), nil)
if err != nil {
return "", err
}
@@ -190,23 +201,31 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
"The new agent sees nothing else, so the prompt must be a complete brief. It starts from the project's default branch, "+
"or from from_task_id's committed HEAD. With workflow, it runs that workflow instead: each stage becomes its own task, titled \"<title>: <stage>\", on the stage's model, "+
"and this call creates the tasks of the stages that start from the brief (the others follow as the stages they come after finish); "+
"prompt is then optional and is added to every stage's first message as its brief, and model can't be given.",
"prompt is then optional and is added to every stage's first message as its brief, and model can't be given. "+
"With agents, it starts a team instead of one agent: each agent works in the task's one worktree on its own model, gets prompt and then its own prompt, "+
"and talks to the others through the task's board; model and workflow can't be given with it.",
`{"type": "object", "properties": {
"project_id": {"type": "string"},
"title": {"type": "string"},
"prompt": {"type": "string"},
"from_task_id": {"type": "string"},
"model": {"type": "string"},
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."}
"workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."},
"agents": {"type": "array", "description": "A team of at least two agents; the first is the team's first agent, which byre's own requests (merges, discards, review findings) go to.", "items": {"type": "object", "properties": {
"name": {"type": "string", "description": "One line, unique in the team, such as a role: architect, skeptic."},
"model": {"type": "string"},
"prompt": {"type": "string", "description": "This agent's own instructions: its role in the team."}
}, "required": ["name", "model", "prompt"]}}
}, "required": ["project_id", "title"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
args := struct {
ProjectID string `json:"project_id"`
Title string `json:"title"`
Prompt string `json:"prompt"`
FromTaskID string `json:"from_task_id"`
Model string `json:"model"`
Workflow string `json:"workflow"`
ProjectID string `json:"project_id"`
Title string `json:"title"`
Prompt string `json:"prompt"`
FromTaskID string `json:"from_task_id"`
Model string `json:"model"`
Workflow string `json:"workflow"`
Agents []api.TeamMember `json:"agents"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
@@ -221,6 +240,15 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if args.Workflow != "" && args.Model != "" {
return "", errModelWithWorkflow
}
if args.Agents != nil && (args.Workflow != "" || args.Model != "") {
return "", fmt.Errorf("%w: agents can't be combined with model or workflow", ErrInvalid)
}
if args.Agents != nil {
err = checkTeam(args.Agents)
if err != nil {
return "", err
}
}
if user.GitName == "" || user.GitEmail == "" {
return "", ErrNoGitIdentity
}
@@ -250,7 +278,14 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if err != nil {
return "", err
}
_, err = m.create(ctx, task, user, args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil)
if args.Agents != nil {
err = m.startTeam(ctx, task, user, args.Prompt, args.Agents, leadActor(user.Login))
if err != nil {
return "", fmt.Errorf("task %s created, but its team did not start: %w", task.Id, err)
}
return fmt.Sprintf("Task %s %q created from %s and its team of %d agents started.", task.Id, task.Title, task.BaseRef, len(args.Agents)), nil
}
_, err = m.create(ctx, task, user, "", args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil)
if err != nil {
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
}
@@ -423,7 +458,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
tool("show",
"Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+
"Whenever the human asks you to show something (\"show me that when you can\") or you want to show something, call show right away; never hold back because you expect a refusal, since only a refused show arranges byre's signal to try again. "+
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), reviews (the task's reviews by reviewer agents), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Tabs: transcript, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), reviews (the task's reviews by reviewer agents), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Omit both task_id and project_id to clear the pane. "+
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
"Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task or tab), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+
@@ -433,8 +468,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}
}, "required": ["tabs", "tab"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
f := api.Focus{}
@@ -764,12 +799,12 @@ func (m *Manager) liveSession(ctx context.Context, taskID string) (api.AgentSess
if err != nil {
return api.AgentSession{}, false, err
}
for _, s := range sessions {
if s.State != api.AgentSessionStateCompleted {
return s, true, nil
}
lead := teamLead(sessions)
sess, found := memberSession(sessions, lead)
if !found {
return api.AgentSession{Member: lead}, false, nil
}
return api.AgentSession{}, false, nil
return sess, true, nil
}
func (m *Manager) taskArg(ctx context.Context, raw json.RawMessage) (api.Task, api.Project, error) {
@@ -826,11 +861,20 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) {
started[*t.TodoItem] = true
}
agent := string(t.State)
sess, found, err := m.liveSession(ctx, t.Id)
sessions, err := m.store.ListSessions(ctx, t.Id)
if err != nil {
return "", err
}
if found && sess.Model != "" {
sess, found := memberSession(sessions, teamLead(sessions))
team := teamSessions(sessions)
switch {
case len(team) > 0:
members := []string{}
for _, s := range team {
members = append(members, fmt.Sprintf("%q %s on %s", s.Member, s.State, s.Model))
}
agent += "; team: " + strings.Join(members, ", ")
case found && sess.Model != "":
agent += "; agent on " + sess.Model
}
open := 0
@@ -909,6 +953,14 @@ func (m *Manager) transcriptText(ctx context.Context, taskID string, after *int6
if err != nil {
return "", err
}
sessions, err := m.store.ListSessions(ctx, task.Id)
if err != nil {
return "", err
}
members := map[string]string{}
for _, s := range sessions {
members[s.Id] = s.Member
}
b := &strings.Builder{}
for _, ev := range events {
@@ -916,9 +968,13 @@ func (m *Manager) transcriptText(ctx context.Context, taskID string, after *int6
continue
}
line := transcriptLine(ev, task.WorktreePath)
if line != "" {
fmt.Fprintf(b, "#%d %s\n", ev.Seq, line)
if line == "" {
continue
}
if ev.AgentSessionId != nil && members[*ev.AgentSessionId] != "" && ev.Kind != "board.posted" {
line = fmt.Sprintf("[%s] %s", members[*ev.AgentSessionId], line)
}
fmt.Fprintf(b, "#%d %s\n", ev.Seq, line)
}
if len(events) == limit && limit > 0 {
fmt.Fprintf(b, "(more may follow: call again with after_seq %d)\n", events[len(events)-1].Seq)
@@ -944,10 +1000,15 @@ func transcriptLine(ev api.Event, root string) string {
}
switch ev.Kind {
case "user.message":
if ev.Actor == "byre" {
switch ev.Actor {
case "byre":
return "byre: " + clip(text("text"), 300)
case boardActor:
return ""
}
return ev.Actor + ": " + text("text")
case "board.posted":
return fmt.Sprintf("board post by %s: %s", postAuthor(ev), text("text"))
case "agent.message":
return "agent: " + text("text")
case "agent.tool_call":