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)
This commit is contained in:
viettranx committed 2026-03-17 11:38:35 +07:00
1 parent 0a78902f9b
commit b2a74ba487
23 files changed
+944 -29

No files matched your search

+39
View File
@@ -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 {