mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* fix(squad): inject leader briefing by task flag, not issue assignee Key squad-leader briefing injection off task.IsLeaderTask + task.SquadID instead of issue.AssigneeType=='squad'. The old gate missed the most common path — an @squad mention in a comment on an issue assigned to a plain agent (MUL-3724) — so the leader booted with zero squad context and did the work itself instead of orchestrating. - migration 127: add agent_task_queue.squad_id (no FK) + partial index - sqlc: CreateAgentTask stamps squad_id; CreateRetryTask inherits it - service: thread squadID through EnqueueTaskForSquadLeader(+WithHandoff), enqueueMentionTask, and the rerun path; all 5 call sites pass the squad id - daemon claim: unified injection keyed on leader-task + squad_id, with a defensive leader-identity re-check; quick-create block retained (it serves issue-less tasks and sets resp.SquadID/SquadName) - briefing: strengthen leader Operating Protocol opening - tests: claim-time injection (comment-mention/non-leader/null-squad), squad_id enqueue stamping, retry inheritance; existing fixture updated Co-authored-by: multica-agent <github@multica.ai> * test+docs(squad): dangling squad_id regression + clarify quick-create path Address review nits on #4606: - Add TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing: squad hard-deleted after enqueue leaves task.squad_id dangling (no FK); claim still 200 and skips injection via the err!=nil guard. This is the load-bearing contract for dropping the FK. - Rewrite the daemon.go injection comment to state quick-create does NOT use the is_leader_task/squad_id columns — it routes squad via the context JSON branch (qc.SquadID) and must not be folded into the column-based path. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: 魏和尚 <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
285 lines
12 KiB
Go
285 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// squadOperatingProtocol is the hard-coded system-level briefing prepended to
|
|
// every squad-leader claim. It explains the leader's coordinator role, the
|
|
// @mention dispatch mechanism, and the stop-after-dispatch contract.
|
|
//
|
|
// Keep this text English-only (matches existing agent-harness conventions)
|
|
// and keep the mention syntax exactly aligned with util.MentionRe — the
|
|
// "Squad Roster" block below renders concrete examples that round-trip
|
|
// through util.ParseMentions, and the protocol text refers to that format.
|
|
const squadOperatingProtocol = `## Squad Operating Protocol
|
|
|
|
**If you are reading this section, you have been activated as a squad LEADER
|
|
for this task — regardless of how the work reached you (direct assignment,
|
|
an @squad mention in a comment, quick-create, or autopilot).** Your job is to
|
|
**coordinate**, NOT to do the work yourself. Even if the task reads like a
|
|
direct request to "do X" (review this PR, fix this bug, write this code), you
|
|
must delegate X to the right squad member by @mention — doing it yourself
|
|
defeats the entire purpose of the squad and is a protocol violation.
|
|
|
|
Your responsibilities, in order:
|
|
|
|
1. **Read the issue** (title, description, latest comments, acceptance
|
|
criteria) and decide which squad member is best suited to do the work.
|
|
Match the task to each member's listed **skills** and role in the Squad
|
|
Roster below — prefer the member whose skills cover the work.
|
|
2. **Delegate by @mention.** Post a single comment on this issue that
|
|
@mentions the chosen member(s) and tells them what to do.
|
|
- **Be terse.** Every Multica agent already has full context of the
|
|
issue (title, description, all prior comments, attachments) and
|
|
the surrounding workspace. Do NOT restate or summarise the
|
|
issue body, prior discussion, or known facts in your delegation
|
|
comment — they read it themselves.
|
|
- Say only what cannot be inferred from the issue: who you're
|
|
picking, why them (one short clause), and any *additional*
|
|
constraints, hints, or sequencing you want them to follow.
|
|
Two or three sentences is usually plenty.
|
|
- Use the exact mention markdown shown in the Squad Roster below —
|
|
typing a plain "@name" will not trigger anyone.
|
|
3. **Record your evaluation.** After every trigger — whether you delegated,
|
|
decided no action is needed, or encountered an error — record it:
|
|
` + "`" + `multica squad activity <issue-id> <outcome> --reason "<short reason>"` + "`" + `
|
|
Outcome values: ` + "`" + `action` + "`" + ` (you delegated or acted),
|
|
` + "`" + `no_action` + "`" + ` (you evaluated and decided nothing is needed),
|
|
` + "`" + `failed` + "`" + ` (you hit an error).
|
|
This is mandatory on every turn — it records your decision in the
|
|
issue timeline so humans can see you evaluated the trigger.
|
|
4. **Stop after dispatching.** Once your delegation comment is posted
|
|
and evaluation recorded, end your turn. Do not continue working,
|
|
do not write code, do not open files. You will be re-triggered
|
|
automatically when:
|
|
- a delegated member posts an update or asks you a question;
|
|
- a delegated member finishes and the issue moves forward;
|
|
- someone @mentions you again on this issue.
|
|
5. **Re-evaluate on each trigger.** When you wake up again, read the new
|
|
activity and decide whether to delegate the next step, escalate to
|
|
the human reporter, or close the loop. If no action is needed
|
|
(e.g. a member posted a progress update that requires no response),
|
|
record ` + "`" + `no_action` + "`" + ` and exit silently.
|
|
|
|
Hard rules:
|
|
- EVERY delegation MUST use the full mention markdown syntax
|
|
` + "`" + `[@Name](mention://<type>/<UUID>)` + "`" + ` exactly as shown in the Squad
|
|
Roster. A plain "@name" or bare name does NOT trigger the agent —
|
|
if you skip the mention link, the task is never delivered and the
|
|
issue stalls. This is non-negotiable: no mention link = no delegation.
|
|
- Do NOT restate the issue body or prior comments in your delegation —
|
|
the assignee already has them. Repeating context is noise that
|
|
buries the actual instruction.
|
|
- Do NOT do the implementation work yourself unless the squad has no
|
|
other suitable members. The squad exists so work is split — bypassing
|
|
it defeats the point.
|
|
- Do NOT @mention members who don't appear in the Squad Roster below;
|
|
they are not part of this squad.
|
|
- One delegation comment per turn is enough. Avoid spamming multiple
|
|
near-identical comments.
|
|
- If the squad has no member capable of the task, post a comment
|
|
explaining the gap (and @mention the issue's reporter if possible)
|
|
rather than silently doing the work.
|
|
- ALWAYS call ` + "`" + `multica squad activity` + "`" + ` before ending your turn —
|
|
even when the outcome is no_action.
|
|
- A child issue you create with ` + "`" + `--status todo` + "`" + ` and an agent assignee
|
|
already fires that agent automatically — the assignment IS the trigger.
|
|
If you also @mention the same agent on this parent issue for the same
|
|
work, the agent runs twice in parallel (once from the mention, once
|
|
from the assignment). Pick exactly one path: either delegate by
|
|
@mention on this issue, or create a ` + "`" + `todo` + "`" + ` child issue assigned to
|
|
them. Never both for the same work.`
|
|
|
|
// buildSquadLeaderBriefing composes the full system briefing appended to a
|
|
// squad leader's Instructions when it claims a task on a squad-assigned
|
|
// issue. The returned string contains three sections:
|
|
//
|
|
// 1. Squad Operating Protocol (constant, system-level rules).
|
|
// 2. Squad Roster (data — leader self-row + members with literal
|
|
// `[@Name](mention://<type>/<UUID>)` strings ready to paste).
|
|
// 3. Squad Instructions (user-defined `squad.instructions`, omitted when
|
|
// empty so we don't leave a dangling heading).
|
|
//
|
|
// Archived agent members are skipped — there's no point asking the leader
|
|
// to delegate to a retired agent. Members whose underlying record can't be
|
|
// loaded (deleted user/agent races, FK weirdness) are also skipped silently.
|
|
func buildSquadLeaderBriefing(ctx context.Context, q *db.Queries, squad db.Squad) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(squadOperatingProtocol)
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString(buildSquadRoster(ctx, q, squad))
|
|
|
|
if trimmed := strings.TrimSpace(squad.Instructions); trimmed != "" {
|
|
sb.WriteString("\n\n## Squad Instructions (")
|
|
sb.WriteString(squad.Name)
|
|
sb.WriteString(")\n\n")
|
|
sb.WriteString(trimmed)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// buildSquadRoster renders the "## Squad Roster" section: a leader self-row
|
|
// plus one row per non-archived member, with literal mention markdown.
|
|
func buildSquadRoster(ctx context.Context, q *db.Queries, squad db.Squad) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("## Squad Roster\n\n")
|
|
|
|
// Leader self-row. Leaders are always agents (FK enforced in schema).
|
|
leaderName := "Leader"
|
|
if leader, err := q.GetAgent(ctx, squad.LeaderID); err == nil {
|
|
leaderName = leader.Name
|
|
}
|
|
sb.WriteString("Leader (you):\n")
|
|
sb.WriteString("- ")
|
|
sb.WriteString(leaderName)
|
|
sb.WriteString(" — agent — `")
|
|
sb.WriteString(formatMention(leaderName, "agent", util.UUIDToString(squad.LeaderID)))
|
|
sb.WriteString("`\n")
|
|
|
|
members, err := q.ListSquadMembers(ctx, squad.ID)
|
|
if err != nil {
|
|
members = nil
|
|
}
|
|
|
|
skillNamesByAgentID, skillsLoaded := loadSquadMemberSkillNames(ctx, q, members, util.UUIDToString(squad.LeaderID))
|
|
|
|
rows := make([]string, 0, len(members))
|
|
for _, m := range members {
|
|
// Skip the leader if they happen to also be in the member list —
|
|
// they're already shown above and we don't want self-delegation.
|
|
if m.MemberType == "agent" && util.UUIDToString(m.MemberID) == util.UUIDToString(squad.LeaderID) {
|
|
continue
|
|
}
|
|
row := renderMemberRow(ctx, q, m, skillNamesByAgentID, skillsLoaded)
|
|
if row != "" {
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
sb.WriteString("\nMembers: (none — you are the only member of this squad)\n")
|
|
return sb.String()
|
|
}
|
|
|
|
sb.WriteString("\nMembers:\n")
|
|
for _, r := range rows {
|
|
sb.WriteString(r)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func loadSquadMemberSkillNames(ctx context.Context, q *db.Queries, members []db.SquadMember, leaderID string) (map[string][]string, bool) {
|
|
agentIDs := make([]pgtype.UUID, 0)
|
|
seen := make(map[string]struct{}, len(members))
|
|
for _, m := range members {
|
|
if m.MemberType != "agent" {
|
|
continue
|
|
}
|
|
id := util.UUIDToString(m.MemberID)
|
|
if id == leaderID {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
agentIDs = append(agentIDs, m.MemberID)
|
|
}
|
|
if len(agentIDs) == 0 {
|
|
return map[string][]string{}, true
|
|
}
|
|
rows, err := q.ListAgentSkillNamesByAgentIDs(ctx, agentIDs)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
byAgentID := make(map[string][]string, len(agentIDs))
|
|
for _, row := range rows {
|
|
id := util.UUIDToString(row.AgentID)
|
|
byAgentID[id] = append(byAgentID[id], row.Name)
|
|
}
|
|
return byAgentID, true
|
|
}
|
|
|
|
// renderMemberRow renders a single roster row, returning "" if the member
|
|
// can't be resolved or should be skipped (e.g. archived agent).
|
|
func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember, skillNamesByAgentID map[string][]string, skillsLoaded bool) string {
|
|
id := util.UUIDToString(m.MemberID)
|
|
role := strings.TrimSpace(m.Role)
|
|
switch m.MemberType {
|
|
case "agent":
|
|
ag, err := q.GetAgent(ctx, m.MemberID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if ag.ArchivedAt.Valid {
|
|
return ""
|
|
}
|
|
// Agents carry skills; surfacing them lets the leader delegate by
|
|
// capability instead of guessing from the free-text role label.
|
|
return formatRosterRow(ag.Name, "agent", role, agentSkillsRosterSegment(skillNamesByAgentID, skillsLoaded, id), formatMention(ag.Name, "agent", id))
|
|
case "member":
|
|
user, err := q.GetUser(ctx, m.MemberID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
// Mention syntax for humans uses the user_id (matches the rest of
|
|
// the product — see util.MentionRe and frontend mention payloads).
|
|
// Humans have no Multica skills, so no skills segment is rendered.
|
|
userID := util.UUIDToString(m.MemberID)
|
|
return formatRosterRow(user.Name, "member (human)", role, "", formatMention(user.Name, "member", userID))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// agentSkillsRosterSegment returns the roster segment describing an agent
|
|
// member's assigned skills. "skills: a, b" when the agent has skills (the
|
|
// names are pre-sorted by ListAgentSkillNamesByAgentIDs), "no skills assigned"
|
|
// when it has none so the leader knows the capability is genuinely absent, and
|
|
// "" only when the lookup fails — a transient DB error degrades to the prior
|
|
// name+role row rather than asserting a misleading "no skills".
|
|
func agentSkillsRosterSegment(skillNamesByAgentID map[string][]string, skillsLoaded bool, agentID string) string {
|
|
if !skillsLoaded {
|
|
return ""
|
|
}
|
|
names := skillNamesByAgentID[agentID]
|
|
if len(names) == 0 {
|
|
return "no skills assigned"
|
|
}
|
|
return "skills: " + strings.Join(names, ", ")
|
|
}
|
|
|
|
func formatRosterRow(name, kind, role, skills, mention string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("- ")
|
|
sb.WriteString(name)
|
|
sb.WriteString(" — ")
|
|
sb.WriteString(kind)
|
|
if role != "" {
|
|
sb.WriteString(`, role: "`)
|
|
sb.WriteString(role)
|
|
sb.WriteString(`"`)
|
|
}
|
|
if skills != "" {
|
|
sb.WriteString(" — ")
|
|
sb.WriteString(skills)
|
|
}
|
|
sb.WriteString(" — `")
|
|
sb.WriteString(mention)
|
|
sb.WriteString("`\n")
|
|
return sb.String()
|
|
}
|
|
|
|
// formatMention emits a mention markdown string that round-trips through
|
|
// util.ParseMentions. The label is the human display name; the link target
|
|
// uses the mention:// scheme with the entity type and UUID.
|
|
func formatMention(name, mentionType, id string) string {
|
|
return "[@" + name + "](mention://" + mentionType + "/" + id + ")"
|
|
}
|