Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions agent/skills/fsskills/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -562,24 +561,44 @@ type discoveredSkillDir struct {
path string
}

// buildScriptSchemasBlock returns a <script_schemas> 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<available_resources />"
}
var sb strings.Builder
sb.WriteString("\n<available_resources>\n")
for _, resource := range resources {
if resource.Description != "" {
fmt.Fprintf(&sb, " <resource name=\"%s\" description=\"%s\"/>\n",
xmlEscapeAttr(resource.Name), xmlEscapeAttr(resource.Description))
} else {
fmt.Fprintf(&sb, " <resource name=\"%s\"/>\n", xmlEscapeAttr(resource.Name))
}
}
sb.WriteString("</available_resources>")
return sb.String()
}

func buildAvailableScriptsBlock(scripts []skills.Script) string {
if len(scripts) == 0 {
return ""
return "\n<available_scripts />"
}
var sb strings.Builder
sb.WriteString("\n<script_schemas>\n")
sb.WriteString("\n<available_scripts>\n")
for _, script := range scripts {
namePart := fmt.Sprintf(" <script name=\"%s\"", xmlEscapeAttr(script.Name))
if script.Description != "" {
namePart += fmt.Sprintf(" description=\"%s\"", xmlEscapeAttr(script.Description))
}
if script.ParametersSchema == "" {
fmt.Fprintf(&sb, " <schema script=\"%s\"/>\n", xmlEscapeAttr(script.Name))
fmt.Fprintf(&sb, "%s/>\n", namePart)
} else {
fmt.Fprintf(&sb, " <schema script=\"%s\">%s</schema>\n", xmlEscapeAttr(script.Name), xmlEscapeContent(script.ParametersSchema))
fmt.Fprintf(&sb, "%s>\n", namePart)
fmt.Fprintf(&sb, " <parameters_schema>%s</parameters_schema>\n", xmlEscapeContent(script.ParametersSchema))
sb.WriteString(" </script>\n")
}
}
sb.WriteString("</script_schemas>")
sb.WriteString("</available_scripts>")
return sb.String()
}

Expand Down
95 changes: 95 additions & 0 deletions agent/skills/fsskills/source_block_builder_test.go
Original file line number Diff line number Diff line change
@@ -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, `<resource name="docs/guide.md" description="The user guide"/>`) {
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, `<resource name="docs/guide.md"/>`) {
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" & <tagged> 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 &quot;quoted&quot; &amp; &lt;tagged&gt; 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, `<script name="scripts/run.py" description="Runs the pipeline"/>`) {
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, `<script name="scripts/run.py" description="Runs the pipeline">`) {
t.Fatalf("expected description attribute in expanded script element, got: %s", got)
}
if !strings.Contains(got, `<parameters_schema>{"type":"object"}</parameters_schema>`) {
t.Fatalf("expected parameters_schema element, got: %s", got)
}
}

