diff --git a/CHANGELOG.md b/CHANGELOG.md index f5748b8..e9b3020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## [Unreleased] +## [0.8.1] - 2026-05-25 + +### Fixed + +- **skills plugin**: guard skill cache with `sync.RWMutex` so parallel tool calls no longer crash with `fatal error: concurrent map writes` ([#53](https://github.com/thinktwiceco/agent-forge/issues/53)) + ## [0.8.0] - 2026-05-25 - [f9fe70a](http://github.com/thinktwiceco/agent-forge/commit/f9fe70ad04f1272d7e7d17d510c7cca041f5ed66) - chore(release): prepare release v0.8.0 diff --git a/VERSION b/VERSION index a3df0a6..6f4eebd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.8.1 diff --git a/src/plugins/skills/plugin.go b/src/plugins/skills/plugin.go index 87c1e94..3796f15 100644 --- a/src/plugins/skills/plugin.go +++ b/src/plugins/skills/plugin.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "strings" + "sync" agentforge "github.com/thinktwiceco/agent-forge/src" "github.com/thinktwiceco/agent-forge/src/agents" @@ -50,6 +51,7 @@ type Skill struct { // skills, and inspect references on demand. type SkillsPlugin struct { dir string + mu sync.RWMutex skills map[string]*Skill } @@ -90,6 +92,9 @@ func (p *SkillsPlugin) Hooks() map[core.Event]core.AgentHookFn { // SystemPrompt implements core.PromptProvider. // Returns a prompt section listing all discovered skills. func (p *SkillsPlugin) SystemPrompt() string { + p.mu.RLock() + defer p.mu.RUnlock() + if len(p.skills) == 0 { return "" } @@ -130,6 +135,9 @@ func (p *SkillsPlugin) Tools() []llms.Tool { // loadSkills scans skills/ for SKILL.md-based skill packages. func (p *SkillsPlugin) loadSkills() error { + p.mu.Lock() + defer p.mu.Unlock() + p.skills = make(map[string]*Skill) entries, err := os.ReadDir(p.dir) @@ -167,6 +175,10 @@ func (p *SkillsPlugin) getSkill(name string) (*Skill, error) { if err := p.loadSkills(); err != nil { return nil, err } + + p.mu.RLock() + defer p.mu.RUnlock() + skill, exists := p.skills[name] if !exists { return nil, fmt.Errorf("skill '%s' not found", name) diff --git a/src/plugins/skills/skills_test.go b/src/plugins/skills/skills_test.go index 0d0fa5d..70f5531 100644 --- a/src/plugins/skills/skills_test.go +++ b/src/plugins/skills/skills_test.go @@ -526,6 +526,48 @@ func TestSkillToolInstallRemoteSkill(t *testing.T) { } } +func TestGetSkill_ConcurrentNoRace(t *testing.T) { + root := t.TempDir() + skillDir := filepath.Join(root, "skills", "concurrent-skill") + if err := os.MkdirAll(filepath.Join(skillDir, "references"), 0755); err != nil { + t.Fatal(err) + } + writeTestSkill(t, skillDir, "concurrent-skill", "Concurrent access test", "Use when testing parallel skill lookups.") + if err := os.WriteFile(filepath.Join(skillDir, "references", "notes.md"), []byte("reference body"), 0644); err != nil { + t.Fatal(err) + } + + plugin := NewSkillsPlugin(root) + tool := newSkillTool(plugin) + + const workers = 32 + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + skill, err := plugin.getSkill("concurrent-skill") + if err != nil { + t.Error(err) + return + } + if skill.Name != "concurrent-skill" { + t.Errorf("unexpected skill name: %q", skill.Name) + } + + refResp := tool.Call(nil, map[string]any{ + "action": "load_skill_reference", + "name": "concurrent-skill", + "referencePath": "notes.md", + }) + if !refResp.Success() || !strings.Contains(refResp.Data(), "reference body") { + t.Errorf("load_skill_reference response: success=%v data=%q err=%q", refResp.Success(), refResp.Data(), refResp.Error()) + } + }() + } + wg.Wait() +} + func writeTestSkill(t *testing.T, dir, name, description, usage string) { t.Helper() if err := os.MkdirAll(dir, 0755); err != nil { diff --git a/src/plugins/skills/tools.go b/src/plugins/skills/tools.go index 7e4adfe..d45893a 100644 --- a/src/plugins/skills/tools.go +++ b/src/plugins/skills/tools.go @@ -142,6 +142,10 @@ func (p *SkillsPlugin) handleListSkills() llms.ToolReturn { if err := p.loadSkills(); err != nil { return core.NewErrorResponse(fmt.Sprintf("failed to load skills: %v", err)) } + + p.mu.RLock() + defer p.mu.RUnlock() + if len(p.skills) == 0 { return core.NewEphemeralResponse("No skills found under skills/.") }