diff --git a/agent/skills/fsskills/source.go b/agent/skills/fsskills/source.go
index 29f01376..e3f30a12 100644
--- a/agent/skills/fsskills/source.go
+++ b/agent/skills/fsskills/source.go
@@ -242,9 +242,8 @@ func (s *Source) parseSkillDirectory(skillFS fs.FS, logPath string) *skills.Skil
return
}
raw := string(data)
- if schemasBlock := buildScriptSchemasBlock(scripts); schemasBlock != "" {
- raw += schemasBlock
- }
+ raw += "\n" + buildAvailableResourcesBlock(resources)
+ raw += "\n" + buildAvailableScriptsBlock(scripts)
cachedContent = raw
})
return cachedContent, contentErr
@@ -562,24 +561,44 @@ type discoveredSkillDir struct {
path string
}
-// buildScriptSchemasBlock returns a XML block listing each
-// script with its parameter schema. Scripts with no schema emit a self-closing
-// element; scripts with a schema emit the JSON inline.
-// Returns an empty string when scripts is empty.
-func buildScriptSchemasBlock(scripts []skills.Script) string {
+func buildAvailableResourcesBlock(resources []skills.Resource) string {
+ if len(resources) == 0 {
+ return "\n"
+ }
+ var sb strings.Builder
+ sb.WriteString("\n\n")
+ for _, resource := range resources {
+ if resource.Description != "" {
+ fmt.Fprintf(&sb, " \n",
+ xmlEscapeAttr(resource.Name), xmlEscapeAttr(resource.Description))
+ } else {
+ fmt.Fprintf(&sb, " \n", xmlEscapeAttr(resource.Name))
+ }
+ }
+ sb.WriteString("")
+ return sb.String()
+}
+
+func buildAvailableScriptsBlock(scripts []skills.Script) string {
if len(scripts) == 0 {
- return ""
+ return "\n"
}
var sb strings.Builder
- sb.WriteString("\n\n")
+ sb.WriteString("\n\n")
for _, script := range scripts {
+ namePart := fmt.Sprintf(" \n")
}
}
- sb.WriteString("")
+ sb.WriteString("")
return sb.String()
}
diff --git a/agent/skills/fsskills/source_block_builder_test.go b/agent/skills/fsskills/source_block_builder_test.go
new file mode 100644
index 00000000..fa809d54
--- /dev/null
+++ b/agent/skills/fsskills/source_block_builder_test.go
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+package fsskills
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/microsoft/agent-framework-go/agent/skills"
+)
+
+func TestBuildAvailableResourcesBlock_WithDescription_EmitsDescriptionAttribute(t *testing.T) {
+ resources := []skills.Resource{
+ {Name: "docs/guide.md", Description: "The user guide"},
+ }
+ got := buildAvailableResourcesBlock(resources)
+ if !strings.Contains(got, ``) {
+ t.Fatalf("expected description attribute in resource element, got: %s", got)
+ }
+}
+
+func TestBuildAvailableResourcesBlock_WithoutDescription_OmitsDescriptionAttribute(t *testing.T) {
+ resources := []skills.Resource{
+ {Name: "docs/guide.md"},
+ }
+ got := buildAvailableResourcesBlock(resources)
+ if !strings.Contains(got, ``) {
+ t.Fatalf("expected self-closing resource without description attribute, got: %s", got)
+ }
+ if strings.Contains(got, "description=") {
+ t.Fatalf("expected no description attribute when description is empty, got: %s", got)
+ }
+}
+
+func TestBuildAvailableResourcesBlock_DescriptionIsXmlEscaped(t *testing.T) {
+ resources := []skills.Resource{
+ {Name: "data.xml", Description: `A "quoted" & resource`},
+ }
+ got := buildAvailableResourcesBlock(resources)
+ if strings.Contains(got, `"A "quoted"`) {
+ t.Fatalf("expected description to be XML-escaped, got: %s", got)
+ }
+ if !strings.Contains(got, `description="A "quoted" & <tagged> resource"`) {
+ t.Fatalf("expected XML-escaped description attribute, got: %s", got)
+ }
+}
+
+func TestBuildAvailableScriptsBlock_WithDescription_NoSchema_EmitsDescriptionAttribute(t *testing.T) {
+ scripts := []skills.Script{
+ {Name: "scripts/run.py", Description: "Runs the pipeline"},
+ }
+ got := buildAvailableScriptsBlock(scripts)
+ if !strings.Contains(got, ``) {
+ t.Fatalf("expected description attribute in self-closing script element, got: %s", got)
+ }
+}
+
+func TestBuildAvailableScriptsBlock_WithDescription_WithSchema_EmitsDescriptionAttribute(t *testing.T) {
+ scripts := []skills.Script{
+ {Name: "scripts/run.py", Description: "Runs the pipeline", ParametersSchema: `{"type":"object"}`},
+ }
+ got := buildAvailableScriptsBlock(scripts)
+ if !strings.Contains(got, `") {
- t.Fatalf("expected in content, got: %s", content)
+ if !strings.Contains(content, "") {
+ t.Fatalf("expected in content, got: %s", content)
+ }
+ if !strings.Contains(content, "") {
+ t.Fatalf("expected in content, got: %s", content)
}
}
@@ -437,12 +440,15 @@ func TestFileSkill_WithScripts_ContentStartsWithOriginalSkillMd(t *testing.T) {
if !strings.Contains(content, "Original instructions.") {
t.Fatalf("expected original SKILL.md content to be preserved, got: %s", content)
}
- if !strings.Contains(content, "") {
- t.Fatalf("expected block appended, got: %s", content)
+ if !strings.Contains(content, "") {
+ t.Fatalf("expected empty block appended, got: %s", content)
+ }
+ if !strings.Contains(content, "") {
+ t.Fatalf("expected block appended, got: %s", content)
}
}
-func TestFileSkill_WithoutScripts_ContentDoesNotIncludeScriptSchemasBlock(t *testing.T) {
+func TestFileSkill_WithoutScripts_ContentIncludesEmptyAvailableScriptsBlock(t *testing.T) {
root := t.TempDir()
createSkillDir(t, root, "no-script-content-skill", "A test skill", "Instructions here.")
source := fsskills.NewSource(os.DirFS(root))
@@ -455,8 +461,8 @@ func TestFileSkill_WithoutScripts_ContentDoesNotIncludeScriptSchemasBlock(t *tes
if err != nil {
t.Fatal(err)
}
- if strings.Contains(content, "") {
- t.Fatalf("expected no block when skill has no scripts, got: %s", content)
+ if !strings.Contains(content, "") {
+ t.Fatalf("expected empty block when skill has no scripts, got: %s", content)
}
}
diff --git a/agent/skills/fsskills/source_test.go b/agent/skills/fsskills/source_test.go
index a74e7d46..de730c42 100644
--- a/agent/skills/fsskills/source_test.go
+++ b/agent/skills/fsskills/source_test.go
@@ -53,6 +53,52 @@ func TestFileSource_NoResourceFiles_ReturnsEmptyResources(t *testing.T) {
}
}
+func TestFileSkill_WithoutResources_ContentIncludesEmptyAvailableResourcesBlock(t *testing.T) {
+ root := t.TempDir()
+ createSkillDir(t, root, "no-resource-content", "A skill", "No resources here.")
+ source := fsskills.NewSource(os.DirFS(root))
+
+ loaded, err := source.Skills(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ content, err := loaded[0].GetContent(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(content, "") {
+ t.Fatalf("expected empty block when skill has no resources, got: %s", content)
+ }
+}
+
+func TestFileSkill_WithResources_ContentIncludesAvailableResourcesBlock(t *testing.T) {
+ root := t.TempDir()
+ createSkillDirWithResource(t, root, "resource-content", "A skill", "Use these resources.", "references/doc.md", "Document content.")
+ createRelativeFile(t, filepath.Join(root, "resource-content"), "assets/config.json", "{}")
+ source := fsskills.NewSource(os.DirFS(root))
+
+ loaded, err := source.Skills(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ content, err := loaded[0].GetContent(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(content, "") {
+ t.Fatalf("expected block in content, got: %s", content)
+ }
+ if !strings.Contains(content, ``) {
+ t.Fatalf("expected assets/config.json resource in content, got: %s", content)
+ }
+ if !strings.Contains(content, ``) {
+ t.Fatalf("expected references/doc.md resource in content, got: %s", content)
+ }
+ if !strings.Contains(content, "") {
+ t.Fatalf("expected in content, got: %s", content)
+ }
+}
+
func TestFileSource_NestedSkillDirectory_DiscoveredWithinDepthLimit(t *testing.T) {
root := t.TempDir()
createSkillDir(t, filepath.Join(root, "level1"), "nested-skill", "Nested", "Nested body.")
diff --git a/agent/skills/skills.go b/agent/skills/skills.go
index 94938631..e64f4d9d 100644
--- a/agent/skills/skills.go
+++ b/agent/skills/skills.go
@@ -84,9 +84,9 @@ type Script struct {
Name string
Description string
// ParametersSchema is an optional JSON schema string describing the argument
- // format expected by the script. When set, the schema is included in the
- // skill's block so the LLM knows how to format arguments.
- // Empty means no schema.
+ // format expected by the script. When set, file-based skills include the schema
+ // in the skill's block so the LLM knows how to format
+ // arguments. Empty means no schema.
ParametersSchema string
Run func(context.Context, *Skill, []string) (any, error)
AdditionalProperties map[string]any
diff --git a/agent/skills/skills_test.go b/agent/skills/skills_test.go
index b40cab5a..ac129a67 100644
--- a/agent/skills/skills_test.go
+++ b/agent/skills/skills_test.go
@@ -389,7 +389,7 @@ func TestLoadSkill_ReturnsBody(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- expected := "---\nname: load-test\ndescription: A skill\n---\nFull instructions here."
+ expected := "---\nname: load-test\ndescription: A skill\n---\nFull instructions here.\n\n\n\n"
if result != expected {
t.Errorf("expected full SKILL.md content, got %q", result)
}