func TestBuildAvailableScriptsBlock_WithoutDescription_OmitsDescriptionAttribute(t *testing.T) {
scripts := []skills.Script{
{Name: "scripts/run.py"},
}
got := buildAvailableScriptsBlock(scripts)
if !strings.Contains(got, `<script name="scripts/run.py"/>`) {
t.Fatalf("expected self-closing script 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 TestBuildAvailableScriptsBlock_DescriptionIsXmlEscaped(t *testing.T) {
scripts := []skills.Script{
{Name: "run.py", Description: `Convert "mph" to km/h`},
}
got := buildAvailableScriptsBlock(scripts)
if strings.Contains(got, `description="Convert "mph"`) {
t.Fatalf("expected description to be XML-escaped, got: %s", got)
}
if !strings.Contains(got, `description="Convert &quot;mph&quot; to km/h"`) {
t.Fatalf("expected XML-escaped description attribute, got: %s", got)
}
}
34 changes: 20 additions & 14 deletions agent/skills/fsskills/source_script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ func TestFileScript_HasDefaultParametersSchema(t *testing.T) {
}
}

func TestFileSkill_WithScripts_ContentIncludesScriptSchemasBlock(t *testing.T) {
func TestFileSkill_WithScripts_ContentIncludesAvailableScriptsBlock(t *testing.T) {
root := t.TempDir()
createSkillDir(t, root, "schema-content-skill", "A test skill", "Instructions here.")
createRelativeFile(t, filepath.Join(root, "schema-content-skill"), "build.sh", "echo build")
Expand All @@ -402,17 +402,20 @@ func TestFileSkill_WithScripts_ContentIncludesScriptSchemasBlock(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.Contains(content, "<script_schemas>") {
t.Fatalf("expected <script_schemas> block in content, got: %s", content)
if !strings.Contains(content, "<available_scripts>") {
t.Fatalf("expected <available_scripts> block in content, got: %s", content)
}
if !strings.Contains(content, `<schema script="build.sh">`) {
t.Fatalf("expected <schema script=\"build.sh\"> in content, got: %s", content)
if !strings.Contains(content, `<script name="build.sh">`) {
t.Fatalf("expected <script name=\"build.sh\"> in content, got: %s", content)
}
if !strings.Contains(content, `<schema script="deploy.sh">`) {
t.Fatalf("expected <schema script=\"deploy.sh\"> in content, got: %s", content)
if !strings.Contains(content, `<script name="deploy.sh">`) {
t.Fatalf("expected <script name=\"deploy.sh\"> in content, got: %s", content)
}
if !strings.Contains(content, "</script_schemas>") {
t.Fatalf("expected </script_schemas> in content, got: %s", content)
if !strings.Contains(content, "<parameters_schema>") {
t.Fatalf("expected <parameters_schema> in content, got: %s", content)
}
if !strings.Contains(content, "</available_scripts>") {
t.Fatalf("expected </available_scripts> in content, got: %s", content)
}
}

Expand All @@ -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, "<script_schemas>") {
t.Fatalf("expected <script_schemas> block appended, got: %s", content)
if !strings.Contains(content, "<available_resources />") {
t.Fatalf("expected empty <available_resources /> block appended, got: %s", content)
}
if !strings.Contains(content, "<available_scripts>") {
t.Fatalf("expected <available_scripts> 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))
Expand All @@ -455,8 +461,8 @@ func TestFileSkill_WithoutScripts_ContentDoesNotIncludeScriptSchemasBlock(t *tes
if err != nil {
t.Fatal(err)
}
if strings.Contains(content, "<script_schemas>") {
t.Fatalf("expected no <script_schemas> block when skill has no scripts, got: %s", content)
if !strings.Contains(content, "<available_scripts />") {
t.Fatalf("expected empty <available_scripts /> block when skill has no scripts, got: %s", content)
}
}

Expand Down
46 changes: 46 additions & 0 deletions agent/skills/fsskills/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<available_resources />") {
t.Fatalf("expected empty <available_resources /> 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, "<available_resources>") {
t.Fatalf("expected <available_resources> block in content, got: %s", content)
}
if !strings.Contains(content, `<resource name="assets/config.json"/>`) {
t.Fatalf("expected assets/config.json resource in content, got: %s", content)
}
if !strings.Contains(content, `<resource name="references/doc.md"/>`) {
t.Fatalf("expected references/doc.md resource in content, got: %s", content)
}
if !strings.Contains(content, "</available_resources>") {
t.Fatalf("expected </available_resources> 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.")
Expand Down
6 changes: 3 additions & 3 deletions agent/skills/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script_schemas> 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 <available_scripts> 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
Expand Down
2 changes: 1 addition & 1 deletion agent/skills/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<available_resources />\n\n<available_scripts />"
if result != expected {
t.Errorf("expected full SKILL.md content, got %q", result)
}
Expand Down
Loading