diff --git a/config/config.example.json b/config/config.example.json index 1408dda5b..8dc7bdc59 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -489,6 +489,9 @@ "install_skill": { "enabled": true }, + "update_skill": { + "enabled": true + }, "list_dir": { "enabled": true }, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 995c5720b..d5ed7a2b3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -255,6 +255,7 @@ func registerSharedTools( skills_enabled := cfg.Tools.IsToolEnabled("skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") + update_skills_enable := cfg.Tools.IsToolEnabled("update_skill") if skills_enabled && (find_skills_enable || install_skills_enable) { clawHubConfig := cfg.Tools.Skills.Registries.ClawHub registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ @@ -285,6 +286,10 @@ func registerSharedTools( } } + if update_skills_enable { + agent.Tools.Register(tools.NewUpdateSkillTool(agent.Workspace)) + } + // Spawn and spawn_status tools share a SubagentManager. // Construct it when either tool is enabled (both require subagent). spawnEnabled := cfg.Tools.IsToolEnabled("spawn") diff --git a/pkg/config/config.go b/pkg/config/config.go index 3c49da1de..fcd6e600b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1147,6 +1147,7 @@ type ToolsConfig struct { FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + UpdateSkill ToolConfig `json:"update_skill" envPrefix:"PICOCLAW_TOOLS_UPDATE_SKILL_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` @@ -2108,6 +2109,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.I2C.Enabled case "install_skill": return t.InstallSkill.Enabled + case "update_skill": + return t.UpdateSkill.Enabled case "list_dir": return t.ListDir.Enabled case "message": diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go index 44c9435d1..214b5d47e 100644 --- a/pkg/config/config_old.go +++ b/pkg/config/config_old.go @@ -72,6 +72,7 @@ type toolsConfigV0 struct { FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + UpdateSkill ToolConfig `json:"update_skill" envPrefix:"PICOCLAW_TOOLS_UPDATE_SKILL_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index ba1a5a0cf..f3f82a8c1 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -471,6 +471,9 @@ func DefaultConfig() *Config { InstallSkill: ToolConfig{ Enabled: true, }, + UpdateSkill: ToolConfig{ + Enabled: true, + }, ListDir: ToolConfig{ Enabled: true, }, diff --git a/pkg/tools/skills_update.go b/pkg/tools/skills_update.go new file mode 100644 index 000000000..f544a5fae --- /dev/null +++ b/pkg/tools/skills_update.go @@ -0,0 +1,102 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// UpdateSkillTool allows the LLM agent to analyze its own performance and +// write new skills or improvements into SKILL.md. +type UpdateSkillTool struct { + workspace string + mu sync.Mutex +} + +// NewUpdateSkillTool creates a new UpdateSkillTool. +func NewUpdateSkillTool(workspace string) *UpdateSkillTool { + return &UpdateSkillTool{ + workspace: workspace, + mu: sync.Mutex{}, + } +} + +func (t *UpdateSkillTool) Name() string { + return "update_skill" +} + +func (t *UpdateSkillTool) Description() string { + return "Analyze a completed conversation to extract strengths, weaknesses, and new knowledge, then update the local SKILL.md file to improve future performance. Call this tool after solving a complex problem to learn from it." +} + +func (t *UpdateSkillTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "analysis": map[string]any{ + "type": "string", + "description": "Analysis of the conversation, noting strengths and weaknesses.", + }, + "skills_to_improve": map[string]any{ + "type": "string", + "description": "Specific skills, commands, or knowledge identified that need improvement to solve similar problems faster next time.", + }, + "markdown_content": map[string]any{ + "type": "string", + "description": "The exact markdown content to append to SKILL.md containing the new learned skill or instruction.", + }, + }, + "required": []string{"analysis", "skills_to_improve", "markdown_content"}, + } +} + +func (t *UpdateSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + t.mu.Lock() + defer t.mu.Unlock() + + analysis, _ := args["analysis"].(string) + skillsToImprove, _ := args["skills_to_improve"].(string) + markdownContent, _ := args["markdown_content"].(string) + + if analysis == "" || skillsToImprove == "" || markdownContent == "" { + return ErrorResult("analysis, skills_to_improve, and markdown_content are all required") + } + + skillFilePath := filepath.Join(t.workspace, "SKILL.md") + + // Prepare the content to append + timestamp := time.Now().Format("2006-01-02 15:04:05") + contentToAppend := fmt.Sprintf("\n\n## Learned Skill: %s\n\n**Analysis**: %s\n\n**Skills Improved**: %s\n\n%s\n", + timestamp, + analysis, + skillsToImprove, + markdownContent, + ) + + // Create directory if it doesn't exist (though workspace should exist) + if err := os.MkdirAll(t.workspace, 0o755); err != nil { + return ErrorResult(fmt.Sprintf("failed to create workspace directory: %v", err)) + } + + // Append to file + f, err := os.OpenFile(skillFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open SKILL.md for writing: %v", err)) + } + defer f.Close() + + if _, err := f.WriteString(contentToAppend); err != nil { + return ErrorResult(fmt.Sprintf("failed to append to SKILL.md: %v", err)) + } + + output := fmt.Sprintf("Successfully learned and updated SKILL.md.\n\nAnalysis: %s\nSkills Improved: %s\n", analysis, skillsToImprove) + + // The response is passed back to the LLM. + // We also populate the ForUser field to notify the user. + res := SilentResult(output) + res.ForUser = fmt.Sprintf("I have analyzed our conversation and improved my skills.\n\n**My Analysis**:\n%s\n\n**Skills I've Improved/Added**:\n%s\n\nI have saved these learnings to `SKILL.md`.", analysis, skillsToImprove) + return res +} diff --git a/pkg/tools/skills_update_test.go b/pkg/tools/skills_update_test.go new file mode 100644 index 000000000..004898404 --- /dev/null +++ b/pkg/tools/skills_update_test.go @@ -0,0 +1,69 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateSkillTool_Name(t *testing.T) { + tool := NewUpdateSkillTool(t.TempDir()) + assert.Equal(t, "update_skill", tool.Name()) + assert.NotEmpty(t, tool.Description()) + assert.NotNil(t, tool.Parameters()) +} + +func TestUpdateSkillTool_Execute(t *testing.T) { + workspace := t.TempDir() + tool := NewUpdateSkillTool(workspace) + + ctx := context.Background() + + // Missing args + res := tool.Execute(ctx, map[string]any{}) + assert.True(t, res.IsError) + assert.Contains(t, res.ForLLM, "required") + + // Success case + args := map[string]any{ + "analysis": "I was slow to find the file.", + "skills_to_improve": "Use grep more effectively.", + "markdown_content": "Always use `grep -rn` when searching for strings.", + } + + res = tool.Execute(ctx, args) + assert.False(t, res.IsError) + assert.Contains(t, res.ForLLM, "Successfully learned and updated SKILL.md") + assert.Contains(t, res.ForUser, "I was slow to find the file.") + + // Check file contents + skillFilePath := filepath.Join(workspace, "SKILL.md") + content, err := os.ReadFile(skillFilePath) + assert.NoError(t, err) + + contentStr := string(content) + assert.Contains(t, contentStr, "Learned Skill") + assert.Contains(t, contentStr, "I was slow to find the file.") + assert.Contains(t, contentStr, "Use grep more effectively.") + assert.Contains(t, contentStr, "Always use `grep -rn` when searching for strings.") + + // Test append + args2 := map[string]any{ + "analysis": "Another analysis.", + "skills_to_improve": "Another skill.", + "markdown_content": "Another markdown.", + } + + res2 := tool.Execute(ctx, args2) + assert.False(t, res2.IsError) + + content2, err2 := os.ReadFile(skillFilePath) + assert.NoError(t, err2) + + contentStr2 := string(content2) + assert.Contains(t, contentStr2, "I was slow to find the file.") // Old content still there + assert.Contains(t, contentStr2, "Another analysis.") // New content added +}