feat: agent reads context files and discovers skills locally (#23935)

Piggybacks on #23878. Moves instruction file reading and skill discovery
from `chatd` (server-side, via multiple `LS`/`ReadFile` round-trips
through the agent connection) to the agent itself (local filesystem
access).

This intentionally drops backward compatibility with older agents that
don't support the context-config endpoint. Agents and server are
deployed together; there is no rolling-update contract to maintain here.

## What changed

The agent's `GET /api/v0/context-config` response now returns
`[]ChatMessagePart` directly — the same types chatd persists. This
eliminates intermediate type conversions and makes the protocol
extensible.

| Field | Type | Description |
|---|---|---|
| `parts` | `[]ChatMessagePart` | Context-file and skill parts, ready to
persist |
| `working_dir` | `string` | Agent's resolved working directory |

Removed from the response: `instructions_dirs`, `instructions_file`,
`skills_dirs`, `skill_meta_file`, `mcp_config_files` — the agent reads
files locally and returns their content as parts.

Removed from chatd: all legacy `LS`/`ReadFile` fallback code
(`readHomeInstructionFile`, `readInstructionDirFile`, `DiscoverSkills`
via LS, etc).

## Why

The previous architecture had the agent resolve paths, serve them over
HTTP, then `chatd` make N+1 round-trips back through the agent
connection to read files. The agent has direct filesystem access and
should just read the files.

## Key design decisions

- **Agent returns `ChatMessagePart` directly** — same types chatd
persists. No intermediate `InstructionFileEntry`/`SkillEntry` types
needed.
- **`SkillMeta.MetaFile`** — persisted via `ContextFileSkillMetaFile` on
the skill part, so custom meta file names
(`CODER_AGENT_EXP_SKILL_META_FILE`) survive across chat turns.
- **No pre-read body** — `read_skill` always dials the workspace to
fetch the skill body on demand. Simpler than caching the body in the
response.
- **MCP config paths kept agent-internal** — `MCPConfigFiles()` getter,
not sent over the wire.
- **No backward compat fallback** — old agents that don't support
context-config get no instruction files. This is acceptable since agent
and server deploy together.
This commit is contained in:
Kyle Carberry
2026-04-04 12:45:46 -04:00
committed by GitHub
parent 7e63fe68f7
commit 919dc299fc
16 changed files with 1151 additions and 1453 deletions
+4 -21
View File
@@ -1003,28 +1003,11 @@ type MCPToolInfo struct {
Required []string `json:"required"`
}
// Default values for context configuration. These are used
// by the agent when env vars are unset and by the server as
// fallbacks for older agents that don't support the
// context-config endpoint.
const (
DefaultInstructionsDir = "~/.coder"
DefaultInstructionsFile = "AGENTS.md"
DefaultSkillsDir = ".agents/skills"
DefaultSkillMetaFile = "SKILL.md"
DefaultMCPConfigFile = ".mcp.json"
)
// ContextConfigResponse is the response from the agent's
// context configuration endpoint. Directory fields contain
// fully resolved absolute paths. File name fields contain
// basenames.
// ContextConfigResponse is the response from the agent's context
// configuration endpoint. Contains pre-read instruction file
// contents and discovered skill metadata as chat message parts.
type ContextConfigResponse struct {
InstructionsDirs []string `json:"instructions_dirs"`
InstructionsFile string `json:"instructions_file"`
SkillsDirs []string `json:"skills_dirs"`
SkillMetaFile string `json:"skill_meta_file"`
MCPConfigFiles []string `json:"mcp_config_files"`
Parts []codersdk.ChatMessagePart `json:"parts"`
}
// CallMCPToolRequest is the request body for proxying an MCP
+79
View File
@@ -0,0 +1,79 @@
package workspacesdk
import (
"regexp"
"strings"
"golang.org/x/xerrors"
)
// markdownCommentRe strips HTML comments from skill file bodies so
// they don't leak into the LLM prompt.
var markdownCommentRe = regexp.MustCompile(`<!--[\s\S]*?-->`)
// ParseSkillFrontmatter extracts name, description, and the
// remaining body from a skill meta file. The expected format is
// YAML-ish frontmatter delimited by "---" lines:
//
// ---
// name: my-skill
// description: Does a thing
// ---
// Body text here...
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
}
+131
View File
@@ -0,0 +1,131 @@
package workspacesdk_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func TestParseSkillFrontmatter(t *testing.T) {
t.Parallel()
t.Run("Basic", func(t *testing.T) {
t.Parallel()
name, desc, body, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: my-skill\ndescription: Does a thing\n---\nBody text here.\n",
)
require.NoError(t, err)
require.Equal(t, "my-skill", name)
require.Equal(t, "Does a thing", desc)
require.Equal(t, "Body text here.", body)
})
t.Run("QuotedValues", func(t *testing.T) {
t.Parallel()
name, desc, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: \"quoted-name\"\ndescription: 'single-quoted'\n---\n",
)
require.NoError(t, err)
require.Equal(t, "quoted-name", name)
require.Equal(t, "single-quoted", desc)
})
t.Run("NoDescription", func(t *testing.T) {
t.Parallel()
name, desc, body, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: minimal\n---\nSome body.\n",
)
require.NoError(t, err)
require.Equal(t, "minimal", name)
require.Empty(t, desc)
require.Equal(t, "Some body.", body)
})
t.Run("HTMLCommentsStripped", func(t *testing.T) {
t.Parallel()
_, _, body, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: strip-test\n---\nBefore <!-- hidden --> after.\n",
)
require.NoError(t, err)
require.Equal(t, "Before after.", body)
})
t.Run("MultilineHTMLComment", func(t *testing.T) {
t.Parallel()
_, _, body, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: multi\n---\nKeep this.\n<!--\nRemove\nall of this.\n-->\nAnd this.\n",
)
require.NoError(t, err)
require.Contains(t, body, "Keep this.")
require.Contains(t, body, "And this.")
require.NotContains(t, body, "Remove")
})
t.Run("BOMPrefix", func(t *testing.T) {
t.Parallel()
name, _, _, err := workspacesdk.ParseSkillFrontmatter(
"\xef\xbb\xbf---\nname: bom-skill\n---\n",
)
require.NoError(t, err)
require.Equal(t, "bom-skill", name)
})
t.Run("EmptyBody", func(t *testing.T) {
t.Parallel()
_, _, body, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: nobody\ndescription: has no body\n---\n",
)
require.NoError(t, err)
require.Empty(t, body)
})
t.Run("CaseInsensitiveKeys", func(t *testing.T) {
t.Parallel()
name, desc, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nName: upper\nDescription: Also upper\n---\n",
)
require.NoError(t, err)
require.Equal(t, "upper", name)
require.Equal(t, "Also upper", desc)
})
t.Run("UnknownKeysIgnored", func(t *testing.T) {
t.Parallel()
name, _, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: test\nauthor: someone\nversion: 1.0\n---\n",
)
require.NoError(t, err)
require.Equal(t, "test", name)
})
t.Run("ErrorMissingOpenDelimiter", func(t *testing.T) {
t.Parallel()
_, _, _, err := workspacesdk.ParseSkillFrontmatter("no frontmatter here")
require.ErrorContains(t, err, "missing opening frontmatter delimiter")
})
t.Run("ErrorMissingCloseDelimiter", func(t *testing.T) {
t.Parallel()
_, _, _, err := workspacesdk.ParseSkillFrontmatter("---\nname: oops\n")
require.ErrorContains(t, err, "missing closing frontmatter delimiter")
})
t.Run("ErrorMissingName", func(t *testing.T) {
t.Parallel()
_, _, _, err := workspacesdk.ParseSkillFrontmatter(
"---\ndescription: no name\n---\n",
)
require.ErrorContains(t, err, "frontmatter missing required 'name' field")
})
t.Run("WhitespaceAroundDelimiters", func(t *testing.T) {
t.Parallel()
name, _, _, err := workspacesdk.ParseSkillFrontmatter(
" --- \nname: spaced\n --- \n",
)
require.NoError(t, err)
require.Equal(t, "spaced", name)
})
}