From 48fd0ef4bc0165df5a62cabbdaeceee75573cc4a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 25 Jun 2026 12:05:54 -0600 Subject: [PATCH] feat: return workspace skill directory from read_skill (#26713) Workspace skills live on the workspace filesystem, and the agent's read_file and execute tools already operate there. read_skill now returns "dir", the absolute skill directory, for workspace skills, so the agent can read or run bundled supporting files (for example a scripts/ helper) with the workspace tools. The field is omitted for personal skills, which are database-backed and have no files. read_skill_file is unchanged. Generated with Coder Agents on behalf of @kylecarbs. --- coderd/x/chatd/chattool/skill.go | 3 +++ coderd/x/chatd/chattool/skill_test.go | 24 ++++++++++++++++++++++++ docs/ai-coder/agents/extending-agents.md | 15 +++++++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/coderd/x/chatd/chattool/skill.go b/coderd/x/chatd/chattool/skill.go index 4267dff5ad..f93786af46 100644 --- a/coderd/x/chatd/chattool/skill.go +++ b/coderd/x/chatd/chattool/skill.go @@ -351,8 +351,11 @@ func ReadSkill(options ReadSkillOptions) fantasy.AgentTool { if ok { return response, nil } + // Include the absolute skill directory so the agent can + // reach supporting files with read_file and execute. return toolResponse(map[string]any{ "name": args.Name, + "dir": content.Dir, "body": content.Body, "files": nonNilFiles(content.Files), }), nil diff --git a/coderd/x/chatd/chattool/skill_test.go b/coderd/x/chatd/chattool/skill_test.go index e9c3205ffd..717518cd3d 100644 --- a/coderd/x/chatd/chattool/skill_test.go +++ b/coderd/x/chatd/chattool/skill_test.go @@ -35,6 +35,18 @@ func responseName(t *testing.T, resp fantasy.ToolResponse) string { return payload.Name } +// responseDir extracts the "dir" field from a read_skill response. It is +// empty when the field is absent, as it is for personal skills. +func responseDir(t *testing.T, resp fantasy.ToolResponse) string { + t.Helper() + + var payload struct { + Dir string `json:"dir"` + } + require.NoError(t, json.Unmarshal([]byte(resp.Content), &payload)) + return payload.Dir +} + func TestFormatResolvedSkillIndex(t *testing.T) { t.Parallel() @@ -305,6 +317,9 @@ func TestReadSkillTool(t *testing.T) { assert.False(t, resp.IsError) assert.Contains(t, resp.Content, "Do the thing.") assert.Contains(t, resp.Content, "helper.md") + // Workspace skills expose the absolute skill directory so the + // model can reach supporting files with read_file/execute. + assert.Equal(t, "/work/.agents/skills/my-skill", responseDir(t, resp)) }) t.Run("PinnedBodyServedWhenWorkspaceUnreachable", func(t *testing.T) { @@ -334,6 +349,10 @@ func TestReadSkillTool(t *testing.T) { assert.False(t, resp.IsError) assert.Contains(t, resp.Content, "Do the thing.") assert.Contains(t, resp.Content, `"files":[]`) + // The dir comes from the pinned SkillMeta, so it is still + // returned even when the workspace is unreachable and the file + // list degrades to empty. + assert.Equal(t, "/work/.agents/skills/my-skill", responseDir(t, resp)) }) t.Run("PersonalSkill", func(t *testing.T) { @@ -372,6 +391,9 @@ func TestReadSkillTool(t *testing.T) { assert.False(t, resp.IsError) assert.Contains(t, resp.Content, "Personal instructions.") assert.Contains(t, resp.Content, `"files":[]`) + // Personal skills are database-backed and have no directory. + assert.Empty(t, responseDir(t, resp)) + assert.NotContains(t, resp.Content, `"dir"`) }) t.Run("PersonalQualifiedAliasPreservesAlias", func(t *testing.T) { @@ -459,6 +481,7 @@ func TestReadSkillTool(t *testing.T) { assert.False(t, resp.IsError) assert.Equal(t, "workspace/my-skill", responseName(t, resp)) assert.Contains(t, resp.Content, "Do the thing.") + assert.Equal(t, "/work/.agents/skills/my-skill", responseDir(t, resp)) }) t.Run("CollisionAliasRoundTrip", func(t *testing.T) { @@ -530,6 +553,7 @@ func TestReadSkillTool(t *testing.T) { assert.False(t, workspaceResp.IsError) workspaceName := responseName(t, workspaceResp) assert.Equal(t, "workspace/deploy", workspaceName) + assert.Equal(t, "/work/.agents/skills/deploy", responseDir(t, workspaceResp)) workspaceResolved, err := resolveAlias(workspaceName) require.NoError(t, err) assert.Equal(t, skillspkg.SourceWorkspace, workspaceResolved.Source) diff --git a/docs/ai-coder/agents/extending-agents.md b/docs/ai-coder/agents/extending-agents.md index 04ce2eca4f..6b50cb5210 100644 --- a/docs/ai-coder/agents/extending-agents.md +++ b/docs/ai-coder/agents/extending-agents.md @@ -25,10 +25,17 @@ calls a tool. Two tools are registered when skills are present: -| Tool | Parameters | Description | -|-------------------|----------------------------------|----------------------------------------------------------| -| `read_skill` | `name` (string) | Returns the SKILL.md body and a list of supporting files | -| `read_skill_file` | `name` (string), `path` (string) | Returns the content of a supporting file | +| Tool | Parameters | Description | +|-------------------|----------------------------------|------------------------------------------------------------------------------------------------------------| +| `read_skill` | `name` (string) | Returns the SKILL.md body, the absolute skill directory (workspace skills), and a list of supporting files | +| `read_skill_file` | `name` (string), `path` (string) | Returns the content of a supporting file | + +For workspace skills, `read_skill` also returns `dir`, the absolute path to +the skill directory in the workspace. The agent's `read_file` and `execute` +tools operate on that same workspace filesystem, so you can join `dir` with a +supporting file's relative path to read or run that file directly, for example +to execute a bundled `scripts/` helper. `read_skill_file` remains available as +a path-safe convenience for reading supporting files. ### Directory structure