mirror of
https://github.com/coder/coder.git
synced 2026-06-06 22:48:19 +00:00
ee855f9618
Replace hardcoded paths for instruction files, skills, and MCP config
with
values read from `CODER_AGENT_EXP_*` environment variables. Template
authors
configure paths via the existing `coder_agent` `env` block. The agent
resolves `~`, relative, and absolute paths locally, then serves the
resolved config over `GET /api/v0/context-config`. `chatd` fetches this
once per workspace attach and falls back to today's defaults for older
agents.
All path env vars are comma-separated, allowing multiple directories:
| Env Var | Default | Controls |
|---|---|---|
| `CODER_AGENT_EXP_INSTRUCTIONS_DIRS` | `~/.coder` | Dirs containing the
instruction file |
| `CODER_AGENT_EXP_INSTRUCTIONS_FILE` | `AGENTS.md` | Instruction file
name |
| `CODER_AGENT_EXP_SKILLS_DIRS` | `.agents/skills` | Skills directories
|
| `CODER_AGENT_EXP_SKILL_META_FILE` | `SKILL.md` | Skill metadata file
name |
| `CODER_AGENT_EXP_MCP_CONFIG_FILES` | `.mcp.json` | MCP config files |
### Example
```hcl
resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
env = {
CODER_AGENT_EXP_INSTRUCTIONS_DIRS = "/opt/company/agent-config,~/.coder"
CODER_AGENT_EXP_INSTRUCTIONS_FILE = "CLAUDE.md"
CODER_AGENT_EXP_SKILLS_DIRS = "/opt/company/ai-skills,.agents/skills"
CODER_AGENT_EXP_MCP_CONFIG_FILES = "/opt/company/mcp.json,.mcp.json"
}
}
```
<details>
<summary>Implementation Details</summary>
### Architecture
Follows the same pattern as MCP tool discovery:
agent resolves locally → exposes via HTTP → chatd consumes.
**Agent-side** (`agent/agentcontextconfig/`):
- `ResolvePath` / `ResolvePaths` handle `~`, relative, and absolute path
forms; returns `""` for relative paths when baseDir is empty
- `Config` reads env vars, falls back to defaults, resolves all paths
- `GET /api/v0/context-config` serves the resolved config as JSON
**chatd-side** (`coderd/x/chatd/`):
- Calls `conn.ContextConfig()` once on first workspace attach
- Falls back to hardcoded defaults on 404 (older agents)
- Iterates instruction dirs, skills dirs using resolved absolute paths
- `LSRelativityRoot` everywhere — no more home/root juggling
### Key design decisions
- **`EXP_` prefix**: env vars use `CODER_AGENT_EXP_*` to indicate
experimental status
- **Plural names**: comma-separated vars use plural names (`DIRS`,
`FILES`); single-value vars use singular (`FILE`)
- **Defaults in `workspacesdk`**: default constants live in
`codersdk/workspacesdk/` so both agent and server reference them without
cross-layer imports
- **`skillMetaFile` persistence**: stored on context-file parts via
`ContextFileSkillMetaFile` and restored on subsequent chat turns so
custom values survive across turns
- **Working dir dedup**: `slices.Contains` guard prevents reading the
same instruction file from both `InstructionsDirs` and the working
directory
- **MCP server dedup**: first-occurrence-wins dedup prevents leaking
duplicate connections from overlapping config files
- **ResolvePath safety**: returns `""` for relative paths when `baseDir`
is empty, so `ResolvePaths` filters them out
### Files changed
| File | Change |
|---|---|
| `agent/agentcontextconfig/` | New package — path resolution + HTTP
endpoint |
| `codersdk/workspacesdk/agentconn.go` | `ContextConfigResponse` type,
default constants, client method |
| `agent/agent.go` + `agent/api.go` | Wire up endpoint, pass config to
MCP |
| `agent/x/agentmcp/manager.go` | Accept `[]string` MCP config paths,
dedup by name |
| `coderd/x/chatd/chatd.go` | Fetch config, thread through, named
returns |
| `coderd/x/chatd/instruction.go` | Accept configurable dir + file name,
`skillMetaFileFromParts` |
| `coderd/x/chatd/chattool/skill.go` | Accept configurable dirs + meta
file |
| `codersdk/chats.go` | `ContextFileSkillMetaFile` field for persistence
|
### Test coverage
- `TestConfig` (4 cases): defaults, custom env vars, whitespace
trimming, comma-separated dirs
- `TestResolvePath` / `TestResolvePaths`: including empty baseDir edge
case
- `TestPersistInstructionFilesFallbackOnOlderAgent`: backward-compat
path when `ContextConfig` returns 404
- `TestChatMessagePartVariantTags`: updated exclusion list for new
internal field
### Backward compatibility
Older agents return 404 for the new endpoint. `chatd` catches this and
falls back to today's defaults via `readHomeInstructionFile` (using
`LSRelativityHome`). Existing workspaces work with no changes.
</details>
531 lines
14 KiB
Go
531 lines
14 KiB
Go
package chattool
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"charm.land/fantasy"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
const (
|
|
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 meta file 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 the skill meta file).
|
|
Files []string
|
|
}
|
|
|
|
// DiscoverSkills walks the given skills directories 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.
|
|
// Skill names must be unique across directories; first
|
|
// occurrence wins.
|
|
func DiscoverSkills(
|
|
ctx context.Context,
|
|
logger slog.Logger,
|
|
conn workspacesdk.AgentConn,
|
|
skillsDirs []string,
|
|
metaFile string,
|
|
) ([]SkillMeta, error) {
|
|
seen := make(map[string]struct{})
|
|
var skills []SkillMeta
|
|
|
|
for _, skillsDirPath := range skillsDirs {
|
|
lsResp, err := conn.LS(ctx, "", workspacesdk.LSRequest{
|
|
Path: []string{skillsDirPath},
|
|
Relativity: workspacesdk.LSRelativityRoot,
|
|
})
|
|
if err != nil {
|
|
// The skills directory is entirely optional.
|
|
// Skip on any error.
|
|
continue
|
|
}
|
|
|
|
for _, entry := range lsResp.Contents {
|
|
if !entry.IsDir {
|
|
continue
|
|
}
|
|
|
|
metaPath := path.Join(
|
|
entry.AbsolutePathString, metaFile,
|
|
)
|
|
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 the
|
|
// meta file. Any error is non-fatal.
|
|
continue
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(reader, maxSkillMetaBytes+1))
|
|
reader.Close()
|
|
if err != nil {
|
|
logger.Debug(ctx, "failed to read skill meta file",
|
|
slog.F("path", metaPath), slog.Error(err))
|
|
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 {
|
|
logger.Debug(ctx, "failed to parse skill frontmatter",
|
|
slog.F("path", metaPath), slog.Error(err))
|
|
continue
|
|
}
|
|
|
|
// The directory name must match the declared name
|
|
// so skill references are unambiguous.
|
|
if name != entry.Name {
|
|
logger.Debug(ctx, "skill name does not match directory",
|
|
slog.F("dir", entry.Name), slog.F("declared_name", name))
|
|
continue
|
|
}
|
|
if !skillNamePattern.MatchString(name) {
|
|
logger.Debug(ctx, "skill name does not match pattern",
|
|
slog.F("name", name))
|
|
continue
|
|
}
|
|
|
|
// First occurrence wins across directories.
|
|
if _, ok := seen[name]; ok {
|
|
continue
|
|
}
|
|
seen[name] = struct{}{}
|
|
|
|
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 meta 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 meta file 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,
|
|
metaFile string,
|
|
) (SkillContent, error) {
|
|
metaPath := path.Join(skill.Dir, metaFile)
|
|
|
|
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 == metaFile {
|
|
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
|
|
SkillMetaFile string
|
|
}
|
|
|
|
// 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 meta file 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, options.SkillMetaFile)
|
|
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
|
|
}
|