Files
coder/coderd/x/chatd/chattool/skill.go
T
Kyle Carberry 839165818b feat(coderd/x/chatd): add skills discovery and tools for chatd (#23715)
Adds skill discovery and tools to chatd so the agent can discover and
load `.agents/skills/` from workspaces, following the same pattern as
AGENTS.md instruction loading and MCP tool discovery.

## What changed

### `chattool/skill.go` — discovery, loading, and tools

- **DiscoverSkills** — walks `.agents/skills/` via `conn.LS()` +
`conn.ReadFile()`, parses SKILL.md frontmatter (name + description),
validates kebab-case names match directory names, silently skips
broken/missing entries.
- **FormatSkillIndex** — renders a compact `<available-skills>` XML
block for system prompt injection (~60 tokens for 3 skills). Progressive
disclosure: only names + descriptions in context, full body loaded on
demand.
- **LoadSkillBody** / **LoadSkillFile** — on-demand loading with path
traversal protection and size caps (64KB for SKILL.md, 512KB for
supporting files).
- **read_skill** / **read_skill_file** tools — `fantasy.AgentTool`
implementations following the same pattern as ReadFile and
WorkspaceMCPTool. Receive pre-discovered `[]SkillMeta` via closure to
avoid re-scanning on every call.

### `chatd.go` — integration into runChat

- Skills discovered in the `g2` errgroup parallel with instructions and
MCP tools.
- `skillsCache` (sync.Map) per chat+agent, same invalidation pattern as
MCP tools cache.
- Skill index injected via `InsertSystem` after workspace instructions.
- Re-injected in `ReloadMessages` callback so it survives compaction.
- `read_skill` + `read_skill_file` tools registered when skills are
present (for both root and subagent chats).
- Cache cleaned up in `cleanupStreamIfIdle` alongside MCP tools cache.

## Format compatibility

Uses the same `.agents/skills/<name>/SKILL.md` format as
[coder/mux](https://github.com/coder/mux) and
[openai/codex](https://github.com/openai/codex).
2026-03-27 15:22:13 -04:00

512 lines
13 KiB
Go

package chattool
import (
"context"
"fmt"
"io"
"path"
"regexp"
"strings"
"charm.land/fantasy"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
const (
agentsSkillsDir = ".agents/skills"
skillMetaFile = "SKILL.md"
maxSkillMetaBytes = 64 * 1024
maxSkillFileBytes = 512 * 1024
)
// skillNamePattern validates kebab-case skill names. Each segment
// must start with a lowercase letter or digit, and segments are
// separated by single hyphens.
var skillNamePattern = regexp.MustCompile(
`^[a-z0-9]+(-[a-z0-9]+)*$`,
)
// markdownCommentRe strips HTML comments from skill bodies so
// they don't leak into the prompt. Matches the same pattern
// used by instruction.go in the parent package.
var markdownCommentRe = regexp.MustCompile(`<!--[\s\S]*?-->`)
// SkillMeta is the frontmatter from a SKILL.md discovered in a
// workspace. It carries just enough information to list the skill
// in the prompt index without reading the full body.
type SkillMeta struct {
Name string
Description string
// Dir is the absolute path to the skill directory inside
// the workspace filesystem.
Dir string
}
// SkillContent is the full body of a skill, loaded on demand
// when the model calls read_skill.
type SkillContent struct {
SkillMeta
// Body is the markdown content after the frontmatter
// delimiters have been stripped.
Body string
// Files lists relative paths of supporting files in the
// skill directory (everything except SKILL.md itself).
Files []string
}
// DiscoverSkills walks the .agents/skills directory inside the
// workspace and returns metadata for every valid skill it finds.
// Missing directories or individual read errors are silently
// skipped so that a partially broken skills tree never blocks the
// conversation.
func DiscoverSkills(
ctx context.Context,
conn workspacesdk.AgentConn,
workingDir string,
) ([]SkillMeta, error) {
skillsDirPath := path.Join(workingDir, agentsSkillsDir)
lsResp, err := conn.LS(ctx, "", workspacesdk.LSRequest{
Path: []string{skillsDirPath},
Relativity: workspacesdk.LSRelativityRoot,
})
if err != nil {
// The skills directory is entirely optional. Return
// nil for any error so skill discovery never blocks
// the conversation.
return nil, nil
}
var skills []SkillMeta
for _, entry := range lsResp.Contents {
if !entry.IsDir {
continue
}
metaPath := path.Join(
entry.AbsolutePathString, skillMetaFile,
)
reader, _, err := conn.ReadFile(
ctx, metaPath, 0, maxSkillMetaBytes+1,
)
if err != nil {
// The directory may have been removed between the
// LS and this read, or it simply lacks a SKILL.md.
// Any error is non-fatal.
continue
}
raw, err := io.ReadAll(io.LimitReader(reader, maxSkillMetaBytes+1))
reader.Close()
if err != nil {
continue
}
// Silently truncate oversized metadata files so a
// single large file cannot exhaust memory.
if int64(len(raw)) > maxSkillMetaBytes {
raw = raw[:maxSkillMetaBytes]
}
name, description, _, err := parseSkillFrontmatter(
string(raw),
)
if err != nil {
continue
}
// The directory name must match the declared name so
// skill references are unambiguous.
if name != entry.Name {
continue
}
if !skillNamePattern.MatchString(name) {
continue
}
skills = append(skills, SkillMeta{
Name: name,
Description: description,
Dir: entry.AbsolutePathString,
})
}
return skills, nil
}
// parseSkillFrontmatter extracts name, description, and the
// markdown body from a SKILL.md file. The frontmatter uses a
// simple `key: value` format between `---` delimiters, and no
// full YAML parser is needed.
func parseSkillFrontmatter(
content string,
) (name, description, body string, err error) {
content = strings.TrimPrefix(content, "\xef\xbb\xbf")
lines := strings.Split(content, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return "", "", "", xerrors.New(
"missing opening frontmatter delimiter",
)
}
closingIdx := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
closingIdx = i
break
}
}
if closingIdx < 0 {
return "", "", "", xerrors.New(
"missing closing frontmatter delimiter",
)
}
for _, line := range lines[1:closingIdx] {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
// Strip surrounding quotes from YAML string values.
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
switch strings.ToLower(key) {
case "name":
name = value
case "description":
description = value
}
}
if name == "" {
return "", "", "", xerrors.New(
"frontmatter missing required 'name' field",
)
}
// Everything after the closing delimiter is the body.
body = strings.Join(lines[closingIdx+1:], "\n")
body = markdownCommentRe.ReplaceAllString(body, "")
body = strings.TrimSpace(body)
return name, description, body, nil
}
// FormatSkillIndex renders an XML block listing all discovered
// skills. This block is injected into the system prompt so the
// model knows which skills are available and how to load them.
func FormatSkillIndex(skills []SkillMeta) string {
if len(skills) == 0 {
return ""
}
var b strings.Builder
_, _ = b.WriteString("<available-skills>\n")
_, _ = b.WriteString(
"Use read_skill to load a skill's full instructions " +
"before following them.\n" +
"Use read_skill_file to read supporting files " +
"referenced by a skill.\n\n",
)
for _, s := range skills {
_, _ = b.WriteString("- ")
_, _ = b.WriteString(s.Name)
if s.Description != "" {
_, _ = b.WriteString(": ")
_, _ = b.WriteString(s.Description)
}
_, _ = b.WriteString("\n")
}
_, _ = b.WriteString("</available-skills>")
return b.String()
}
// LoadSkillBody reads the full SKILL.md for a discovered skill
// and lists the supporting files in its directory. The caller
// should have already obtained the SkillMeta from DiscoverSkills.
func LoadSkillBody(
ctx context.Context,
conn workspacesdk.AgentConn,
skill SkillMeta,
) (SkillContent, error) {
metaPath := path.Join(skill.Dir, skillMetaFile)
reader, _, err := conn.ReadFile(
ctx, metaPath, 0, maxSkillMetaBytes+1,
)
if err != nil {
return SkillContent{}, xerrors.Errorf(
"read skill body: %w", err,
)
}
raw, err := io.ReadAll(io.LimitReader(reader, maxSkillMetaBytes+1))
reader.Close()
if err != nil {
return SkillContent{}, xerrors.Errorf(
"read skill body bytes: %w", err,
)
}
if int64(len(raw)) > maxSkillMetaBytes {
raw = raw[:maxSkillMetaBytes]
}
_, _, body, err := parseSkillFrontmatter(string(raw))
if err != nil {
return SkillContent{}, xerrors.Errorf(
"parse skill frontmatter: %w", err,
)
}
// List supporting files so the model knows what it can
// request via read_skill_file.
lsResp, err := conn.LS(ctx, "", workspacesdk.LSRequest{
Path: []string{skill.Dir},
Relativity: workspacesdk.LSRelativityRoot,
})
if err != nil {
return SkillContent{}, xerrors.Errorf(
"list skill directory: %w", err,
)
}
var files []string
for _, entry := range lsResp.Contents {
if entry.Name == skillMetaFile {
continue
}
name := entry.Name
if entry.IsDir {
name += "/"
}
files = append(files, name)
}
return SkillContent{
SkillMeta: skill,
Body: body,
Files: files,
}, nil
}
// LoadSkillFile reads a supporting file from a skill's directory.
// The relativePath is validated to prevent directory traversal and
// access to hidden files.
func LoadSkillFile(
ctx context.Context,
conn workspacesdk.AgentConn,
skill SkillMeta,
relativePath string,
) (string, error) {
if err := validateSkillFilePath(relativePath); err != nil {
return "", err
}
fullPath := path.Join(skill.Dir, relativePath)
reader, _, err := conn.ReadFile(
ctx, fullPath, 0, maxSkillFileBytes+1,
)
if err != nil {
return "", xerrors.Errorf(
"read skill file: %w", err,
)
}
raw, err := io.ReadAll(io.LimitReader(reader, maxSkillFileBytes+1))
reader.Close()
if err != nil {
return "", xerrors.Errorf(
"read skill file bytes: %w", err,
)
}
if int64(len(raw)) > maxSkillFileBytes {
raw = raw[:maxSkillFileBytes]
}
return string(raw), nil
}
// validateSkillFilePath rejects paths that could escape the skill
// directory or access hidden files. Only forward-relative,
// non-hidden paths are allowed.
func validateSkillFilePath(p string) error {
if p == "" {
return xerrors.New("path is required")
}
if strings.HasPrefix(p, "/") {
return xerrors.New(
"absolute paths are not allowed",
)
}
for _, component := range strings.Split(p, "/") {
if component == ".." {
return xerrors.New(
"path traversal is not allowed",
)
}
if strings.HasPrefix(component, ".") {
return xerrors.New(
"hidden file components are not allowed",
)
}
}
return nil
}
// ReadSkillOptions configures the read_skill and read_skill_file
// tools.
type ReadSkillOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
GetSkills func() []SkillMeta
}
// ReadSkillArgs are the parameters accepted by read_skill.
type ReadSkillArgs struct {
Name string `json:"name" description:"The kebab-case name of the skill to read."`
}
// ReadSkill returns an AgentTool that reads the full instructions
// for a skill by name. The model should call this before
// following any skill's instructions.
func ReadSkill(options ReadSkillOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
"read_skill",
"Read the full instructions for a skill by name. "+
"Returns the SKILL.md body and a list of "+
"supporting files. Use read_skill before "+
"following a skill's instructions.",
func(ctx context.Context, args ReadSkillArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if options.GetWorkspaceConn == nil {
return fantasy.NewTextErrorResponse(
"workspace connection resolver is not configured",
), nil
}
if args.Name == "" {
return fantasy.NewTextErrorResponse(
"name is required",
), nil
}
skill, ok := findSkill(options.GetSkills, args.Name)
if !ok {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("skill %q not found", args.Name),
), nil
}
conn, err := options.GetWorkspaceConn(ctx)
if err != nil {
return fantasy.NewTextErrorResponse(
err.Error(),
), nil
}
content, err := LoadSkillBody(ctx, conn, skill)
if err != nil {
return fantasy.NewTextErrorResponse(
err.Error(),
), nil
}
return toolResponse(map[string]any{
"name": content.Name,
"body": content.Body,
"files": content.Files,
}), nil
},
)
}
// ReadSkillFileArgs are the parameters accepted by
// read_skill_file.
type ReadSkillFileArgs struct {
Name string `json:"name" description:"The kebab-case name of the skill."`
Path string `json:"path" description:"Relative path to a file in the skill directory (e.g. roles/security-reviewer.md)."`
}
// ReadSkillFile returns an AgentTool that reads a supporting file
// from a skill's directory.
func ReadSkillFile(options ReadSkillOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
"read_skill_file",
"Read a supporting file from a skill's directory "+
"(e.g. roles/security-reviewer.md).",
func(ctx context.Context, args ReadSkillFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if options.GetWorkspaceConn == nil {
return fantasy.NewTextErrorResponse(
"workspace connection resolver is not configured",
), nil
}
if args.Name == "" {
return fantasy.NewTextErrorResponse(
"name is required",
), nil
}
if args.Path == "" {
return fantasy.NewTextErrorResponse(
"path is required",
), nil
}
skill, ok := findSkill(options.GetSkills, args.Name)
if !ok {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("skill %q not found", args.Name),
), nil
}
// Validate the path early so we reject bad
// inputs before dialing the workspace agent.
if err := validateSkillFilePath(args.Path); err != nil {
return fantasy.NewTextErrorResponse(
err.Error(),
), nil
}
conn, err := options.GetWorkspaceConn(ctx)
if err != nil {
return fantasy.NewTextErrorResponse(
err.Error(),
), nil
}
content, err := LoadSkillFile(
ctx, conn, skill, args.Path,
)
if err != nil {
return fantasy.NewTextErrorResponse(
err.Error(),
), nil
}
return toolResponse(map[string]any{
"content": content,
}), nil
},
)
}
// findSkill looks up a skill by name in the current skill list.
func findSkill(
getSkills func() []SkillMeta,
name string,
) (SkillMeta, bool) {
if getSkills == nil {
return SkillMeta{}, false
}
for _, s := range getSkills() {
if s.Name == name {
return s, true
}
}
return SkillMeta{}, false
}