From acb730d8541d24233cb010046a89b96e53261f5a Mon Sep 17 00:00:00 2001 From: RafiulPaceProjects Date: Wed, 11 Mar 2026 18:28:38 -0400 Subject: [PATCH] feat(skills): implement per-agent SkillsFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the existing-but-unused `AgentConfig.Skills` field through `ContextBuilder` into `SkillsLoader` so each agent only sees the skills it is configured to access. Changes: - `pkg/skills/loader.go`: Add `BuildSkillsSummaryFiltered(allowedNames)`; `BuildSkillsSummary()` now delegates to it with nil (all skills). - `pkg/agent/context.go`: Add `skillsFilter []string` field and `WithSkillsFilter()` builder method; `BuildSystemPrompt()` uses the filter; `GetSkillsInfo()` reflects filtered count via `available` key. - `pkg/agent/instance.go`: Call `contextBuilder.WithSkillsFilter(skillsFilter)` after `skillsFilter` is populated from `agentCfg.Skills`. Behaviour: empty/nil filter → all skills (no change from before); non-empty filter → only listed skills appear in the system prompt and `/skills` output. The system prompt cache is unaffected (filter is constant for a ContextBuilder's lifetime). Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/context.go | 27 +++++++++++++++++++++++---- pkg/agent/instance.go | 2 ++ pkg/skills/loader.go | 23 ++++++++++++++++++++++- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 5a84c45e2..3adede94e 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -25,6 +25,7 @@ type ContextBuilder struct { memory *MemoryStore toolDiscoveryBM25 bool toolDiscoveryRegex bool + skillsFilter []string // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -51,6 +52,13 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil return cb } +// WithSkillsFilter restricts which skills appear in the system prompt to the +// named list. An empty or nil filter means all available skills are included. +func (cb *ContextBuilder) WithSkillsFilter(filter []string) *ContextBuilder { + cb.skillsFilter = filter + return cb +} + func getGlobalConfigDir() string { if home := os.Getenv("PICOCLAW_HOME"); home != "" { return home @@ -141,7 +149,7 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } // Skills - show summary, AI can read full content with read_file tool - skillsSummary := cb.skillsLoader.BuildSkillsSummary() + skillsSummary := cb.skillsLoader.BuildSkillsSummaryFiltered(cb.skillsFilter) if skillsSummary != "" { parts = append(parts, fmt.Sprintf(`# Skills @@ -718,16 +726,27 @@ func (cb *ContextBuilder) AddAssistantMessage( return messages } -// GetSkillsInfo returns information about loaded skills. +// GetSkillsInfo returns information about loaded skills, respecting any active filter. func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() + + var allowed map[string]bool + if len(cb.skillsFilter) > 0 { + allowed = make(map[string]bool, len(cb.skillsFilter)) + for _, n := range cb.skillsFilter { + allowed[n] = true + } + } + skillNames := make([]string, 0, len(allSkills)) for _, s := range allSkills { - skillNames = append(skillNames, s.Name) + if allowed == nil || allowed[s.Name] { + skillNames = append(skillNames, s.Name) + } } return map[string]any{ "total": len(allSkills), - "available": len(allSkills), + "available": len(skillNames), "names": skillNames, } } diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 0c7baa1ee..ca1a3ee31 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -117,6 +117,8 @@ func NewAgentInstance( skillsFilter = agentCfg.Skills } + contextBuilder.WithSkillsFilter(skillsFilter) + maxIter := defaults.MaxToolIterations if maxIter == 0 { maxIter = 20 diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index f5985a662..c8765e10c 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -192,19 +192,36 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string { } func (sl *SkillsLoader) BuildSkillsSummary() string { + return sl.BuildSkillsSummaryFiltered(nil) +} + +// BuildSkillsSummaryFiltered builds the XML skills summary, restricted to +// allowedNames if non-empty. An empty/nil allowedNames includes all skills. +func (sl *SkillsLoader) BuildSkillsSummaryFiltered(allowedNames []string) string { allSkills := sl.ListSkills() if len(allSkills) == 0 { return "" } + var allowed map[string]bool + if len(allowedNames) > 0 { + allowed = make(map[string]bool, len(allowedNames)) + for _, n := range allowedNames { + allowed[n] = true + } + } + var lines []string lines = append(lines, "") for _, s := range allSkills { + if allowed != nil && !allowed[s.Name] { + continue + } escapedName := escapeXML(s.Name) escapedDesc := escapeXML(s.Description) escapedPath := escapeXML(s.Path) - lines = append(lines, fmt.Sprintf(" ")) + lines = append(lines, " ") lines = append(lines, fmt.Sprintf(" %s", escapedName)) lines = append(lines, fmt.Sprintf(" %s", escapedDesc)) lines = append(lines, fmt.Sprintf(" %s", escapedPath)) @@ -213,6 +230,10 @@ func (sl *SkillsLoader) BuildSkillsSummary() string { } lines = append(lines, "") + if len(lines) == 2 { + // Only open/close tags — filter excluded everything + return "" + } return strings.Join(lines, "\n") }