mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 10:15:12 +00:00
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:
1 parent
0a78902f9b
commit
b2a74ba487
23 files changed
+944
-29
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user