Files
goclaw/internal/gateway/methods/skills.go
T
viettranx b2a74ba487 feat(skills): skill_manage tool + skill_evolve learning loop (#218)
Adds skill_manage — a first-class agent tool for creating, updating, and
deleting skills from within a conversation — paired with per-agent
skill_evolve that nudges predefined agents to capture reusable workflows.

Tool (skill_manage):
- create: write skill from SKILL.md content string (auto-grant, dep scan)
- patch: find/replace producing new immutable version (advisory-locked)
- delete: soft-delete (archive in DB, move to .trash/)
- Security guard: 25 regex patterns block shell injection, credential exfil,
  path traversal, SQL injection, privilege escalation
- Ownership enforced: only skill owner can patch/delete (admin bypass)
- Content size limit: 100KB; companion file copy: 20MB, symlink-safe (WalkDir)
- Enabled: false by default — admin opt-in per agent

Learning loop (skill_evolve, predefined agents only):
- System prompt: SHOULD/SHOULD NOT guidance for skill creation
- Budget nudges: [System] prefix at 70%/90% iteration budget (ephemeral, i18n)
- Postscript: once-per-run suggestion with explicit user consent
- Config: other_config.skill_evolve + skill_nudge_interval (default 15)

Security hardening (pre-existing + new):
- CreateSkillManaged: RETURNING id + pg_advisory_xact_lock (atomic upsert)
- GetNextVersionLocked: advisory lock for race-safe patch versioning
- Ownership checks on HTTP update/delete, gateway update, 4 grant/revoke handlers
- copyOtherFiles: filepath.WalkDir for real symlink detection

UI: Skill Learning toggle + nudge interval in Agent General Tab
i18n: backend (en/vi/zh catalogs) + frontend (en/vi/zh locale files)
2026-03-17 11:38:35 +07:00

194 lines
5.8 KiB
Go

package methods
import (
"context"
"encoding/json"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// skillOwnerGetter is an optional interface for stores that can return a skill's owner ID.
type skillOwnerGetter interface {
GetSkillOwnerID(id uuid.UUID) (string, bool)
}
// SkillsMethods handles skills.list, skills.get, skills.update.
type SkillsMethods struct {
store store.SkillStore
}
func NewSkillsMethods(s store.SkillStore) *SkillsMethods {
return &SkillsMethods{store: s}
}
func (m *SkillsMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodSkillsList, m.handleList)
router.Register(protocol.MethodSkillsGet, m.handleGet)
router.Register(protocol.MethodSkillsUpdate, m.handleUpdate)
}
func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
allSkills := m.store.ListSkills()
result := make([]map[string]any, 0, len(allSkills))
for _, s := range allSkills {
entry := map[string]any{
"name": s.Name,
"slug": s.Slug,
"description": s.Description,
"source": s.Source,
"version": s.Version,
"is_system": s.IsSystem,
"enabled": s.Enabled,
}
if s.ID != "" {
entry["id"] = s.ID
}
if s.Visibility != "" {
entry["visibility"] = s.Visibility
}
if len(s.Tags) > 0 {
entry["tags"] = s.Tags
}
if s.Status != "" {
entry["status"] = s.Status
}
if s.Author != "" {
entry["author"] = s.Author
}
if len(s.MissingDeps) > 0 {
entry["missing_deps"] = s.MissingDeps
}
result = append(result, entry)
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"skills": result,
}))
}
func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
info, ok := m.store.GetSkill(params.Name)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
return
}
content, _ := m.store.LoadSkill(params.Name)
resp := map[string]any{
"name": info.Name,
"slug": info.Slug,
"description": info.Description,
"source": info.Source,
"content": content,
"version": info.Version,
}
if info.ID != "" {
resp["id"] = info.ID
}
if info.Visibility != "" {
resp["visibility"] = info.Visibility
}
if len(info.Tags) > 0 {
resp["tags"] = info.Tags
}
client.SendResponse(protocol.NewOKResponse(req.ID, resp))
}
// skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore).
type skillUpdater interface {
UpdateSkill(id uuid.UUID, updates map[string]any) error
}
func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
ID string `json:"id"`
Updates map[string]any `json:"updates"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.Name == "" && params.ID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name or id")))
return
}
// Check if the store supports updates (PGSkillStore does, FileSkillStore doesn't)
updater, ok := m.store.(skillUpdater)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgSkillsUpdateNotSupported)))
return
}
// Resolve skill ID
var skillID uuid.UUID
if params.ID != "" {
parsed, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "skill")))
return
}
skillID = parsed
} else {
// Look up by name — use GetSkill which returns path info, but we need DB ID
// For PGSkillStore, the name is the slug
info, exists := m.store.GetSkill(params.Name)
if !exists {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
return
}
// Try to parse Path as UUID (PGSkillStore stores DB ID in Path field for managed skills)
parsed, err := uuid.Parse(info.Path)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotResolveSkillID)))
return
}
skillID = parsed
}
if params.Updates == nil || len(params.Updates) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "updates")))
return
}
// Ownership check: only skill owner or admin can update
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
if ownerGetter, ok := m.store.(skillOwnerGetter); ok {
if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
return
}
}
}
if err := updater.UpdateSkill(skillID, params.Updates); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
m.store.BumpVersion()
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
}