Merge pull request #7 from TanLuong/feat/add-update-skill-tool-4324387879818579483

feat: add update_skill tool to dynamically update SKILL.md
This commit is contained in:
Nhat Tan 2026-03-25 16:17:37 +07:00 committed by GitHub
commit 8e1024e47b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 186 additions and 0 deletions

View file

@ -489,6 +489,9 @@
"install_skill": {
"enabled": true
},
"update_skill": {
"enabled": true
},
"list_dir": {
"enabled": true
},

View file

@ -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")

View file

@ -1152,6 +1152,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_"`
@ -2113,6 +2114,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":

View file

@ -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_"`

View file

@ -471,6 +471,9 @@ func DefaultConfig() *Config {
InstallSkill: ToolConfig{
Enabled: true,
},
UpdateSkill: ToolConfig{
Enabled: true,
},
ListDir: ToolConfig{
Enabled: true,
},

102
pkg/tools/skills_update.go Normal file
View file

@ -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
}

View file

@ -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
}