diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 356aeda49..cac8c1587 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -1097,7 +1097,11 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { // Build response with fresh agent data (name + skills + custom_env + custom_args). resp := taskToResponse(*task) if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil { + // Workspace-bound skills first, then platform built-in skills. Built-in + // names carry a "multica-" prefix so their on-disk slugs never collide + // with a user-authored workspace skill (see writeSkillFiles). skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID) + skills = append(skills, h.TaskService.BuiltinSkills()...) var customEnv map[string]string if agent.CustomEnv != nil { if err := json.Unmarshal(agent.CustomEnv, &customEnv); err != nil { diff --git a/server/internal/service/builtin_skills.go b/server/internal/service/builtin_skills.go new file mode 100644 index 000000000..f7d9425c2 --- /dev/null +++ b/server/internal/service/builtin_skills.go @@ -0,0 +1,72 @@ +package service + +import ( + "embed" + "io/fs" + "path" + "strings" +) + +//go:embed builtin_skills +var builtinSkillsFS embed.FS + +const builtinSkillsRoot = "builtin_skills" + +// BuiltinSkills returns the platform's built-in skills, embedded at compile +// time. Every agent receives these on top of its workspace-bound skills, so +// they teach platform-wide "how to" workflows (e.g. mentioning) that the +// runtime brief intentionally leaves to skills. +// +// Layout: builtin_skills//SKILL.md plus optional supporting files. The +// directory carries a "multica-" prefix so its on-disk slug can never +// collide with a workspace skill a user authored (see writeSkillFiles, which +// derives the skill directory from AgentSkillData.Name). +func (s *TaskService) BuiltinSkills() []AgentSkillData { + return loadBuiltinSkills() +} + +func loadBuiltinSkills() []AgentSkillData { + entries, err := fs.ReadDir(builtinSkillsFS, builtinSkillsRoot) + if err != nil { + return nil + } + var skills []AgentSkillData + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if skill, ok := loadBuiltinSkill(entry.Name()); ok { + skills = append(skills, skill) + } + } + return skills +} + +func loadBuiltinSkill(name string) (AgentSkillData, bool) { + dir := path.Join(builtinSkillsRoot, name) + content, err := fs.ReadFile(builtinSkillsFS, path.Join(dir, "SKILL.md")) + if err != nil { + // A skill directory without a SKILL.md is malformed — skip it rather + // than ship an empty skill. + return AgentSkillData{}, false + } + skill := AgentSkillData{Name: name, Content: string(content)} + // Any other file in the directory becomes a supporting file, preserving + // its relative path so subdirectories (e.g. rules/styling.md) survive. + _ = fs.WalkDir(builtinSkillsFS, dir, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil || d.IsDir() { + return walkErr + } + rel := strings.TrimPrefix(p, dir+"/") + if rel == "SKILL.md" { + return nil + } + data, readErr := fs.ReadFile(builtinSkillsFS, p) + if readErr != nil { + return nil + } + skill.Files = append(skill.Files, AgentSkillFileData{Path: rel, Content: string(data)}) + return nil + }) + return skill, true +} diff --git a/server/internal/service/builtin_skills/multica-mentioning/SKILL.md b/server/internal/service/builtin_skills/multica-mentioning/SKILL.md new file mode 100644 index 000000000..84bac30c1 --- /dev/null +++ b/server/internal/service/builtin_skills/multica-mentioning/SKILL.md @@ -0,0 +1,77 @@ +--- +name: multica-mentioning +description: Use when writing an issue comment that needs to @mention someone — notify a person, trigger another agent, or hand work to a squad. Covers how to build a mention link that actually fires and what each mention type does. +--- + +# Mentioning & Delegating + +This skill covers HOW to build a mention that works. WHETHER to mention at all +— loop avoidance, staying silent on acknowledgements — is already in your +runtime brief's Mentions section; follow that and do not repeat it here. + +## The one rule that breaks mentions + +A mention link needs a REAL UUID. Writing `[@Alice](mention://member/Alice)` +does NOTHING: a name is not a UUID, so the link silently fails — no +notification, no trigger, no error. Always look up the UUID first. + +## Step 1 — look up the UUID with `--output json` + +- a person → `multica workspace member list --output json` → use `user_id` +- an agent → `multica agent list --output json` → use `id` +- a squad → `multica squad list --output json` → use `id` + +Match by display name. If the name is ambiguous or absent, do not guess — +say so in your comment instead of emitting a broken link. + +## Step 2 — build the link; type and id source MUST match + +Format: `[@Name](mention:///)` + +| To… | type | uuid from | What it triggers | +| -------------------- | -------- | -------------- | ----------------------------------------- | +| notify a person | `member` | member.user_id | sends them a notification (no run) | +| make an agent work | `agent` | agent.id | enqueues a run for that agent | +| hand work to a squad | `squad` | squad.id | enqueues the squad LEADER, who delegates | +| reference an issue | `issue` | issue.id | nothing — a plain link, always safe | + +Using the wrong `type` for an id points at the wrong entity or fails silently. + +**`@all` is the exception** — it uses the literal `all`, never a UUID: +`[@all](mention://all/all)`. It broadcasts to everyone on the issue; it does +NOT make any specific agent run, and it also suppresses the assignee's +automatic on-comment trigger. Use it to announce, not to request work. + +## What does NOT happen (so the result doesn't surprise you) + +- A wrong/missing UUID, or a bare `@name`, silently does nothing. +- `@member` never makes a person "run" — it only notifies them. +- Even a correct mention may not fire if the target agent already has a + pending task on this issue, is archived, or is private and you cannot + access it. That is expected — do not retry in a loop. + +## Incorrect → Correct + +Incorrect: `@alice please review` + → plain text, no link, nobody is notified. + +Incorrect: `[@Alice](mention://member/Alice) please review` + → "Alice" is not a UUID, the link is silently dead. + +Correct: + 1. `multica workspace member list --output json` → Alice's user_id = 7f3a… + 2. `[@Alice](mention://member/7f3a…) please review` + +## Source of truth + +These behaviors are hard-coded in the Multica backend. If a mention does not +behave as described, check the source rather than guessing: + +- `server/internal/util/mention.go:16` — the mention regex. The id must be a + hex UUID (or the literal `all`); a name silently fails to parse. +- `server/internal/handler/comment.go:884` — `enqueueMentionedAgentTasks`: + how `@agent` enqueues a run and `@squad` enqueues the leader, plus the + guards (already-pending dedup, archived, private) that make a valid mention + no-op. +- `server/internal/handler/comment.go:768` — `@all` is a broadcast that + suppresses the assignee's auto-trigger.