feat(skills): implement per-agent SkillsFilter

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 <noreply@anthropic.com>
This commit is contained in:
RafiulPaceProjects 2026-03-11 18:28:38 -04:00
parent 6612ca099a
commit acb730d854
3 changed files with 47 additions and 5 deletions

View file

@ -25,6 +25,7 @@ type ContextBuilder struct {
memory *MemoryStore memory *MemoryStore
toolDiscoveryBM25 bool toolDiscoveryBM25 bool
toolDiscoveryRegex bool toolDiscoveryRegex bool
skillsFilter []string
// Cache for system prompt to avoid rebuilding on every call. // Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context. // This fixes issue #607: repeated reprocessing of the entire context.
@ -51,6 +52,13 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
return cb 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 { func getGlobalConfigDir() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv("PICOCLAW_HOME"); home != "" {
return home return home
@ -141,7 +149,7 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
} }
// Skills - show summary, AI can read full content with read_file tool // Skills - show summary, AI can read full content with read_file tool
skillsSummary := cb.skillsLoader.BuildSkillsSummary() skillsSummary := cb.skillsLoader.BuildSkillsSummaryFiltered(cb.skillsFilter)
if skillsSummary != "" { if skillsSummary != "" {
parts = append(parts, fmt.Sprintf(`# Skills parts = append(parts, fmt.Sprintf(`# Skills
@ -718,16 +726,27 @@ func (cb *ContextBuilder) AddAssistantMessage(
return messages 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 { func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills() 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)) skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills { for _, s := range allSkills {
if allowed == nil || allowed[s.Name] {
skillNames = append(skillNames, s.Name) skillNames = append(skillNames, s.Name)
} }
}
return map[string]any{ return map[string]any{
"total": len(allSkills), "total": len(allSkills),
"available": len(allSkills), "available": len(skillNames),
"names": skillNames, "names": skillNames,
} }
} }

View file

@ -117,6 +117,8 @@ func NewAgentInstance(
skillsFilter = agentCfg.Skills skillsFilter = agentCfg.Skills
} }
contextBuilder.WithSkillsFilter(skillsFilter)
maxIter := defaults.MaxToolIterations maxIter := defaults.MaxToolIterations
if maxIter == 0 { if maxIter == 0 {
maxIter = 20 maxIter = 20

View file

@ -192,19 +192,36 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string {
} }
func (sl *SkillsLoader) BuildSkillsSummary() 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() allSkills := sl.ListSkills()
if len(allSkills) == 0 { if len(allSkills) == 0 {
return "" 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 var lines []string
lines = append(lines, "<skills>") lines = append(lines, "<skills>")
for _, s := range allSkills { for _, s := range allSkills {
if allowed != nil && !allowed[s.Name] {
continue
}
escapedName := escapeXML(s.Name) escapedName := escapeXML(s.Name)
escapedDesc := escapeXML(s.Description) escapedDesc := escapeXML(s.Description)
escapedPath := escapeXML(s.Path) escapedPath := escapeXML(s.Path)
lines = append(lines, fmt.Sprintf(" <skill>")) lines = append(lines, " <skill>")
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName)) lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc)) lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath)) lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
@ -213,6 +230,10 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
} }
lines = append(lines, "</skills>") lines = append(lines, "</skills>")
if len(lines) == 2 {
// Only open/close tags — filter excluded everything
return ""
}
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }