Files
Agent-Squad/adapters/sqlite/squads.go
T
weichenandCursor a182d7662b Move cross-library squad code into adapter modules
Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients.
Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test.
Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:40:58 +00:00

214 lines
6.7 KiB
Go

package platform
import (
"fmt"
"strings"
)
// Squad is a leader plus agent and human members.
type Squad struct {
ID, WorkspaceID, Name, LeaderAgentID, Instructions string
}
// SquadMember is one roster row. Kind is agent or human.
type SquadMember struct {
Kind, ID, Name, RoleDescription string
}
func (s *Store) CreateSquad(squad Squad, members []SquadMember) (Squad, error) {
if squad.ID == "" {
squad.ID = newID()
}
if _, err := s.db.Exec(`INSERT INTO squad(id,workspace_id,name,leader_agent_id,instructions,created_at) VALUES(?,?,?,?,?,?)`,
squad.ID, squad.WorkspaceID, squad.Name, squad.LeaderAgentID, squad.Instructions, now()); err != nil {
return Squad{}, err
}
for _, m := range members {
if _, err := s.db.Exec(`INSERT INTO squad_member(squad_id,member_kind,member_id,role_description) VALUES(?,?,?,?)`,
squad.ID, m.Kind, m.ID, m.RoleDescription); err != nil {
return Squad{}, err
}
}
return squad, nil
}
// LeaderBrief is the fixed protocol, the roster with mention links, and the instructions.
func (s *Store) LeaderBrief(squadID string) (string, error) {
var name, instructions, leader string
if err := s.db.QueryRow(`SELECT name, instructions, leader_agent_id FROM squad WHERE id=?`, squadID).Scan(&name, &instructions, &leader); err != nil {
return "", err
}
leaderAgent, err := s.GetAgent(leader)
if err != nil {
return "", err
}
var b strings.Builder
b.WriteString("## Squad Operating Protocol\n")
b.WriteString("Delegate with one short comment that mentions the owner. Record squad activity every turn as action, no_action, or failed. Stop after dispatch. Do not implement the task yourself. Do not both mention a member and create a todo sub-issue for the same work.\n\n")
b.WriteString("## Squad Roster\n")
fmt.Fprintf(&b, "- [@%s](mention://agent/%s) leader of %s\n", leaderAgent.Name, leaderAgent.ID, name)
rows, err := s.db.Query(`SELECT member_kind, member_id, role_description FROM squad_member WHERE squad_id=?`, squadID)
if err != nil {
return "", err
}
type rosterRow struct{ kind, id, role string }
var roster []rosterRow
for rows.Next() {
var row rosterRow
if err := rows.Scan(&row.kind, &row.id, &row.role); err != nil {
rows.Close()
return "", err
}
roster = append(roster, row)
}
err = rows.Err()
rows.Close()
if err != nil {
return "", err
}
for _, row := range roster {
memberName := row.id
if row.kind == "agent" {
if a, err := s.GetAgent(row.id); err == nil {
memberName = a.Name
}
} else {
_ = s.db.QueryRow(`SELECT name FROM member WHERE id=?`, row.id).Scan(&memberName)
}
scheme := "member"
if row.kind == "agent" {
scheme = "agent"
}
fmt.Fprintf(&b, "- [@%s](mention://%s/%s) %s\n", memberName, scheme, row.id, row.role)
}
b.WriteString("\n## Squad Instructions\n")
b.WriteString(instructions)
b.WriteString("\n")
return b.String(), nil
}
// RecordActivity appends one squad activity row. action is action, no_action, or failed.
func (s *Store) RecordActivity(squadID, issueID, actorID, action, detail string) error {
switch action {
case "action", "no_action", "failed":
default:
return fmt.Errorf("activity action must be action, no_action, or failed")
}
_, err := s.db.Exec(`INSERT INTO activity(id,squad_id,issue_id,actor_id,action,detail,created_at) VALUES(?,?,?,?,?,?,?)`,
newID(), squadID, issueID, actorID, action, detail, now())
return err
}
// TouchStatus lets the squad leader move an issue. The first turn sets in_progress.
// goalMet sets in_review. Other actors are refused.
func (s *Store) TouchStatus(issueID, actorAgentID string, goalMet bool) error {
issue, err := s.GetIssue(issueID)
if err != nil {
return err
}
if issue.AssigneeKind != "squad" {
return fmt.Errorf("status changes require a squad assignee")
}
var leader string
if err := s.db.QueryRow(`SELECT leader_agent_id FROM squad WHERE id=?`, issue.AssigneeID).Scan(&leader); err != nil {
return err
}
if actorAgentID != leader {
return fmt.Errorf("only the squad leader may change issue status")
}
next := issue.Status
if goalMet {
next = "in_review"
} else if issue.Status == "todo" || issue.Status == "" {
next = "in_progress"
}
_, err = s.db.Exec(`UPDATE issue SET status=? WHERE id=?`, next, issueID)
return err
}
// CloseIssue marks a sub-issue done and wakes the leader when every
// sibling in the same stage is closed. Delegation depth is capped at 4.
func (s *Store) CloseIssue(issueID string) error {
issue, err := s.GetIssue(issueID)
if err != nil {
return err
}
if depth, err := s.issueDepth(issue); err != nil {
return err
} else if depth > 4 {
return fmt.Errorf("delegation depth cap is 4")
}
if _, err := s.db.Exec(`UPDATE issue SET status='done' WHERE id=?`, issueID); err != nil {
return err
}
if issue.ParentID == "" || issue.Stage == "" {
return nil
}
var open int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM issue WHERE parent_id=? AND stage=? AND status NOT IN ('done','cancelled')`, issue.ParentID, issue.Stage).Scan(&open); err != nil {
return err
}
if open > 0 {
return nil
}
parent, err := s.GetIssue(issue.ParentID)
if err != nil {
return err
}
if parent.AssigneeKind != "squad" {
return nil
}
var leader string
if err := s.db.QueryRow(`SELECT leader_agent_id FROM squad WHERE id=?`, parent.AssigneeID).Scan(&leader); err != nil {
return err
}
_, err = s.enqueue(parent, leader, true, parent.AssigneeID)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO wakeup(id,task_id,kind,fire_at,created_at) VALUES(?,?,?,?,?)`,
newID(), parent.ID, "child_done", "", now())
return err
}
func (s *Store) issueDepth(issue Issue) (int, error) {
depth := 0
cur := issue
for cur.ParentID != "" && depth < 8 {
depth++
next, err := s.GetIssue(cur.ParentID)
if err != nil {
return 0, err
}
cur = next
}
return depth, nil
}
// PlanStages creates one sub-issue per stage title under parent.
func (s *Store) PlanStages(parentID string, stages []string) ([]Issue, error) {
parent, err := s.GetIssue(parentID)
if err != nil {
return nil, err
}
var out []Issue
for i, title := range stages {
child, err := s.CreateIssue(Issue{
WorkspaceID: parent.WorkspaceID, Title: title, Body: title,
Status: "todo", ParentID: parent.ID, Stage: fmt.Sprintf("%d", i+1),
AssigneeKind: parent.AssigneeKind, AssigneeID: parent.AssigneeID,
})
if err != nil {
return nil, err
}
out = append(out, child)
}
return out, nil
}
// DelegateComment posts a mention comment. It does not write a JSON file.
func (s *Store) DelegateComment(issueID, authorAgentID, mentionedAgentID, body string) (Comment, error) {
text := body + " [@" + mentionedAgentID + "](mention://agent/" + mentionedAgentID + ")"
return s.AddComment(Comment{IssueID: issueID, AuthorKind: "agent", AuthorID: authorAgentID, Body: text})
}