diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index 31dc31cb1..b4b4e4d5e 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -668,10 +668,11 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string { b.WriteString("## Quick Start\n\n") fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID) - if len(ctx.AgentSkills) > 0 { + skills := modelVisibleSkills(ctx.AgentSkills) + if len(skills) > 0 { b.WriteString("## Agent Skills\n\n") b.WriteString("The following skills are available to you:\n\n") - for _, skill := range ctx.AgentSkills { + for _, skill := range skills { fmt.Fprintf(&b, "- **%s**\n", skill.Name) } b.WriteString("\n") @@ -692,9 +693,10 @@ func renderQuickCreateContext(ctx TaskContextForEnv) string { b.WriteString("> ") b.WriteString(ctx.QuickCreatePrompt) b.WriteString("\n\n") - if len(ctx.AgentSkills) > 0 { + skills := modelVisibleSkills(ctx.AgentSkills) + if len(skills) > 0 { b.WriteString("## Agent Skills\n\n") - for _, skill := range ctx.AgentSkills { + for _, skill := range skills { fmt.Fprintf(&b, "- **%s**\n", skill.Name) } b.WriteString("\n") @@ -731,10 +733,11 @@ func renderAutopilotContext(ctx TaskContextForEnv) string { b.WriteString("\n\n") } - if len(ctx.AgentSkills) > 0 { + skills := modelVisibleSkills(ctx.AgentSkills) + if len(skills) > 0 { b.WriteString("## Agent Skills\n\n") b.WriteString("The following skills are available to you:\n\n") - for _, skill := range ctx.AgentSkills { + for _, skill := range skills { fmt.Fprintf(&b, "- **%s**\n", skill.Name) } b.WriteString("\n") diff --git a/server/internal/daemon/execenv/runtime_config_sections.go b/server/internal/daemon/execenv/runtime_config_sections.go index 9504d40c6..ce9dddc17 100644 --- a/server/internal/daemon/execenv/runtime_config_sections.go +++ b/server/internal/daemon/execenv/runtime_config_sections.go @@ -421,7 +421,8 @@ func writeSubIssueCreation(b *strings.Builder) { // writeSkills emits the Skills section listing skill names + descriptions. func writeSkills(b *strings.Builder, provider string, ctx TaskContextForEnv) { - if len(ctx.AgentSkills) == 0 { + skills := modelVisibleSkills(ctx.AgentSkills) + if len(skills) == 0 { return } b.WriteString("## Skills\n\n") @@ -435,7 +436,7 @@ func writeSkills(b *strings.Builder, provider string, ctx TaskContextForEnv) { default: b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n") } - for _, skill := range ctx.AgentSkills { + for _, skill := range skills { if desc := strings.TrimSpace(skill.Description); desc != "" { fmt.Fprintf(b, "- **%s** — %s\n", skill.Name, desc) } else { diff --git a/server/internal/daemon/execenv/skill_visibility.go b/server/internal/daemon/execenv/skill_visibility.go new file mode 100644 index 000000000..1edaea47a --- /dev/null +++ b/server/internal/daemon/execenv/skill_visibility.go @@ -0,0 +1,47 @@ +package execenv + +import ( + "strings" + + "gopkg.in/yaml.v3" +) + +func modelVisibleSkills(skills []SkillContextForEnv) []SkillContextForEnv { + if len(skills) == 0 { + return nil + } + visible := make([]SkillContextForEnv, 0, len(skills)) + for _, skill := range skills { + if skillModelInvocationVisible(skill) { + visible = append(visible, skill) + } + } + return visible +} + +func skillModelInvocationVisible(skill SkillContextForEnv) bool { + return !skillDisablesModelInvocation(skill.Content) +} + +func skillDisablesModelInvocation(content string) bool { + fmBody, _, ok := frontmatterParts(content) + if !ok || strings.TrimSpace(fmBody) == "" { + return false + } + var data map[string]any + if err := yaml.Unmarshal([]byte(fmBody), &data); err != nil { + return false + } + value, ok := data["disable-model-invocation"] + if !ok { + return false + } + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(strings.TrimSpace(v), "true") + default: + return false + } +} diff --git a/server/internal/daemon/execenv/skill_visibility_test.go b/server/internal/daemon/execenv/skill_visibility_test.go new file mode 100644 index 000000000..6aae92bd3 --- /dev/null +++ b/server/internal/daemon/execenv/skill_visibility_test.go @@ -0,0 +1,226 @@ +package execenv + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSkillModelInvocationVisibility(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + content string + want bool + }{ + { + name: "no frontmatter is visible", + content: "body", + want: true, + }, + { + name: "disable true is hidden", + content: `--- +name: hidden +disable-model-invocation: true +--- + +body`, + want: false, + }, + { + name: "disable quoted true is hidden", + content: `--- +name: hidden +disable-model-invocation: "true" +--- + +body`, + want: false, + }, + { + name: "disable false is visible", + content: `--- +name: visible +disable-model-invocation: false +--- + +body`, + want: true, + }, + { + name: "user invocable false is still visible", + content: `--- +name: visible +user-invocable: false +--- + +body`, + want: true, + }, + { + name: "invalid frontmatter is visible", + content: `--- +name: visible +description: bad: value +disable-model-invocation: true +--- + +body`, + want: true, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := skillModelInvocationVisible(SkillContextForEnv{Content: tc.content}) + if got != tc.want { + t.Errorf("skillModelInvocationVisible = %v, want %v", got, tc.want) + } + }) + } +} + +func TestRenderedSkillListsHideDisableModelInvocationSkills(t *testing.T) { + t.Parallel() + + ctx := TaskContextForEnv{ + IssueID: "issue-1", + QuickCreatePrompt: "create something", + AutopilotRunID: "run-1", + AgentName: "Eve", + AgentID: "eve-1", + AgentSkills: []SkillContextForEnv{ + { + Name: "Visible Skill", + Description: "visible description", + Content: `--- +name: visible-skill +--- + +Visible body.`, + }, + { + Name: "Hidden Skill", + Description: "hidden description", + Content: `--- +name: hidden-skill +disable-model-invocation: true +--- + +Hidden body.`, + }, + }, + } + + rendered := map[string]string{ + "runtime config": buildMetaSkillContent("codex", TaskContextForEnv{ + IssueID: ctx.IssueID, + AgentName: ctx.AgentName, + AgentID: ctx.AgentID, + AgentSkills: ctx.AgentSkills, + }), + "issue context": renderIssueContext("codex", TaskContextForEnv{ + IssueID: ctx.IssueID, + AgentSkills: ctx.AgentSkills, + }), + "quick create": renderQuickCreateContext(TaskContextForEnv{ + QuickCreatePrompt: ctx.QuickCreatePrompt, + AgentSkills: ctx.AgentSkills, + }), + "autopilot": renderAutopilotContext(TaskContextForEnv{ + AutopilotRunID: ctx.AutopilotRunID, + AgentSkills: ctx.AgentSkills, + }), + } + + for name, out := range rendered { + if !strings.Contains(out, "Visible Skill") { + t.Errorf("%s missing visible skill:\n%s", name, out) + } + if strings.Contains(out, "Hidden Skill") || strings.Contains(out, "hidden description") { + t.Errorf("%s advertised disable-model-invocation skill:\n%s", name, out) + } + } +} + +func TestRenderedSkillListsOmitSectionWhenOnlyDisabledSkills(t *testing.T) { + t.Parallel() + + ctx := TaskContextForEnv{ + IssueID: "issue-1", + AgentSkills: []SkillContextForEnv{ + { + Name: "Hidden Skill", + Content: `--- +name: hidden-skill +disable-model-invocation: true +--- + +Hidden body.`, + }, + }, + } + + runtimeConfig := buildMetaSkillContent("codex", ctx) + if strings.Contains(runtimeConfig, "## Skills") { + t.Errorf("runtime config should omit Skills section when every skill disables model invocation:\n%s", runtimeConfig) + } + + issueContext := renderIssueContext("codex", ctx) + if strings.Contains(issueContext, "## Agent Skills") { + t.Errorf("issue context should omit Agent Skills section when every skill disables model invocation:\n%s", issueContext) + } +} + +func TestWriteContextFilesHydratesDisableModelInvocationSkillButDoesNotAdvertiseIt(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + ctx := TaskContextForEnv{ + IssueID: "issue-1", + AgentSkills: []SkillContextForEnv{ + { + Name: "Visible Skill", + Content: `--- +name: visible-skill +--- + +Visible body.`, + }, + { + Name: "Hidden Skill", + Content: `--- +name: hidden-skill +disable-model-invocation: true +--- + +Hidden body.`, + }, + }, + } + + if err := writeContextFiles(dir, "", ctx, nil); err != nil { + t.Fatalf("writeContextFiles failed: %v", err) + } + + issueContext, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md")) + if err != nil { + t.Fatalf("read issue_context.md: %v", err) + } + if strings.Contains(string(issueContext), "Hidden Skill") { + t.Fatalf("issue_context.md advertised hidden skill:\n%s", string(issueContext)) + } + + hiddenSkill, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "hidden-skill", "SKILL.md")) + if err != nil { + t.Fatalf("disabled skill should still be hydrated on disk: %v", err) + } + if !strings.Contains(string(hiddenSkill), "disable-model-invocation: true") { + t.Errorf("hidden skill frontmatter should be preserved on disk:\n%s", string(hiddenSkill)) + } +}