diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index 841c4149..0fcd967e 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -105,6 +105,7 @@ func builtinToolSeedData() []store.BuiltinToolDef { {Name: "skill_search", DisplayName: "Skill Search", Description: "Search for available skills by keyword or description to find relevant capabilities", Category: "skills", Enabled: true}, {Name: "use_skill", DisplayName: "Use Skill", Description: "Activate a skill to use its specialized capabilities (tracing marker)", Category: "skills", Enabled: true}, {Name: "publish_skill", DisplayName: "Publish Skill", Description: "Register a skill directory (created via skill-creator) in the system database, making it discoverable and grantable to agents", Category: "skills", Enabled: true}, + {Name: "skill_manage", DisplayName: "Skill Manager", Description: "Create, patch, or delete skills from conversation experience. Admin must enable per agent.", Category: "skills", Enabled: false}, // teams {Name: "team_tasks", DisplayName: "Team Tasks", Description: "View, create, update, and complete tasks on the team task board", Category: "teams", Enabled: true, diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index a795b31d..76b270e0 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -500,6 +500,8 @@ func setupSkillsSystem( if len(storeDirs) > 0 { toolsReg.Register(tools.NewPublishSkillTool(pgSkills, storeDirs[0], skillsLoader)) slog.Info("publish_skill tool registered") + toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], skillsLoader)) + slog.Info("skill_manage tool registered") } } } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index f31bbc4b..4bc866cf 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -16,6 +16,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -430,6 +431,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) var teamTaskCreates int // count of team_tasks action=create calls var teamTaskSpawns int // count of spawn calls with team_task_id + // Skill evolution: budget pressure nudge state (sent at most once each per run). + var skillNudge70Sent, skillNudge90Sent bool + var skillPostscriptSent bool + // Inject retry hook so channels can update placeholder on LLM retries. ctx = providers.WithRetryHook(ctx, func(attempt, maxAttempts int, err error) { emitRun(AgentEvent{ @@ -467,6 +472,26 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) slog.Debug("agent iteration", "agent", l.id, "iteration", iteration, "messages", len(messages)) + // Skill evolution: budget pressure nudges at 70% and 90% of iteration budget. + // Ephemeral (in-memory only, not persisted to session) — LLM sees them during this run only. + if l.skillEvolve && maxIter > 0 { + locale := store.LocaleFromContext(ctx) + iterPct := float64(iteration) / float64(maxIter) + if iterPct >= 0.90 && !skillNudge90Sent { + skillNudge90Sent = true + messages = append(messages, providers.Message{ + Role: "user", + Content: i18n.T(locale, i18n.MsgSkillNudge90Pct), + }) + } else if iterPct >= 0.70 && !skillNudge70Sent { + skillNudge70Sent = true + messages = append(messages, providers.Message{ + Role: "user", + Content: i18n.T(locale, i18n.MsgSkillNudge70Pct), + }) + } + } + // Emit activity event: thinking phase emitRun(AgentEvent{ Type: protocol.AgentEventActivity, @@ -996,6 +1021,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) messages = append(messages, forLLM...) pendingMsgs = append(pendingMsgs, forSession...) } + } // 4. Full sanitization pipeline (matching TS extractAssistantText + sanitizeUserFacingText) @@ -1010,6 +1036,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // filtered at the payload level before delivery. isSilent := IsSilentReply(finalContent) + // 5b. Skill evolution: postscript suggestion after complex tasks. + // Fires when skill_evolve=true AND the run involved enough tool calls to warrant a skill. + // Appended to the agent's own final response so the user sees it inline and can explicitly + // consent ("save as skill") before anything is created. No mid-loop injection, no async + // goroutine, no session contamination — the next user turn naturally triggers skill creation. + if l.skillEvolve && l.skillNudgeInterval > 0 && + totalToolCalls >= l.skillNudgeInterval && + finalContent != "" && !isSilent && !skillPostscriptSent { + skillPostscriptSent = true + locale := store.LocaleFromContext(ctx) + finalContent += "\n\n---\n_" + i18n.T(locale, i18n.MsgSkillNudgePostscript) + "_" + } + // 6. Fallback for empty content if finalContent == "" { if len(asyncToolCalls) > 0 { diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 0ebcf4fb..bd32d241 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -76,6 +76,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s _, hasSpawn := l.tools.Get("spawn") _, hasTeamTools := l.tools.Get("team_tasks") _, hasSkillSearch := l.tools.Get("skill_search") + _, hasSkillManage := l.tools.Get("skill_manage") _, hasMCPToolSearch := l.tools.Get("mcp_tool_search") _, hasKG := l.tools.Get("knowledge_graph_search") @@ -161,6 +162,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s HasTeam: hasTeamTools, TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx), HasSkillSearch: hasSkillSearch, + HasSkillManage: l.skillEvolve && hasSkillManage, HasMCPToolSearch: hasMCPToolSearch, HasKnowledgeGraph: hasKG, MCPToolDescs: mcpToolDescs, diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index edc61ddd..4fe804db 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -111,6 +111,11 @@ type Loop struct { // Self-evolve: predefined agents can update SOUL.md through chat selfEvolve bool + // Skill learning loop: when skillEvolve=true, the loop injects nudges reminding + // the agent to capture reusable patterns as skills via skill_manage. + skillEvolve bool + skillNudgeInterval int // nudge every N tool calls (0 = disabled, 15 = default) + // Group writer cache for system prompt injection groupWriterCache *store.GroupWriterCache @@ -221,6 +226,10 @@ type LoopConfig struct { // Self-evolve: predefined agents can update SOUL.md (style/tone) through chat SelfEvolve bool + // Skill evolution: agent learning loop config (from other_config JSONB) + SkillEvolve bool + SkillNudgeInterval int // 0 = disabled, 15 = default + // Group writer cache for system prompt injection GroupWriterCache *store.GroupWriterCache @@ -317,6 +326,8 @@ func NewLoop(cfg LoopConfig) *Loop { builtinToolSettings: cfg.BuiltinToolSettings, thinkingLevel: cfg.ThinkingLevel, selfEvolve: cfg.SelfEvolve, + skillEvolve: cfg.SkillEvolve, + skillNudgeInterval: cfg.SkillNudgeInterval, groupWriterCache: cfg.GroupWriterCache, teamStore: cfg.TeamStore, secureCLIStore: cfg.SecureCLIStore, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 9bd3a0ab..b1c51560 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -343,6 +343,8 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { BuiltinToolSettings: builtinSettings, ThinkingLevel: ag.ParseThinkingLevel(), SelfEvolve: ag.ParseSelfEvolve(), + SkillEvolve: ag.AgentType == "predefined" && ag.ParseSkillEvolve(), + SkillNudgeInterval: ag.ParseSkillNudgeInterval(), WorkspaceSharing: ag.ParseWorkspaceSharing(), GroupWriterCache: deps.GroupWriterCache, TeamStore: deps.TeamStore, diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 1565761d..7b3296f3 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -39,6 +39,7 @@ type SystemPromptConfig struct { AgentType string // "open" or "predefined" — affects context file framing HasSkillSearch bool // skill_search tool registered? (for search-mode prompt) + HasSkillManage bool // skill_manage tool registered + skill_evolve enabled for this agent HasMCPToolSearch bool // mcp_tool_search tool registered? (MCP search mode) HasKnowledgeGraph bool // knowledge_graph_search tool registered? MCPToolDescs map[string]string // MCP tool name → description (inline mode only) @@ -75,6 +76,8 @@ var coreToolSummaries = map[string]string{ "datetime": "Get current date/time with timezone support — use before creating cron jobs or time-sensitive operations", "cron": "Manage scheduled jobs and reminders", "skill_search": "Search available skills by keyword (weather, translate, github, etc.)", + "skill_manage": "Create, patch, or delete skills from conversation experience", + "publish_skill": "Register a skill directory in the system database, making it discoverable", "use_skill": "Invoke a skill by name and follow its instructions", "mcp_tool_search": "Search for available MCP external integration tools by keyword", "browser": "Browse web pages interactively", @@ -191,8 +194,8 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { // 4. ## Skills (full only) — skip during bootstrap // SkillsSummary non-empty → inline mode (XML list in prompt, TS-style) // SkillsSummary empty + HasSkillSearch → search mode (use skill_search tool) - if !isMinimal && !cfg.IsBootstrap && (cfg.SkillsSummary != "" || cfg.HasSkillSearch) { - lines = append(lines, buildSkillsSection(cfg.SkillsSummary, cfg.HasSkillSearch)...) + if !isMinimal && !cfg.IsBootstrap && (cfg.SkillsSummary != "" || cfg.HasSkillSearch || cfg.HasSkillManage) { + lines = append(lines, buildSkillsSection(cfg.SkillsSummary, cfg.HasSkillSearch, cfg.HasSkillManage)...) } // 4.5. ## MCP Tools (full only) — skip during bootstrap @@ -367,11 +370,13 @@ func buildSelfEvolveSection() []string { } } -func buildSkillsSection(skillsSummary string, hasSkillSearch bool) []string { +func buildSkillsSection(skillsSummary string, hasSkillSearch, hasSkillManage bool) []string { + var lines []string + if skillsSummary != "" { // Inline mode: skills XML is in the prompt (like TS). // Agent scans descriptions directly. - return []string{ + lines = append(lines, "## Skills (mandatory)", "", "Before replying, scan `` below.", @@ -381,12 +386,10 @@ func buildSkillsSection(skillsSummary string, hasSkillSearch bool) []string { "", skillsSummary, "", - } - } - - if hasSkillSearch { + ) + } else if hasSkillSearch { // Search mode: too many skills to inline, agent uses skill_search tool. - return []string{ + lines = append(lines, "## Skills (mandatory)", "", "Before replying, check if a skill applies:", @@ -400,10 +403,45 @@ func buildSkillsSection(skillsSummary string, hasSkillSearch bool) []string { "- Prefer `skill_search` over `browser` or `web_search` when the domain might have a skill.", "- If skill_search returns no results, fall back to other tools freely.", "", - } + ) } - return nil + // Skill creation guidance: shown when skill_evolve=true and skill_manage is registered. + // Add parent ## Skills header if not already present from inline/search modes. + if hasSkillManage { + if skillsSummary == "" && !hasSkillSearch { + lines = append(lines, "## Skills", "") + } + lines = append(lines, + "### Skill Creation (recommended after complex tasks)", + "", + "After completing a complex task (5+ tool calls), consider:", + "\"Would this process be useful again in the future?\"", + "", + "SHOULD create skill when:", + "- Process is repeatable with different inputs", + "- Multiple steps that are easy to forget", + "- Domain-specific workflow others could benefit from", + "", + "SHOULD NOT create skill when:", + "- One-time task specific to this user/context", + "- Debugging or troubleshooting (too context-dependent)", + "- Simple tasks (< 5 tool calls)", + "- User explicitly said \"skip\" or declined", + "", + "Creating: `skill_manage(action=\"create\", content=\"---\\nname: ...\\nslug: ...\\ndescription: ...\\n---\\n# ...\")`", + "Improving: `skill_manage(action=\"patch\", slug=\"...\", find=\"...\", replace=\"...\")`", + "Removing: `skill_manage(action=\"delete\", slug=\"...\")`", + "", + "Constraints:", + "- You can only manage skills you created (not system or other users' skills)", + "- Quality over quantity — one excellent skill beats five mediocre ones", + "- Ask user before creating if unsure", + "", + ) + } + + return lines } func buildWorkspaceSection(workspace string, sandboxEnabled bool, containerDir string) []string { diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go index 93936146..a96b1062 100644 --- a/internal/gateway/methods/skills.go +++ b/internal/gateway/methods/skills.go @@ -8,10 +8,16 @@ import ( "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 @@ -166,6 +172,16 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client 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 diff --git a/internal/http/skills.go b/internal/http/skills.go index eedd14c8..38f0c75b 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -8,6 +8,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" @@ -91,6 +92,16 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { return } + // Ownership check (admins bypass) + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + userID := store.UserIDFromContext(r.Context()) + if ownerID, found := h.skills.GetSkillOwnerID(id); found && ownerID != userID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) + return + } + } + var updates map[string]any if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) @@ -122,6 +133,16 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { return } + // Ownership check (admins bypass) + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + userID := store.UserIDFromContext(r.Context()) + if ownerID, found := h.skills.GetSkillOwnerID(id); found && ownerID != userID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) + return + } + } + if err := h.skills.DeleteSkill(id); err != nil { if err.Error() == "cannot delete system skill" { writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete system skill"}) diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go index cfd5864c..3943144d 100644 --- a/internal/http/skills_grants.go +++ b/internal/http/skills_grants.go @@ -11,6 +11,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -43,6 +44,15 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) return } + // Ownership check (admins bypass) + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) + return + } + } + var req struct { AgentID string `json:"agent_id"` Version int `json:"version"` @@ -82,6 +92,16 @@ func (h *SkillsHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request return } + // Ownership check (admins bypass) + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + userID := store.UserIDFromContext(r.Context()) + if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) + return + } + } + agentIDStr := r.PathValue("agentID") agentID, err := uuid.Parse(agentIDStr) if err != nil { @@ -110,6 +130,15 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) return } + // Ownership check (admins bypass) + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) + return + } + } + var req struct { UserID string `json:"user_id"` } @@ -146,6 +175,16 @@ func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) return } + // Ownership check (admins bypass) + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + userID := store.UserIDFromContext(r.Context()) + if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) + return + } + } + targetUserID := r.PathValue("userID") if err := store.ValidateUserID(targetUserID); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 7c1e4dd7..870f37c5 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -169,7 +169,13 @@ func init() { MsgToolSpawn: "Spawn a subagent for background work or delegate a task to a linked agent", MsgToolSkillSearch: "Search for available skills by keyword or description to find relevant capabilities", MsgToolUseSkill: "Activate a skill to use its specialized capabilities (tracing marker)", + MsgToolSkillManage: "Create, patch, or delete skills from conversation experience", + MsgToolPublishSkill: "Register a skill directory in the system database, making it discoverable", MsgToolTeamTasks: "View, create, update, and complete tasks on the team task board", MsgToolTeamMessage: "Send a direct message or broadcast to teammates in the agent team", + + MsgSkillNudgePostscript: "This task involved several steps. Want me to save the process as a reusable skill? Reply **\"save as skill\"** or **\"skip\"**.", + MsgSkillNudge70Pct: "[System] You are at 70% of your iteration budget. Consider whether any patterns from this session would make a good skill.", + MsgSkillNudge90Pct: "[System] You are at 90% of your iteration budget. If this session involved reusable patterns, consider saving them as a skill before completing.", }) } diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index 1322b121..98637ece 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -169,7 +169,13 @@ func init() { MsgToolSpawn: "Tạo subagent chạy nền hoặc giao việc cho agent đã liên kết", MsgToolSkillSearch: "Tìm kiếm kỹ năng có sẵn theo từ khóa hoặc mô tả", MsgToolUseSkill: "Kích hoạt kỹ năng để sử dụng khả năng chuyên biệt (đánh dấu tracing)", + MsgToolSkillManage: "Tạo, sửa hoặc xóa kỹ năng từ trải nghiệm hội thoại", + MsgToolPublishSkill: "Đăng ký thư mục kỹ năng vào hệ thống, cho phép tìm kiếm và cấp quyền", MsgToolTeamTasks: "Xem, tạo, cập nhật và hoàn thành tác vụ trên bảng tác vụ nhóm", MsgToolTeamMessage: "Gửi tin nhắn trực tiếp hoặc broadcast đến đồng đội trong nhóm agent", + + MsgSkillNudgePostscript: "Tác vụ này cần nhiều bước. Bạn muốn tôi lưu quy trình này thành kỹ năng tái sử dụng không? Trả lời **\"lưu kỹ năng\"** hoặc **\"bỏ qua\"**.", + MsgSkillNudge70Pct: "[System] Bạn đã dùng 70% ngân sách vòng lặp. Cân nhắc xem các mẫu trong phiên này có nên lưu thành kỹ năng không.", + MsgSkillNudge90Pct: "[System] Bạn đã dùng 90% ngân sách vòng lặp. Nếu phiên này có quy trình tái sử dụng, hãy cân nhắc lưu thành kỹ năng trước khi hoàn thành.", }) } diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index edfcdfef..f49c90cd 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -169,7 +169,13 @@ func init() { MsgToolSpawn: "创建子代理执行后台工作或将任务委派给已链接的代理", MsgToolSkillSearch: "按关键字或描述搜索可用技能以查找相关功能", MsgToolUseSkill: "激活技能以使用其专门功能(追踪标记)", + MsgToolSkillManage: "从对话经验中创建、修补或删除技能", + MsgToolPublishSkill: "将技能目录注册到系统数据库中,使其可被发现和授权", MsgToolTeamTasks: "查看、创建、更新和完成团队任务板上的任务", MsgToolTeamMessage: "向代理团队中的队友发送直接消息或广播", + + MsgSkillNudgePostscript: "此任务涉及多个步骤。要我将此过程保存为可重用技能吗?回复 **\"保存技能\"** 或 **\"跳过\"**。", + MsgSkillNudge70Pct: "[System] 您已使用 70% 的迭代预算。请考虑本次会话中的模式是否值得保存为技能。", + MsgSkillNudge90Pct: "[System] 您已使用 90% 的迭代预算。如果本次会话涉及可重用的模式,请考虑在完成前将其保存为技能。", }) } diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index 5ec3f0c7..31aaf5dc 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -170,6 +170,13 @@ const ( MsgToolSpawn = "core.tool.spawn" MsgToolSkillSearch = "core.tool.skill_search" MsgToolUseSkill = "core.tool.use_skill" + MsgToolSkillManage = "core.tool.skill_manage" + MsgToolPublishSkill = "core.tool.publish_skill" MsgToolTeamTasks = "core.tool.team_tasks" MsgToolTeamMessage = "core.tool.team_message" + + // Skill evolution nudges (user-facing) + MsgSkillNudgePostscript = "skill.nudge_postscript" + MsgSkillNudge70Pct = "skill.nudge_70_pct" + MsgSkillNudge90Pct = "skill.nudge_90_pct" ) diff --git a/internal/skills/guard.go b/internal/skills/guard.go new file mode 100644 index 00000000..0948a37e --- /dev/null +++ b/internal/skills/guard.go @@ -0,0 +1,102 @@ +package skills + +import ( + "fmt" + "regexp" + "strings" +) + +// GuardViolation describes a security pattern match in SKILL.md content. +type GuardViolation struct { + Pattern string + Reason string + Line int +} + +// guardRule pairs a compiled regexp with a human-readable rejection reason. +type guardRule struct { + re *regexp.Regexp + reason string +} + +// skillGuardRules contains high-signal security patterns for SKILL.md content. +// Focus: shell injection, destructive ops, obfuscated payloads, credential exfil, +// path traversal, SQL injection, privilege escalation. +// +// Not exhaustive — GoClaw's exec tool has its own runtime deny-list. +// This scanner runs BEFORE the file is written, blocking poisoned skills at creation time. +var skillGuardRules = []guardRule{ + // --- Destructive shell operations --- + {regexp.MustCompile(`(?i)rm\s+-rf\s+[/~]`), "destructive rm -rf on root/home"}, + {regexp.MustCompile(`:\s*\|\s*:\s*&`), "fork bomb (self-pipe-to-background pattern)"}, + {regexp.MustCompile(`(?i)\bdd\b.+\bof=/dev/`), "disk overwrite via dd"}, + {regexp.MustCompile(`(?i)\bmkfs\b`), "filesystem format command"}, + {regexp.MustCompile(`(?i)\bshred\s`), "file shredding command"}, + + // --- Code injection / obfuscation --- + {regexp.MustCompile(`(?i)base64\s+(--decode|-d)\s*\|`), "base64 decode piped to shell"}, + {regexp.MustCompile(`(?i)eval\s*\(\s*base64`), "eval of base64-encoded payload"}, + {regexp.MustCompile(`(?i)eval\s*\$\s*\(`), "eval of subshell output"}, + {regexp.MustCompile(`(?i)curl\s+\S+\s*\|\s*(ba)?sh`), "curl pipe to shell"}, + {regexp.MustCompile(`(?i)wget\s+\S+\s*\|\s*(ba)?sh`), "wget pipe to shell"}, + {regexp.MustCompile(`(?i)python[23]?\s+-c\s+.{0,40}exec\s*\(`), "python exec injection"}, + {regexp.MustCompile(`(?i)__import__\s*\(\s*['"]os['"]`), "Python os import obfuscation"}, + + // --- Credential / data exfiltration --- + {regexp.MustCompile(`(?i)/etc/passwd\b`), "system password file access"}, + {regexp.MustCompile(`(?i)/etc/shadow\b`), "shadow password file access"}, + {regexp.MustCompile(`(?i)\.ssh/id_rsa`), "private SSH key access"}, + {regexp.MustCompile(`(?i)AWS_SECRET_ACCESS_KEY`), "AWS credential reference"}, + {regexp.MustCompile(`(?i)GOCLAW_DB_URL\b`), "GoClaw database credential"}, + {regexp.MustCompile(`(?i)(curl|wget)\s+\S+.*\$\{?(HOME|USER|PASS|KEY|SECRET|TOKEN)`), "env var exfiltration via HTTP"}, + + // --- Path traversal --- + {regexp.MustCompile(`\.\./\.\./\.\./`), "deep path traversal (../../..)"}, + + // --- SQL injection --- + {regexp.MustCompile(`(?i)\bDROP\s+TABLE\b`), "SQL DROP TABLE"}, + {regexp.MustCompile(`(?i)\bTRUNCATE\s+TABLE\b`), "SQL TRUNCATE TABLE"}, + {regexp.MustCompile(`(?i)\bDROP\s+DATABASE\b`), "SQL DROP DATABASE"}, + + // --- Privilege escalation --- + {regexp.MustCompile(`(?i)\bsudo\s`), "sudo usage"}, + {regexp.MustCompile(`(?i)\bchmod\s+[0-7]*[2367]\b`), "world-writable chmod (others write bit set)"}, + {regexp.MustCompile(`(?i)\bchown\s+root\b`), "chown to root"}, +} + +// GuardSkillContent scans SKILL.md content for security violations before any disk write. +// Returns (violations, safe). safe=true means no violations found and the content may be written. +// +// Scanning is line-by-line: one violation per line (first matching rule wins). +// Hard-reject on ANY violation — no partial allow. +func GuardSkillContent(content string) ([]GuardViolation, bool) { + lines := strings.Split(content, "\n") + var violations []GuardViolation + + for lineNum, line := range lines { + for _, rule := range skillGuardRules { + if rule.re.MatchString(line) { + violations = append(violations, GuardViolation{ + Pattern: rule.re.String(), + Reason: rule.reason, + Line: lineNum + 1, + }) + break // one violation per line is sufficient + } + } + } + + return violations, len(violations) == 0 +} + +// FormatGuardViolations returns a human-readable rejection message suitable +// for returning to the LLM as a tool error result. +func FormatGuardViolations(violations []GuardViolation) string { + var sb strings.Builder + sb.WriteString("Skill content rejected by security scanner. Violations:\n") + for _, v := range violations { + sb.WriteString(fmt.Sprintf(" - Line %d: %s\n", v.Line, v.Reason)) + } + sb.WriteString("\nRemove the flagged content and try again.") + return sb.String() +} diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 4baee4b5..605e1858 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -169,6 +169,41 @@ func (a *AgentData) ParseSelfEvolve() bool { return cfg.SelfEvolve } +// ParseSkillEvolve extracts skill_evolve from other_config JSONB. +// When true, the agent's learning loop is enabled: system prompt includes skill +// creation guidance, and the loop injects nudges at tool count milestones. +func (a *AgentData) ParseSkillEvolve() bool { + if len(a.OtherConfig) == 0 { + return false + } + var cfg struct { + SkillEvolve bool `json:"skill_evolve"` + } + if json.Unmarshal(a.OtherConfig, &cfg) != nil { + return false + } + return cfg.SkillEvolve +} + +// ParseSkillNudgeInterval extracts skill_nudge_interval from other_config JSONB. +// Returns the interval (in tool calls) at which the loop injects a skill creation reminder. +// Default 15 when not set. Explicitly 0 disables mid-loop nudges (system prompt guidance still shown). +func (a *AgentData) ParseSkillNudgeInterval() int { + if len(a.OtherConfig) == 0 { + return 15 + } + var cfg struct { + SkillNudgeInterval *int `json:"skill_nudge_interval"` + } + if json.Unmarshal(a.OtherConfig, &cfg) != nil { + return 15 + } + if cfg.SkillNudgeInterval == nil { + return 15 + } + return *cfg.SkillNudgeInterval +} + // WorkspaceSharingConfig controls per-user workspace isolation. // When shared_dm/shared_group is true, users share the base workspace directory // instead of each getting an isolated subfolder. diff --git a/internal/store/pg/skills_content.go b/internal/store/pg/skills_content.go index d6355c1a..5a612a24 100644 --- a/internal/store/pg/skills_content.go +++ b/internal/store/pg/skills_content.go @@ -150,6 +150,28 @@ func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) { return info, true } +// GetSkillOwnerID returns the owner_id for a skill by UUID. +// Returns ("", false) if the skill does not exist. +func (s *PGSkillStore) GetSkillOwnerID(id uuid.UUID) (string, bool) { + var ownerID string + err := s.db.QueryRow("SELECT owner_id FROM skills WHERE id = $1", id).Scan(&ownerID) + if err != nil { + return "", false + } + return ownerID, true +} + +// GetSkillOwnerIDBySlug returns the owner_id for a skill by slug. +// Returns ("", false) if the skill does not exist or is archived. +func (s *PGSkillStore) GetSkillOwnerIDBySlug(slug string) (string, bool) { + var ownerID string + err := s.db.QueryRow("SELECT owner_id FROM skills WHERE slug = $1 AND status = 'active'", slug).Scan(&ownerID) + if err != nil { + return "", false + } + return ownerID, true +} + func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir string) store.SkillInfo { d := "" if desc != nil { diff --git a/internal/store/pg/skills_crud.go b/internal/store/pg/skills_crud.go index 91eee0ad..5397a525 100644 --- a/internal/store/pg/skills_crud.go +++ b/internal/store/pg/skills_crud.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "hash/fnv" "github.com/google/uuid" @@ -84,12 +85,24 @@ func (s *PGSkillStore) DeleteSkill(id uuid.UUID) error { return nil } -// CreateSkillManaged creates a skill from upload parameters. +// slugAdvisoryLock returns a stable int64 lock key derived from a slug string, +// suitable for use with pg_advisory_xact_lock. +func slugAdvisoryLock(slug string) int64 { + h := fnv.New64a() + h.Write([]byte(slug)) + return int64(h.Sum64()) +} + +// CreateSkillManaged creates or updates a skill from upload parameters. +// It uses a transaction with an advisory lock on the slug to prevent concurrent +// callers from racing on version calculation and upsert. +// The RETURNING id clause ensures the actual row ID is returned (new insert or +// existing row on conflict), so callers always receive a valid ID. func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreateParams) (uuid.UUID, error) { if err := store.ValidateUserID(p.OwnerID); err != nil { return uuid.Nil, err } - id := store.GenNewID() + // Marshal frontmatter to JSON for DB storage fmJSON := []byte("{}") if len(p.Frontmatter) > 0 { @@ -97,7 +110,31 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara fmJSON = b } } - _, err := s.db.ExecContext(ctx, + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return uuid.Nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + // Acquire advisory lock scoped to this transaction so concurrent calls for + // the same slug serialize version calculation and the upsert atomically. + if _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock($1)", slugAdvisoryLock(p.Slug)); err != nil { + return uuid.Nil, fmt.Errorf("advisory lock: %w", err) + } + + // Compute next version atomically under the lock. + var version int + if err := tx.QueryRowContext(ctx, + "SELECT COALESCE(MAX(version), 0) + 1 FROM skills WHERE slug = $1", + p.Slug, + ).Scan(&version); err != nil { + return uuid.Nil, fmt.Errorf("get next version: %w", err) + } + + id := store.GenNewID() + var returnedID uuid.UUID + err = tx.QueryRowContext(ctx, `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW()) ON CONFLICT (slug) DO UPDATE SET @@ -106,20 +143,28 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara file_path = EXCLUDED.file_path, file_size = EXCLUDED.file_size, file_hash = EXCLUDED.file_hash, visibility = CASE WHEN skills.status = 'archived' THEN 'private' ELSE skills.visibility END, - status = 'active', updated_at = NOW()`, - id, p.Name, p.Slug, p.Description, p.OwnerID, p.Visibility, p.Version, + status = 'active', updated_at = NOW() + RETURNING id`, + id, p.Name, p.Slug, p.Description, p.OwnerID, p.Visibility, version, fmJSON, p.FilePath, p.FileSize, p.FileHash, - ) - if err == nil { - s.BumpVersion() - // Generate embedding asynchronously - desc := "" - if p.Description != nil { - desc = *p.Description - } - go s.generateEmbedding(context.Background(), p.Slug, p.Name, desc) + ).Scan(&returnedID) + if err != nil { + return uuid.Nil, fmt.Errorf("upsert skill: %w", err) } - return id, err + + if err := tx.Commit(); err != nil { + return uuid.Nil, fmt.Errorf("commit: %w", err) + } + + s.BumpVersion() + // Generate embedding asynchronously + desc := "" + if p.Description != nil { + desc = *p.Description + } + go s.generateEmbedding(context.Background(), p.Slug, p.Name, desc) + + return returnedID, nil } // GetSkillFilePath returns the filesystem path and version for a skill by UUID. @@ -131,12 +176,38 @@ func (s *PGSkillStore) GetSkillFilePath(id uuid.UUID) (filePath string, slug str } // GetNextVersion returns the next version number for a skill slug. +// NOTE: This function has an inherent race condition — two concurrent callers +// for the same slug can receive the same version number. Use it only for +// informational purposes (e.g. display). For write paths, use CreateSkillManaged +// which computes the version atomically under a pg_advisory_xact_lock. func (s *PGSkillStore) GetNextVersion(slug string) int { var maxVersion int s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = $1", slug).Scan(&maxVersion) return maxVersion + 1 } +// GetNextVersionLocked computes the next version atomically using an advisory lock. +// Safe for concurrent write paths (patch, create). Returns version and a cleanup func +// that MUST be called to release the lock (commits the transaction). +func (s *PGSkillStore) GetNextVersionLocked(ctx context.Context, slug string) (int, func() error, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, nil, fmt.Errorf("begin tx: %w", err) + } + if _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock($1)", slugAdvisoryLock(slug)); err != nil { + tx.Rollback() + return 0, nil, fmt.Errorf("advisory lock: %w", err) + } + var version int + if err := tx.QueryRowContext(ctx, + "SELECT COALESCE(MAX(version), 0) + 1 FROM skills WHERE slug = $1", slug, + ).Scan(&version); err != nil { + tx.Rollback() + return 0, nil, fmt.Errorf("get next version: %w", err) + } + return version, func() error { return tx.Commit() }, nil +} + // ToggleSkill enables or disables a skill by UUID. func (s *PGSkillStore) ToggleSkill(id uuid.UUID, enabled bool) error { _, err := s.db.Exec( diff --git a/internal/tools/skill_manage.go b/internal/tools/skill_manage.go new file mode 100644 index 00000000..040c29ab --- /dev/null +++ b/internal/tools/skill_manage.go @@ -0,0 +1,408 @@ +package tools + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// SkillManageTool provides agent-driven skill lifecycle management. +// Complements publish_skill (directory-based) with a content-string interface +// so agents can create/patch/delete skills without pre-writing files to disk. +type SkillManageTool struct { + skills *pg.PGSkillStore + base string // skills-store/ base directory + loader *skills.Loader // cache invalidation +} + +func NewSkillManageTool(skills *pg.PGSkillStore, baseDir string, loader *skills.Loader) *SkillManageTool { + return &SkillManageTool{skills: skills, base: baseDir, loader: loader} +} + +func (t *SkillManageTool) Name() string { return "skill_manage" } + +func (t *SkillManageTool) Description() string { + return "Create, patch, or delete your own skills from content strings. " + + "action=create: write a new skill from SKILL.md content (content string, no directory needed). " + + "action=patch: update an existing skill via find/replace (creates new immutable version). " + + "action=delete: archive a skill so it is no longer discoverable. " + + "Security scanner rejects dangerous patterns. You can only manage skills you own." +} + +func (t *SkillManageTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"create", "patch", "delete"}, + "description": "Operation to perform on the skill.", + }, + "slug": map[string]any{ + "type": "string", + "description": "Unique skill identifier (lowercase alphanumeric + hyphens). Required for patch/delete. For create: auto-derived from 'name' frontmatter field if omitted.", + }, + "content": map[string]any{ + "type": "string", + "description": "Full SKILL.md content including YAML frontmatter (---\\nname: ...\\n---\\n# ...). Required for create.", + }, + "find": map[string]any{ + "type": "string", + "description": "Exact text to find in the current SKILL.md. Required for patch.", + }, + "replace": map[string]any{ + "type": "string", + "description": "Replacement text. Required for patch.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *SkillManageTool) Execute(ctx context.Context, args map[string]any) *Result { + action, _ := args["action"].(string) + switch action { + case "create": + return t.executeCreate(ctx, args) + case "patch": + return t.executePatch(ctx, args) + case "delete": + return t.executeDelete(ctx, args) + default: + return ErrorResult("action must be one of: create, patch, delete") + } +} + +// maxSkillContentSize limits SKILL.md content to 100KB to prevent abuse. +const maxSkillContentSize = 100 * 1024 + +// executeCreate writes a new skill from a SKILL.md content string. +func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any) *Result { + content, _ := args["content"].(string) + if strings.TrimSpace(content) == "" { + return ErrorResult("content is required for action=create") + } + if len(content) > maxSkillContentSize { + return ErrorResult(fmt.Sprintf("content too large (%d bytes, max %d)", len(content), maxSkillContentSize)) + } + + // Security scan before any disk write + violations, safe := skills.GuardSkillContent(content) + if !safe { + return ErrorResult(skills.FormatGuardViolations(violations)) + } + + // Parse frontmatter + name, description, slug, frontmatter := skills.ParseSkillFrontmatter(content) + if name == "" { + return ErrorResult("SKILL.md frontmatter must contain 'name' field") + } + + // Allow slug override from args + if argSlug, _ := args["slug"].(string); argSlug != "" { + slug = argSlug + } + if slug == "" { + slug = skills.Slugify(name) + } + if !skills.SlugRegexp.MatchString(slug) { + return ErrorResult(fmt.Sprintf("invalid slug %q: must be lowercase alphanumeric with hyphens", slug)) + } + if t.skills.IsSystemSkill(slug) { + return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug)) + } + + // Version + destination + version := t.skills.GetNextVersion(slug) + destDir := filepath.Join(t.base, slug, fmt.Sprintf("%d", version)) + if err := os.MkdirAll(destDir, 0755); err != nil { + return ErrorResult(fmt.Sprintf("failed to create skill directory: %v", err)) + } + + // Write SKILL.md + contentBytes := []byte(content) + skillPath := filepath.Join(destDir, "SKILL.md") + if err := os.WriteFile(skillPath, contentBytes, 0644); err != nil { + return ErrorResult(fmt.Sprintf("failed to write SKILL.md: %v", err)) + } + + // Hash + size + hasher := sha256.New() + hasher.Write(contentBytes) + fileHash := fmt.Sprintf("%x", hasher.Sum(nil)) + fileSize := int64(len(contentBytes)) + + // DB insert + userID := store.UserIDFromContext(ctx) + if userID == "" { + userID = "system" + } + desc := description + id, err := t.skills.CreateSkillManaged(ctx, pg.SkillCreateParams{ + Name: name, + Slug: slug, + Description: &desc, + OwnerID: userID, + Visibility: "private", + Version: version, + FilePath: destDir, + FileSize: fileSize, + FileHash: &fileHash, + Frontmatter: frontmatter, + }) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to register skill: %v", err)) + } + + slog.Info("skill_manage: created", "id", id, "slug", slug, "version", version, "owner", userID) + + // Auto-grant to calling agent + granted := false + agentID := store.AgentIDFromContext(ctx) + if agentID != uuid.Nil { + if err := t.skills.GrantToAgent(ctx, id, agentID, version, userID); err != nil { + slog.Warn("skill_manage: auto-grant failed", "error", err) + } else { + granted = true + } + } + + if t.loader != nil { + t.loader.BumpVersion() + } + + // Dep scan (best-effort, warn only) + var depsWarning string + manifest := skills.ScanSkillDeps(destDir) + if manifest != nil && !manifest.IsEmpty() { + ok, missing := skills.CheckSkillDeps(manifest) + if !ok { + _ = t.skills.StoreMissingDeps(id, missing) + depsWarning = skills.FormatMissing(missing) + } + } + + result := fmt.Sprintf("Skill %q created.\n- Slug: %s\n- Version: %d", name, slug, version) + if granted { + result += "\n- Granted to current agent" + } + result += "\n\nSkill will appear in search on next turn." + if depsWarning != "" { + result += fmt.Sprintf("\n\n⚠ Missing dependencies: %s", depsWarning) + } + return NewResult(result) +} + +// executePatch applies a find/replace to the latest version and saves as a new version. +func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) *Result { + slug, _ := args["slug"].(string) + find, _ := args["find"].(string) + replace, _ := args["replace"].(string) + if slug == "" { + return ErrorResult("slug is required for action=patch") + } + if find == "" { + return ErrorResult("find is required for action=patch") + } + + info, ok := t.skills.GetSkill(slug) + if !ok { + return ErrorResult(fmt.Sprintf("skill %q not found or archived", slug)) + } + if t.skills.IsSystemSkill(slug) { + return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug)) + } + + // Ownership check: only the skill owner can patch + userID := store.UserIDFromContext(ctx) + if ownerID, found := t.skills.GetSkillOwnerIDBySlug(slug); found && ownerID != userID { + return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug)) + } + + // Read current SKILL.md from latest version + current, err := os.ReadFile(info.Path) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read current SKILL.md: %v", err)) + } + + patched := strings.Replace(string(current), find, replace, 1) + if patched == string(current) { + return NewResult("no change: find text not found in current SKILL.md") + } + + // Security scan on patched content + violations, safe := skills.GuardSkillContent(patched) + if !safe { + return ErrorResult(skills.FormatGuardViolations(violations)) + } + + oldVer := info.Version + newVer, commitLock, lockErr := t.skills.GetNextVersionLocked(ctx, slug) + if lockErr != nil { + return ErrorResult(fmt.Sprintf("failed to lock version: %v", lockErr)) + } + defer commitLock() //nolint:errcheck + destDir := filepath.Join(t.base, slug, fmt.Sprintf("%d", newVer)) + if err := os.MkdirAll(destDir, 0755); err != nil { + return ErrorResult(fmt.Sprintf("failed to create new version directory: %v", err)) + } + + // Write patched SKILL.md + patchedBytes := []byte(patched) + if err := os.WriteFile(filepath.Join(destDir, "SKILL.md"), patchedBytes, 0644); err != nil { + return ErrorResult(fmt.Sprintf("failed to write patched SKILL.md: %v", err)) + } + + // Copy any companion files from old version (scripts, assets, etc.) + if err := copyOtherFiles(info.BaseDir, destDir); err != nil { + slog.Warn("skill_manage: failed to copy companion files", "error", err) + } + + // Hash + size + hasher := sha256.New() + hasher.Write(patchedBytes) + fileHash := fmt.Sprintf("%x", hasher.Sum(nil)) + fileSize := int64(len(patchedBytes)) + + // DB update + skillID, err := uuid.Parse(info.ID) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err)) + } + if err := t.skills.UpdateSkill(skillID, map[string]any{ + "version": newVer, + "file_path": destDir, + "file_size": fileSize, + "file_hash": &fileHash, + "updated_at": time.Now(), + }); err != nil { + return ErrorResult(fmt.Sprintf("failed to update skill in database: %v", err)) + } + + slog.Info("skill_manage: patched", "slug", slug, "old_version", oldVer, "new_version", newVer) + + if t.loader != nil { + t.loader.BumpVersion() + } + + return NewResult(fmt.Sprintf("Skill %q patched. v%d → v%d. Changes active next turn.", slug, oldVer, newVer)) +} + +// executeDelete archives a skill in the DB and moves its directory to .trash/. +func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any) *Result { + slug, _ := args["slug"].(string) + if slug == "" { + return ErrorResult("slug is required for action=delete") + } + + info, ok := t.skills.GetSkill(slug) + if !ok { + return ErrorResult(fmt.Sprintf("skill %q not found or already archived", slug)) + } + if t.skills.IsSystemSkill(slug) { + return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug)) + } + + // Ownership check: only the skill owner can delete + deleteUserID := store.UserIDFromContext(ctx) + if ownerID, found := t.skills.GetSkillOwnerIDBySlug(slug); found && ownerID != deleteUserID { + return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug)) + } + + // Soft-delete on disk: move to .trash/. + trashDir := filepath.Join(t.base, ".trash") + if err := os.MkdirAll(trashDir, 0755); err != nil { + slog.Warn("skill_manage: failed to create .trash dir", "error", err) + } else { + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + src := filepath.Join(t.base, slug) + dst := filepath.Join(trashDir, slug+"."+timestamp) + if err := os.Rename(src, dst); err != nil { + // Cross-device rename fails on some setups — log and continue (DB archive is primary) + slog.Warn("skill_manage: disk move to .trash failed", "slug", slug, "error", err) + } + } + + // DB archive + skillID, err := uuid.Parse(info.ID) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err)) + } + if err := t.skills.DeleteSkill(skillID); err != nil { + return ErrorResult(fmt.Sprintf("failed to archive skill in database: %v", err)) + } + + slog.Info("skill_manage: deleted", "slug", slug, "id", info.ID) + + if t.loader != nil { + t.loader.BumpVersion() + } + + return NewResult(fmt.Sprintf("Skill %q archived and removed from search.", slug)) +} + +// maxCopySize limits total companion file copy to 20MB (matching publish_skill). +const maxCopySize = 20 << 20 + +// copyOtherFiles copies all files from srcDir to dstDir except SKILL.md. +// Used by patch to carry companion files (scripts, assets) into the new version directory. +// Uses WalkDir (not Walk) so symlinks are detected via DirEntry.Type() before Stat follows them. +// Enforces a 20MB total size limit. +func copyOtherFiles(srcDir, dstDir string) error { + var totalSize int64 + return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + // Skip symlinks — WalkDir exposes the raw type before following + if d.Type()&os.ModeSymlink != 0 { + return nil + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + if rel == "." || rel == "SKILL.md" { + return nil + } + // Skip path traversal attempts + if strings.Contains(rel, "..") { + return nil + } + if d.IsDir() { + return os.MkdirAll(filepath.Join(dstDir, rel), 0755) + } + fi, err := d.Info() + if err != nil { + return err + } + totalSize += fi.Size() + if totalSize > maxCopySize { + return fmt.Errorf("companion files exceed %d bytes limit", maxCopySize) + } + src, err := os.Open(path) + if err != nil { + return err + } + defer src.Close() + dst, err := os.Create(filepath.Join(dstDir, rel)) + if err != nil { + return err + } + defer dst.Close() + _, err = io.Copy(dst, src) + return err + }) +} diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index f2cc4508..7f48644d 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -100,6 +100,12 @@ "selfEvolutionLabel": "Allow agent to evolve its communication style", "selfEvolutionHint": "When enabled, the agent can update its SOUL.md to refine tone, vocabulary, and response style based on interactions. Identity, name, and operating instructions remain locked.", "selfEvolutionInfo": "Agent will evolve its style over time through SOUL.md updates. Only style and tone are affected — identity and workflow rules stay fixed.", + "skillLearning": "Skill Learning", + "skillLearningLabel": "Allow agent to create and manage skills from experience", + "skillLearningHint": "When enabled, the agent can capture reusable workflows as skills after complex tasks. The agent will be gently reminded to save useful patterns. Requires the skill_manage tool to be enabled.", + "skillLearningInfo": "After complex tasks (5+ tool calls), the agent may suggest saving the workflow as a reusable skill. User approval is required before any skill is created. Skills are private to the owner by default.", + "skillNudgeIntervalLabel": "Nudge after N tool calls", + "skillNudgeIntervalHint": "After this many tool calls, the agent will suggest saving the workflow as a skill. Set to 0 to disable post-task suggestions (system prompt guidance still shown).", "budget": "Monthly Budget", "budgetLabel": "Budget limit (USD)", "budgetHint": "Optional monthly spending cap. Agent will reject requests when exceeded. Leave empty for unlimited.", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 7b6f0b17..204cafe8 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -100,6 +100,12 @@ "selfEvolutionLabel": "Cho phép agent tiến hóa phong cách giao tiếp", "selfEvolutionHint": "Khi được bật, agent có thể cập nhật SOUL.md để tinh chỉnh giọng điệu, từ vựng và phong cách phản hồi dựa trên tương tác. Nhận dạng, tên và hướng dẫn hoạt động vẫn bị khóa.", "selfEvolutionInfo": "Agent sẽ tiến hóa phong cách theo thời gian qua các cập nhật SOUL.md. Chỉ phong cách và giọng điệu bị ảnh hưởng — nhận dạng và quy tắc quy trình làm việc vẫn cố định.", + "skillLearning": "Học kỹ năng", + "skillLearningLabel": "Cho phép agent tạo và quản lý kỹ năng từ trải nghiệm", + "skillLearningHint": "Khi được bật, agent có thể lưu lại quy trình làm việc tái sử dụng thành kỹ năng sau các tác vụ phức tạp. Agent sẽ được nhắc nhẹ để lưu các mẫu hữu ích. Cần bật tool skill_manage.", + "skillLearningInfo": "Sau các tác vụ phức tạp (5+ lệnh tool), agent có thể đề xuất lưu quy trình thành kỹ năng tái sử dụng. Cần sự đồng ý của người dùng trước khi tạo kỹ năng. Kỹ năng mặc định là riêng tư.", + "skillNudgeIntervalLabel": "Nhắc sau N lệnh tool", + "skillNudgeIntervalHint": "Sau số lượng lệnh tool này, agent sẽ đề xuất lưu quy trình thành kỹ năng. Đặt 0 để tắt đề xuất sau tác vụ (hướng dẫn trong system prompt vẫn hiển thị).", "budget": "Ngân sách hàng tháng", "budgetLabel": "Giới hạn ngân sách (USD)", "budgetHint": "Giới hạn chi tiêu hàng tháng tùy chọn. Agent sẽ từ chối yêu cầu khi vượt quá. Để trống cho không giới hạn.", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 9c64547f..cdbdd830 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -100,6 +100,12 @@ "selfEvolutionLabel": "允许Agent进化其沟通风格", "selfEvolutionHint": "启用后,Agent可以更新其 SOUL.md 以根据交互改善语调、词汇和响应风格。身份、名称和操作指令保持锁定。", "selfEvolutionInfo": "Agent将通过 SOUL.md 更新随时间进化其风格。只有风格和语调受影响 — 身份和工作流规则保持固定。", + "skillLearning": "技能学习", + "skillLearningLabel": "允许Agent从经验中创建和管理技能", + "skillLearningHint": "启用后,Agent可以在复杂任务后将可重用的工作流程保存为技能。Agent会被温和地提醒保存有用的模式。需要启用 skill_manage 工具。", + "skillLearningInfo": "在复杂任务(5+ 工具调用)后,Agent可能会建议将工作流程保存为可重用技能。创建技能前需要用户批准。技能默认为私有。", + "skillNudgeIntervalLabel": "N 次工具调用后提醒", + "skillNudgeIntervalHint": "达到此工具调用次数后,Agent会建议将工作流程保存为技能。设为 0 可禁用任务后建议(系统提示词中的指导仍然显示)。", "budget": "月度预算", "budgetLabel": "预算限额 (USD)", "budgetHint": "可选的月度支出上限。超出后 Agent 将拒绝请求。留空表示无限制。", diff --git a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx index 7d9a536a..2fc4b4c4 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { Save, Check, AlertCircle, Sparkles, Info, DollarSign } from "lucide-react"; +import { Save, Check, AlertCircle, Sparkles, Info, DollarSign, GraduationCap } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -35,10 +35,14 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { agent.budget_monthly_cents ? String(agent.budget_monthly_cents / 100) : "", ); - // Self-evolve (predefined agents only) + // Self-evolve & skill-evolve (predefined agents only) const otherCfg = (agent.other_config ?? {}) as Record; const [emoji, setEmoji] = useState(typeof otherCfg.emoji === "string" ? otherCfg.emoji : ""); const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve)); + const [skillEvolve, setSkillEvolve] = useState(Boolean(otherCfg.skill_evolve)); + const [skillNudgeInterval, setSkillNudgeInterval] = useState( + typeof otherCfg.skill_nudge_interval === "number" ? otherCfg.skill_nudge_interval : 15, + ); // Save state const [saving, setSaving] = useState(false); @@ -54,7 +58,13 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { setSaveError(null); setSaved(false); try { - const updatedOtherConfig = { ...otherCfg, self_evolve: selfEvolve, emoji: emoji.trim() || undefined }; + const updatedOtherConfig = { + ...otherCfg, + self_evolve: selfEvolve, + skill_evolve: skillEvolve, + skill_nudge_interval: skillEvolve ? skillNudgeInterval : undefined, + emoji: emoji.trim() || undefined, + }; const budgetCents = budgetDollars ? Math.round(parseFloat(budgetDollars) * 100) : null; await onUpdate({ display_name: displayName, @@ -174,6 +184,59 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { )} + {/* Skill Learning (predefined agents only) */} + {agent.agent_type === "predefined" && ( + <> + +
+
+ +

{t("general.skillLearning")}

+
+
+
+ +

+ {t("general.skillLearningHint")} +

+
+ +
+ {skillEvolve && ( + <> +
+ + {t("general.skillLearningInfo")} +
+
+ + setSkillNudgeInterval(Number(e.target.value) || 0)} + className="max-w-[120px] text-base md:text-sm" + /> +

+ {t("general.skillNudgeIntervalHint")} +

+
+ + )} +
+ + )} + {/* Save */} {saveError && (