Add team tasks: several agents in one worktree sharing a board
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
"g.fc.run/misc/byre/internal/workflow"
|
||||
)
|
||||
|
||||
const boardActor = "board"
|
||||
|
||||
func teamLead(sessions []api.AgentSession) string {
|
||||
if len(sessions) == 0 {
|
||||
return ""
|
||||
}
|
||||
return sessions[0].Member
|
||||
}
|
||||
|
||||
func memberSession(sessions []api.AgentSession, member string) (api.AgentSession, bool) {
|
||||
for i := len(sessions) - 1; i >= 0; i-- {
|
||||
s := sessions[i]
|
||||
if s.Member == member && s.State != api.AgentSessionStateCompleted {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return api.AgentSession{}, false
|
||||
}
|
||||
|
||||
func teamSessions(sessions []api.AgentSession) []api.AgentSession {
|
||||
team := []api.AgentSession{}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range sessions {
|
||||
if s.Member == "" || seen[s.Member] {
|
||||
continue
|
||||
}
|
||||
seen[s.Member] = true
|
||||
live, found := memberSession(sessions, s.Member)
|
||||
if !found {
|
||||
live = latestSession(sessions, s.Member)
|
||||
}
|
||||
team = append(team, live)
|
||||
}
|
||||
return team
|
||||
}
|
||||
|
||||
func latestSession(sessions []api.AgentSession, member string) api.AgentSession {
|
||||
latest := api.AgentSession{}
|
||||
for _, s := range sessions {
|
||||
if s.Member == member {
|
||||
latest = s
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func (m *Manager) leadsTask(ctx context.Context, taskID, sessionID string) bool {
|
||||
sess, found, err := m.liveSession(ctx, taskID)
|
||||
if err != nil {
|
||||
slog.Error("find task's first agent", "task", taskID, "err", err)
|
||||
return false
|
||||
}
|
||||
return found && sess.Id == sessionID
|
||||
}
|
||||
|
||||
func checkTeam(members []api.TeamMember) error {
|
||||
problems := workflow.TeamProblems(members)
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("%w: %s", ErrInvalid, strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) startTeam(ctx context.Context, task api.Task, creator api.User, shared string, members []api.TeamMember, actor string) error {
|
||||
err := checkTeam(members)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if creator.GitName == "" || creator.GitEmail == "" {
|
||||
return ErrNoGitIdentity
|
||||
}
|
||||
sessions := []api.AgentSession{}
|
||||
for _, mem := range members {
|
||||
sess, err := m.newSession(ctx, task, creator, mem.Name, mem.Model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
failed := []string{}
|
||||
for i, sess := range sessions {
|
||||
_, err = m.turn(ctx, sess, task, actor, teamPrompt(shared, members[i]), nil)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%q: %v", members[i].Name, err))
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("these agents did not start, so the team is incomplete; discard the task and create it again: %s", strings.Join(failed, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func teamPrompt(shared string, mem api.TeamMember) string {
|
||||
return fmt.Sprintf("%s\n\n## Your part: %s\n\n%s", strings.TrimSpace(shared), mem.Name, strings.TrimSpace(mem.Prompt))
|
||||
}
|
||||
|
||||
func teamGuidance(sessions []api.AgentSession, member string, stage bool) string {
|
||||
team := teamSessions(sessions)
|
||||
names := []string{}
|
||||
for _, s := range team {
|
||||
names = append(names, fmt.Sprintf("%q (%s)", s.Member, s.Model))
|
||||
}
|
||||
lead := teamLead(sessions)
|
||||
g := fmt.Sprintf("\n- This task is worked by a team of agents in this one worktree: %s. You are %q. "+
|
||||
"The team talks through a shared board: post_to_board sends a post to every other agent (a working agent gets it after its next tool call; an idle one starts a turn with it), and read_board reads the posts so far. "+
|
||||
"The human and the lead post there too. Post only when you add something: a finding, a proposal, an objection, a decision, a request. Don't post acknowledgements or thanks, since every post wakes every other agent. "+
|
||||
"Your final message goes to the human, not to the team. The other agents change the same files: re-read a file before you edit it, and say on the board when you take on a file or an area.",
|
||||
strings.Join(names, ", "), member)
|
||||
if !stage {
|
||||
return g
|
||||
}
|
||||
if member == lead {
|
||||
return g + " You are the team's first agent, so you call finish_stage once the team's work on this stage is done; the others tell you on the board when their parts are."
|
||||
}
|
||||
return g + fmt.Sprintf(" %q, the team's first agent, finishes this stage with finish_stage; tell it on the board when your part is done.", lead)
|
||||
}
|
||||
|
||||
type poster struct {
|
||||
actor string
|
||||
member string
|
||||
model string
|
||||
session string
|
||||
}
|
||||
|
||||
func (m *Manager) Post(ctx context.Context, task api.Task, actor, text string) error {
|
||||
return m.post(ctx, task, poster{actor: actor}, text)
|
||||
}
|
||||
|
||||
func (m *Manager) post(ctx context.Context, task api.Task, from poster, text string) error {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return fmt.Errorf("%w: text is empty", ErrInvalid)
|
||||
}
|
||||
if task.Status != api.TaskStatusActive {
|
||||
return fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.Status)
|
||||
}
|
||||
sessions, err := m.store.ListSessions(ctx, task.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
team := teamSessions(sessions)
|
||||
if len(team) == 0 {
|
||||
return fmt.Errorf("%w: task %s has no team, so no board", ErrInvalid, task.Id)
|
||||
}
|
||||
|
||||
payload := map[string]any{"text": text}
|
||||
if from.member != "" {
|
||||
payload["member"] = from.member
|
||||
payload["model"] = from.model
|
||||
}
|
||||
ev := api.Event{Kind: "board.posted", ProjectId: &task.ProjectId, TaskId: &task.Id, Actor: from.actor, Payload: payload}
|
||||
if from.session != "" {
|
||||
ev.AgentSessionId = &from.session
|
||||
}
|
||||
err = m.store.AppendEvent(ctx, ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
who := from.actor
|
||||
if from.member != "" {
|
||||
who = fmt.Sprintf("%q", from.member)
|
||||
}
|
||||
msg := fmt.Sprintf("%s posted on the board:\n\n%s", who, text)
|
||||
for _, s := range team {
|
||||
if s.Member == from.member {
|
||||
continue
|
||||
}
|
||||
m.deliverPost(context.WithoutCancel(ctx), task, s, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) deliverPost(ctx context.Context, task api.Task, s api.AgentSession, msg string) {
|
||||
started, err := m.store.SessionStarted(ctx, s.Id)
|
||||
if err == nil && started {
|
||||
_, _, err = m.Message(ctx, s.Id, boardActor, msg, "")
|
||||
}
|
||||
if err == nil && started {
|
||||
return
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrConflict) {
|
||||
slog.Error("deliver board post", "task", task.Id, "member", s.Member, "err", err)
|
||||
}
|
||||
err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: task.Id, Member: s.Member, Actor: boardActor, Text: msg}, task.ProjectId, s.Id)
|
||||
if err != nil {
|
||||
slog.Error("queue board post", "task", task.Id, "member", s.Member, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) deliverTeam(ctx context.Context, l *live) {
|
||||
if l.member == "" {
|
||||
return
|
||||
}
|
||||
sessions, err := m.store.ListSessions(ctx, l.taskID)
|
||||
if err != nil {
|
||||
slog.Error("list team sessions", "task", l.taskID, "err", err)
|
||||
return
|
||||
}
|
||||
for _, s := range teamSessions(sessions) {
|
||||
if s.Id == l.id || s.State != api.AgentSessionStateIdle {
|
||||
continue
|
||||
}
|
||||
err = m.Deliver(ctx, s.Id)
|
||||
if err != nil {
|
||||
slog.Error("deliver to team member", "session", s.Id, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) boardTools(l *live) []mcp.Tool {
|
||||
return []mcp.Tool{
|
||||
{
|
||||
Name: "post_to_board",
|
||||
Description: "Post to this task's team board. Every other agent of the team gets the post: a working agent after its next tool call, an idle one as a new turn. The human sees the board in the task's Board tab.",
|
||||
InputSchema: json.RawMessage(`{"type": "object", "properties": {"text": {"type": "string", "description": "Markdown."}}, "required": ["text"]}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := struct {
|
||||
Text string `json:"text"`
|
||||
}{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed post_to_board call: %w", err)
|
||||
}
|
||||
task, err := m.store.GetTask(ctx, l.taskID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = m.post(ctx, task, poster{actor: agentActor, member: l.member, model: l.model, session: l.id}, args.Text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Posted to the board.", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_board",
|
||||
Description: "Read this task's team board: every post, oldest first, each with its seq and who posted it. With after_seq, only the posts after it.",
|
||||
InputSchema: json.RawMessage(`{"type": "object", "properties": {"after_seq": {"type": "integer"}}}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := struct {
|
||||
AfterSeq int64 `json:"after_seq"`
|
||||
}{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed read_board call: %w", err)
|
||||
}
|
||||
posts, err := m.store.BoardPosts(ctx, l.taskID, args.AfterSeq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
return "No posts.", nil
|
||||
}
|
||||
b := &strings.Builder{}
|
||||
for _, ev := range posts {
|
||||
fmt.Fprintf(b, "#%d %s:\n%v\n\n", ev.Seq, postAuthor(ev), ev.Payload["text"])
|
||||
}
|
||||
return strings.TrimSpace(b.String()), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func postAuthor(ev api.Event) string {
|
||||
member, _ := ev.Payload["member"].(string)
|
||||
if member != "" {
|
||||
return fmt.Sprintf("%q (%v)", member, ev.Payload["model"])
|
||||
}
|
||||
return ev.Actor
|
||||
}
|
||||
Reference in New Issue
Block a user