feat(skills): introduce built-in agent skills (WIP)

Inject platform-authored, version-bundled skills into every agent on top of
its workspace-bound skills, so agents learn how to operate Multica correctly
without users needing to know the internals or agents needing to read source.

Mechanism: skills are embedded into the server binary and appended to the
agent payload at task-claim time (handler/daemon.go), reusing the existing
SkillData wire + daemon-side writeSkillFiles. The daemon needs no changes,
and because it travels over an existing wire field, older daemons pick the
skills up the moment the server ships.

First skill: multica-mentioning — how to build a working @mention (look up
the UUID, match type to id source, know what each mention type triggers).

WIP: injection mechanism + first skill only; more skills to follow in
dependency order (skill -> agent -> squad).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-28 18:07:57 +08:00
parent bd1fb10afa
commit fa9919a969
3 changed files with 153 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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/<name>/SKILL.md plus optional supporting files. The
// <name> 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
}

View File

@@ -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://<type>/<uuid>)`
| 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.