mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
5b87d7b74f
The default skills lookup only scanned the project-relative .agents/skills directory, so personal skills had to be repeated per project or wired in via CODER_AGENT_EXP_SKILLS_DIRS. Now the default is the comma-separated list ~/.coder/skills,.agents/skills, which lets discoverSkills's existing first-occurrence-wins policy prefer home-scoped skills over project ones with the same name. The change is additive when ~/.coder/skills is absent (missing directories are silently skipped in discoverSkills) and unaffects users who set the env var explicitly. Closes CODAGT-403
383 lines
11 KiB
Go
383 lines
11 KiB
Go
package agentcontextconfig
|
|
|
|
import (
|
|
"cmp"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
// Env var names for context configuration. Prefixed with EXP_
|
|
// to indicate these are experimental and may change.
|
|
const (
|
|
EnvInstructionsDirs = "CODER_AGENT_EXP_INSTRUCTIONS_DIRS"
|
|
EnvInstructionsFile = "CODER_AGENT_EXP_INSTRUCTIONS_FILE"
|
|
EnvSkillsDirs = "CODER_AGENT_EXP_SKILLS_DIRS"
|
|
EnvSkillMetaFile = "CODER_AGENT_EXP_SKILL_META_FILE"
|
|
EnvMCPConfigFiles = "CODER_AGENT_EXP_MCP_CONFIG_FILES"
|
|
)
|
|
|
|
const (
|
|
maxInstructionFileBytes = 64 * 1024
|
|
maxSkillMetaBytes = 64 * 1024
|
|
)
|
|
|
|
// markdownCommentPattern strips HTML comments from instruction
|
|
// file content for security (prevents hidden prompt injection).
|
|
var markdownCommentPattern = regexp.MustCompile(`<!--[\s\S]*?-->`)
|
|
|
|
// invisibleRunePattern strips invisible Unicode characters that
|
|
// could be used for prompt injection.
|
|
//
|
|
//nolint:gocritic // Non-ASCII char ranges are intentional for invisible Unicode stripping.
|
|
var invisibleRunePattern = regexp.MustCompile(
|
|
"[\u00ad\u034f\u061c\u070f" +
|
|
"\u115f\u1160\u17b4\u17b5" +
|
|
"\u180b-\u180f" +
|
|
"\u200b\u200d\u200e\u200f" +
|
|
"\u202a-\u202e" +
|
|
"\u2060-\u206f" +
|
|
"\u3164" +
|
|
"\ufe00-\ufe0f" +
|
|
"\ufeff" +
|
|
"\uffa0" +
|
|
"\ufff0-\ufff8]",
|
|
)
|
|
|
|
// skillNamePattern validates kebab-case skill names.
|
|
var skillNamePattern = regexp.MustCompile(
|
|
`^[a-z0-9]+(-[a-z0-9]+)*$`,
|
|
)
|
|
|
|
// Default values for agent-internal configuration. These are
|
|
// used when the corresponding env vars are unset.
|
|
//
|
|
// DefaultSkillsDir is a comma-separated list so home-scoped
|
|
// skills override project-scoped ones with the same name
|
|
// (discoverSkills picks the first occurrence per skill name).
|
|
const (
|
|
DefaultInstructionsDir = "~/.coder"
|
|
DefaultInstructionsFile = "AGENTS.md"
|
|
DefaultSkillsDir = "~/.coder/skills,.agents/skills"
|
|
DefaultSkillMetaFile = "SKILL.md"
|
|
DefaultMCPConfigFile = ".mcp.json"
|
|
)
|
|
|
|
// Config holds the agent's context configuration.
|
|
// Defaults are applied by NewAPI, not by the zero value.
|
|
type Config struct {
|
|
InstructionsDirs string
|
|
InstructionsFile string
|
|
SkillsDirs string
|
|
SkillMetaFile string
|
|
MCPConfigFiles string
|
|
}
|
|
|
|
// applyDefaults fills zero-valued fields with their defaults.
|
|
func (c Config) applyDefaults() Config {
|
|
c.InstructionsDirs = cmp.Or(c.InstructionsDirs, DefaultInstructionsDir)
|
|
c.InstructionsFile = cmp.Or(c.InstructionsFile, DefaultInstructionsFile)
|
|
c.SkillsDirs = cmp.Or(c.SkillsDirs, DefaultSkillsDir)
|
|
c.SkillMetaFile = cmp.Or(c.SkillMetaFile, DefaultSkillMetaFile)
|
|
c.MCPConfigFiles = cmp.Or(c.MCPConfigFiles, DefaultMCPConfigFile)
|
|
return c
|
|
}
|
|
|
|
// ReadEnvConfig reads the CODER_AGENT_EXP_* environment
|
|
// variables, falling back to defaults for unset values.
|
|
func ReadEnvConfig() Config {
|
|
return Config{
|
|
InstructionsDirs: strings.TrimSpace(os.Getenv(EnvInstructionsDirs)),
|
|
InstructionsFile: strings.TrimSpace(os.Getenv(EnvInstructionsFile)),
|
|
SkillsDirs: strings.TrimSpace(os.Getenv(EnvSkillsDirs)),
|
|
SkillMetaFile: strings.TrimSpace(os.Getenv(EnvSkillMetaFile)),
|
|
MCPConfigFiles: strings.TrimSpace(os.Getenv(EnvMCPConfigFiles)),
|
|
}.applyDefaults()
|
|
}
|
|
|
|
// envVarKeys returns every CODER_AGENT_EXP_* env var key
|
|
// used by the context configuration subsystem.
|
|
func envVarKeys() []string {
|
|
return []string{
|
|
EnvInstructionsDirs, EnvInstructionsFile,
|
|
EnvSkillsDirs, EnvSkillMetaFile, EnvMCPConfigFiles,
|
|
}
|
|
}
|
|
|
|
// ClearEnvVars removes the CODER_AGENT_EXP_* environment
|
|
// variables from the current process so they are not
|
|
// inherited by child processes.
|
|
func ClearEnvVars() {
|
|
for _, key := range envVarKeys() {
|
|
_ = os.Unsetenv(key)
|
|
}
|
|
}
|
|
|
|
// API exposes the resolved context configuration through the
|
|
// agent's HTTP API.
|
|
type API struct {
|
|
workingDir func() string
|
|
cfg Config
|
|
}
|
|
|
|
// NewAPI creates a context configuration API. The working
|
|
// directory closure is evaluated lazily per request.
|
|
func NewAPI(workingDir func() string, cfg Config) *API {
|
|
if workingDir == nil {
|
|
workingDir = func() string { return "" }
|
|
}
|
|
return &API{workingDir: workingDir, cfg: cfg.applyDefaults()}
|
|
}
|
|
|
|
// Resolve reads instruction files, discovers skills, and
|
|
// resolves MCP config file paths for the given config and
|
|
// working directory.
|
|
func Resolve(workingDir string, cfg Config) (workspacesdk.ContextConfigResponse, []string) {
|
|
resolvedInstructionsDirs := ResolvePaths(cfg.InstructionsDirs, workingDir)
|
|
resolvedSkillsDirs := ResolvePaths(cfg.SkillsDirs, workingDir)
|
|
|
|
// Read instruction files from each configured directory.
|
|
parts := readInstructionFiles(resolvedInstructionsDirs, cfg.InstructionsFile)
|
|
|
|
// Also check the working directory for the instruction file,
|
|
// unless it was already covered by InstructionsDirs.
|
|
if workingDir != "" {
|
|
seenDirs := make(map[string]struct{}, len(resolvedInstructionsDirs))
|
|
for _, d := range resolvedInstructionsDirs {
|
|
seenDirs[d] = struct{}{}
|
|
}
|
|
if _, ok := seenDirs[workingDir]; !ok {
|
|
if entry, found := readInstructionFileFromDir(workingDir, cfg.InstructionsFile); found {
|
|
parts = append(parts, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Discover skills from each configured skills directory.
|
|
skillParts := discoverSkills(resolvedSkillsDirs, cfg.SkillMetaFile)
|
|
parts = append(parts, skillParts...)
|
|
|
|
// Guarantee non-nil slice to signal agent support.
|
|
if parts == nil {
|
|
parts = []codersdk.ChatMessagePart{}
|
|
}
|
|
|
|
return workspacesdk.ContextConfigResponse{
|
|
Parts: parts,
|
|
}, ResolvePaths(cfg.MCPConfigFiles, workingDir)
|
|
}
|
|
|
|
// ContextPartsFromDir reads instruction files and discovers skills
|
|
// from a specific directory, using default file names. This is used
|
|
// by the CLI chat context commands to read context from an arbitrary
|
|
// directory without consulting agent env vars.
|
|
func ContextPartsFromDir(dir string) []codersdk.ChatMessagePart {
|
|
var parts []codersdk.ChatMessagePart
|
|
|
|
if entry, found := readInstructionFileFromDir(dir, DefaultInstructionsFile); found {
|
|
parts = append(parts, entry)
|
|
}
|
|
|
|
// Reuse ResolvePaths so CLI skill discovery follows the same
|
|
// project-relative path handling as agent config resolution.
|
|
skillParts := discoverSkills(
|
|
ResolvePaths(strings.Join([]string{DefaultSkillsDir, "skills"}, ","), dir),
|
|
DefaultSkillMetaFile,
|
|
)
|
|
parts = append(parts, skillParts...)
|
|
|
|
// Guarantee non-nil slice.
|
|
if parts == nil {
|
|
parts = []codersdk.ChatMessagePart{}
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
// MCPConfigFiles returns the resolved MCP configuration file
|
|
// paths for the agent's MCP manager.
|
|
func (api *API) MCPConfigFiles() []string {
|
|
_, mcpFiles := Resolve(api.workingDir(), api.cfg)
|
|
return mcpFiles
|
|
}
|
|
|
|
// Routes returns the HTTP handler for the context config
|
|
// endpoint.
|
|
func (api *API) Routes() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Get("/", api.handleGet)
|
|
return r
|
|
}
|
|
|
|
func (api *API) handleGet(rw http.ResponseWriter, r *http.Request) {
|
|
response, _ := Resolve(api.workingDir(), api.cfg)
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, response)
|
|
}
|
|
|
|
// readInstructionFiles reads instruction files from each given
|
|
// directory. Missing directories are silently skipped. Duplicate
|
|
// directories are deduplicated.
|
|
func readInstructionFiles(dirs []string, fileName string) []codersdk.ChatMessagePart {
|
|
var parts []codersdk.ChatMessagePart
|
|
seen := make(map[string]struct{}, len(dirs))
|
|
for _, dir := range dirs {
|
|
if _, ok := seen[dir]; ok {
|
|
continue
|
|
}
|
|
seen[dir] = struct{}{}
|
|
if part, found := readInstructionFileFromDir(dir, fileName); found {
|
|
parts = append(parts, part)
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
// readInstructionFileFromDir scans a directory for a file matching
|
|
// fileName (case-insensitive) and reads its contents.
|
|
func readInstructionFileFromDir(dir, fileName string) (codersdk.ChatMessagePart, bool) {
|
|
dirEntries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return codersdk.ChatMessagePart{}, false
|
|
}
|
|
|
|
for _, e := range dirEntries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(e.Name()), fileName) {
|
|
filePath := filepath.Join(dir, e.Name())
|
|
content, truncated, ok := readAndSanitizeFile(filePath, maxInstructionFileBytes)
|
|
if !ok {
|
|
return codersdk.ChatMessagePart{}, false
|
|
}
|
|
if content == "" {
|
|
return codersdk.ChatMessagePart{}, false
|
|
}
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeContextFile,
|
|
ContextFilePath: filePath,
|
|
ContextFileContent: content,
|
|
ContextFileTruncated: truncated,
|
|
}, true
|
|
}
|
|
}
|
|
return codersdk.ChatMessagePart{}, false
|
|
}
|
|
|
|
// readAndSanitizeFile reads the file at path, capping the read
|
|
// at maxBytes to avoid unbounded memory allocation. It sanitizes
|
|
// the content (strips HTML comments and invisible Unicode) and
|
|
// returns the result. Returns false if the file cannot be read.
|
|
func readAndSanitizeFile(path string, maxBytes int64) (content string, truncated bool, ok bool) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", false, false
|
|
}
|
|
defer f.Close()
|
|
|
|
// Read at most maxBytes+1 to detect truncation without
|
|
// allocating the entire file into memory.
|
|
raw, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
|
|
if err != nil {
|
|
return "", false, false
|
|
}
|
|
|
|
truncated = int64(len(raw)) > maxBytes
|
|
if truncated {
|
|
raw = raw[:maxBytes]
|
|
}
|
|
|
|
s := sanitizeInstructionMarkdown(string(raw))
|
|
if s == "" {
|
|
return "", truncated, true
|
|
}
|
|
return s, truncated, true
|
|
}
|
|
|
|
// sanitizeInstructionMarkdown strips HTML comments, invisible
|
|
// Unicode characters, and CRLF line endings from instruction
|
|
// file content.
|
|
func sanitizeInstructionMarkdown(content string) string {
|
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
|
content = strings.ReplaceAll(content, "\r", "\n")
|
|
content = markdownCommentPattern.ReplaceAllString(content, "")
|
|
content = invisibleRunePattern.ReplaceAllString(content, "")
|
|
return strings.TrimSpace(content)
|
|
}
|
|
|
|
// discoverSkills walks the given skills directories and returns
|
|
// metadata for every valid skill it finds. Body and supporting
|
|
// file lists are NOT included; chatd fetches those on demand
|
|
// via read_skill. Missing directories or individual errors are
|
|
// silently skipped.
|
|
func discoverSkills(skillsDirs []string, metaFile string) []codersdk.ChatMessagePart {
|
|
seen := make(map[string]struct{})
|
|
var parts []codersdk.ChatMessagePart
|
|
|
|
for _, skillsDir := range skillsDirs {
|
|
entries, err := os.ReadDir(skillsDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
metaPath := filepath.Join(skillsDir, entry.Name(), metaFile)
|
|
f, err := os.Open(metaPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(f, maxSkillMetaBytes+1))
|
|
_ = f.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if int64(len(raw)) > maxSkillMetaBytes {
|
|
raw = raw[:maxSkillMetaBytes]
|
|
}
|
|
|
|
name, description, _, err := workspacesdk.ParseSkillFrontmatter(string(raw))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// The directory name must match the declared name.
|
|
if name != entry.Name() {
|
|
continue
|
|
}
|
|
if !skillNamePattern.MatchString(name) {
|
|
continue
|
|
}
|
|
|
|
// First occurrence wins across directories.
|
|
if _, ok := seen[name]; ok {
|
|
continue
|
|
}
|
|
seen[name] = struct{}{}
|
|
|
|
skillDir := filepath.Join(skillsDir, entry.Name())
|
|
parts = append(parts, codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeSkill,
|
|
SkillName: name,
|
|
SkillDescription: description,
|
|
SkillDir: skillDir,
|
|
ContextFileSkillMetaFile: metaFile,
|
|
})
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|