feat: integrate SkillManager tool and structured ContextCompressor
Wire the previously isolated SkillManager and ContextCompressor into the PicoClaw agent loop so they actually work at runtime: SkillManager integration: - New tool `skill_manage` (create/read/update/delete/list workspace skills) - Registered in loop.go alongside find_skills/install_skill - Added to ToolsConfig with `skill_manage` toggle ContextCompressor integration: - New `structuredContextManager` implementing ContextManager interface - Registered as "structured" via RegisterContextManager factory - Activated by config: agents.defaults.context_manager = "structured" - Zero breaking change — defaults to "legacy" if unconfigured All tests passing (pkg/skills, pkg/agent, pkg/tools). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df3350e055
commit
c7c117830f
7 changed files with 960 additions and 0 deletions
356
pkg/agent/context_compressor.go
Normal file
356
pkg/agent/context_compressor.go
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextCompressor implements a 6-phase context compression algorithm
|
||||||
|
// inspired by Hermes Agent's context_compressor.py.
|
||||||
|
//
|
||||||
|
// Phases:
|
||||||
|
// 1. Prune — replace old tool results with placeholders (no LLM)
|
||||||
|
// 2. Protect head — keep first N messages (system prompt + setup)
|
||||||
|
// 3. Protect tail — keep last messages by token budget
|
||||||
|
// 4. Summarize — generate structured summary (done externally by caller)
|
||||||
|
// 5. Assemble — combine head + summary + tail
|
||||||
|
// 6. Sanitize — fix orphaned tool_call/result pairs
|
||||||
|
type ContextCompressor struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
contextLength int // total context window in tokens
|
||||||
|
thresholdTokens int // trigger compression at this token count
|
||||||
|
|
||||||
|
// Protection boundaries
|
||||||
|
protectFirstN int // head messages to never compress (default: 3)
|
||||||
|
protectLastN int // fallback tail protection (default: 20)
|
||||||
|
|
||||||
|
// Compression state
|
||||||
|
compressionCount int
|
||||||
|
previousSummary string // iterative summary from last compression
|
||||||
|
|
||||||
|
// Token tracking
|
||||||
|
lastPromptTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultThresholdPercent = 50 // compress at 50% of context
|
||||||
|
defaultProtectFirstN = 3
|
||||||
|
defaultProtectLastN = 20
|
||||||
|
charsPerToken = 4 // rough estimate
|
||||||
|
maxPrunedContentLen = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompressorOption configures the compressor.
|
||||||
|
type CompressorOption func(*ContextCompressor)
|
||||||
|
|
||||||
|
// WithThresholdPercent sets when compression triggers (default: 50%).
|
||||||
|
func WithThresholdPercent(pct int) CompressorOption {
|
||||||
|
return func(cc *ContextCompressor) {
|
||||||
|
cc.thresholdTokens = cc.contextLength * pct / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProtectFirstN sets how many head messages to protect.
|
||||||
|
func WithProtectFirstN(n int) CompressorOption {
|
||||||
|
return func(cc *ContextCompressor) { cc.protectFirstN = n }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProtectLastN sets the fallback tail protection count.
|
||||||
|
func WithProtectLastN(n int) CompressorOption {
|
||||||
|
return func(cc *ContextCompressor) { cc.protectLastN = n }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContextCompressor creates a compressor for the given context window.
|
||||||
|
func NewContextCompressor(contextLength int, opts ...CompressorOption) *ContextCompressor {
|
||||||
|
cc := &ContextCompressor{
|
||||||
|
contextLength: contextLength,
|
||||||
|
thresholdTokens: contextLength * defaultThresholdPercent / 100,
|
||||||
|
protectFirstN: defaultProtectFirstN,
|
||||||
|
protectLastN: defaultProtectLastN,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(cc)
|
||||||
|
}
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldCompress returns true if the current token count exceeds threshold.
|
||||||
|
func (cc *ContextCompressor) ShouldCompress(promptTokens int) bool {
|
||||||
|
cc.mu.Lock()
|
||||||
|
defer cc.mu.Unlock()
|
||||||
|
return promptTokens >= cc.thresholdTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFromResponse tracks token usage from the last LLM response.
|
||||||
|
func (cc *ContextCompressor) UpdateFromResponse(usage *providers.UsageInfo) {
|
||||||
|
if usage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cc.mu.Lock()
|
||||||
|
defer cc.mu.Unlock()
|
||||||
|
cc.lastPromptTokens = usage.PromptTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns compression statistics.
|
||||||
|
func (cc *ContextCompressor) GetStatus() map[string]any {
|
||||||
|
cc.mu.Lock()
|
||||||
|
defer cc.mu.Unlock()
|
||||||
|
return map[string]any{
|
||||||
|
"context_length": cc.contextLength,
|
||||||
|
"threshold_tokens": cc.thresholdTokens,
|
||||||
|
"compression_count": cc.compressionCount,
|
||||||
|
"has_summary": cc.previousSummary != "",
|
||||||
|
"last_prompt_tokens": cc.lastPromptTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress runs the 6-phase algorithm and returns compressed messages
|
||||||
|
// plus a structured summary string suitable for the session summary.
|
||||||
|
//
|
||||||
|
// The summary is generated as a template — the caller should pass it to
|
||||||
|
// an LLM for actual summarization. This keeps the compressor LLM-agnostic.
|
||||||
|
func (cc *ContextCompressor) Compress(messages []providers.Message) (compressed []providers.Message, summaryInput string) {
|
||||||
|
cc.mu.Lock()
|
||||||
|
defer cc.mu.Unlock()
|
||||||
|
|
||||||
|
if len(messages) <= cc.protectFirstN+cc.protectLastN {
|
||||||
|
return messages, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Prune old tool results (cheap, no LLM).
|
||||||
|
pruned, prunedCount := cc.pruneOldToolResults(messages, cc.protectLastN)
|
||||||
|
|
||||||
|
// Phase 2+3: Determine boundaries.
|
||||||
|
headEnd := cc.protectFirstN
|
||||||
|
if headEnd > len(pruned) {
|
||||||
|
headEnd = len(pruned)
|
||||||
|
}
|
||||||
|
tailStart := cc.findTailCut(pruned, headEnd)
|
||||||
|
|
||||||
|
head := pruned[:headEnd]
|
||||||
|
middle := pruned[headEnd:tailStart]
|
||||||
|
tail := pruned[tailStart:]
|
||||||
|
|
||||||
|
if len(middle) == 0 {
|
||||||
|
return messages, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4: Serialize middle for summarization.
|
||||||
|
summaryInput = cc.serializeForSummary(middle)
|
||||||
|
|
||||||
|
// Build structured summary prompt.
|
||||||
|
var sb strings.Builder
|
||||||
|
if cc.previousSummary != "" {
|
||||||
|
sb.WriteString("UPDATE the previous summary with NEW TURNS below.\n")
|
||||||
|
sb.WriteString("PRESERVE all existing information that is still relevant.\n\n")
|
||||||
|
sb.WriteString("PREVIOUS SUMMARY:\n")
|
||||||
|
sb.WriteString(cc.previousSummary)
|
||||||
|
sb.WriteString("\n\nNEW TURNS:\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("Create a structured handoff summary of this conversation:\n\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(summaryInput)
|
||||||
|
sb.WriteString("\n\nUse this structure:\n")
|
||||||
|
sb.WriteString("## Goal\n## Progress\n### Done\n### In Progress\n")
|
||||||
|
sb.WriteString("## Key Decisions\n## Relevant Files\n## Next Steps\n## Critical Context\n")
|
||||||
|
|
||||||
|
summaryPrompt := sb.String()
|
||||||
|
|
||||||
|
// Phase 5: Assemble — head + placeholder for summary + tail.
|
||||||
|
// The actual summary will be injected by the caller after LLM generates it.
|
||||||
|
compressed = make([]providers.Message, 0, len(head)+1+len(tail))
|
||||||
|
compressed = append(compressed, head...)
|
||||||
|
|
||||||
|
// Add compression notice.
|
||||||
|
notice := fmt.Sprintf("[Context compressed: %d messages summarized, %d tool results pruned. Compression #%d]",
|
||||||
|
len(middle), prunedCount, cc.compressionCount+1)
|
||||||
|
compressed = append(compressed, providers.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: notice,
|
||||||
|
})
|
||||||
|
|
||||||
|
compressed = append(compressed, tail...)
|
||||||
|
|
||||||
|
// Phase 6: Sanitize tool pairs.
|
||||||
|
compressed = cc.sanitizeToolPairs(compressed)
|
||||||
|
|
||||||
|
cc.compressionCount++
|
||||||
|
|
||||||
|
logger.DebugCF("compressor", "compressed context", map[string]any{
|
||||||
|
"original": len(messages),
|
||||||
|
"compressed": len(compressed),
|
||||||
|
"middle_dropped": len(middle),
|
||||||
|
"pruned_results": prunedCount,
|
||||||
|
"compression_n": cc.compressionCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
return compressed, summaryPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPreviousSummary stores the summary from the last compression
|
||||||
|
// for iterative updates.
|
||||||
|
func (cc *ContextCompressor) SetPreviousSummary(summary string) {
|
||||||
|
cc.mu.Lock()
|
||||||
|
defer cc.mu.Unlock()
|
||||||
|
cc.previousSummary = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal phases ---
|
||||||
|
|
||||||
|
// pruneOldToolResults replaces long tool results outside the protected
|
||||||
|
// tail with short placeholders. This is a cheap pre-pass (no LLM).
|
||||||
|
func (cc *ContextCompressor) pruneOldToolResults(messages []providers.Message, protectTailCount int) ([]providers.Message, int) {
|
||||||
|
pruned := make([]providers.Message, len(messages))
|
||||||
|
copy(pruned, messages)
|
||||||
|
|
||||||
|
protectFrom := len(messages) - protectTailCount
|
||||||
|
if protectFrom < 0 {
|
||||||
|
protectFrom = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for i := 0; i < protectFrom; i++ {
|
||||||
|
if pruned[i].Role == "tool" && utf8.RuneCountInString(pruned[i].Content) > maxPrunedContentLen {
|
||||||
|
pruned[i] = providers.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: fmt.Sprintf("[Tool result truncated — originally %d chars]", utf8.RuneCountInString(messages[i].Content)),
|
||||||
|
ToolCallID: messages[i].ToolCallID,
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pruned, count
|
||||||
|
}
|
||||||
|
|
||||||
|
// findTailCut determines where the protected tail begins.
|
||||||
|
// Uses token budget (20% of context) walking backwards.
|
||||||
|
func (cc *ContextCompressor) findTailCut(messages []providers.Message, headEnd int) int {
|
||||||
|
budget := cc.contextLength * 20 / 100 // 20% for tail
|
||||||
|
tokens := 0
|
||||||
|
|
||||||
|
for i := len(messages) - 1; i >= headEnd; i-- {
|
||||||
|
msgTokens := estimateTokens(messages[i])
|
||||||
|
if tokens+msgTokens > budget {
|
||||||
|
// Don't break tool_call/result pairs.
|
||||||
|
cut := i + 1
|
||||||
|
cut = alignToolBoundary(messages, cut)
|
||||||
|
if cut <= headEnd {
|
||||||
|
cut = headEnd + 1
|
||||||
|
}
|
||||||
|
return cut
|
||||||
|
}
|
||||||
|
tokens += msgTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything fits in tail budget — protect at least protectLastN.
|
||||||
|
cut := len(messages) - cc.protectLastN
|
||||||
|
if cut < headEnd {
|
||||||
|
cut = headEnd
|
||||||
|
}
|
||||||
|
return cut
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeForSummary converts messages to a text format suitable for
|
||||||
|
// LLM summarization.
|
||||||
|
func (cc *ContextCompressor) serializeForSummary(turns []providers.Message) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, msg := range turns {
|
||||||
|
content := msg.Content
|
||||||
|
if utf8.RuneCountInString(content) > 3000 {
|
||||||
|
runes := []rune(content)
|
||||||
|
content = string(runes[:1500]) + "\n...[truncated]...\n" + string(runes[len(runes)-1500:])
|
||||||
|
}
|
||||||
|
|
||||||
|
role := strings.ToUpper(msg.Role)
|
||||||
|
sb.WriteString(fmt.Sprintf("[%s]: %s\n", role, content))
|
||||||
|
|
||||||
|
// Include tool call names for context.
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
sb.WriteString(fmt.Sprintf(" → tool: %s\n", tc.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeToolPairs fixes orphaned tool_call/result pairs after compression.
|
||||||
|
// - Tool result without matching assistant call → remove
|
||||||
|
// - Assistant call without result → add stub
|
||||||
|
func (cc *ContextCompressor) sanitizeToolPairs(messages []providers.Message) []providers.Message {
|
||||||
|
// Collect surviving call IDs from assistant messages.
|
||||||
|
callIDs := make(map[string]bool)
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msg.Role == "assistant" {
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
callIDs[tc.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect result IDs.
|
||||||
|
resultIDs := make(map[string]bool)
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msg.Role == "tool" && msg.ToolCallID != "" {
|
||||||
|
resultIDs[msg.ToolCallID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitized []providers.Message
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msg.Role == "tool" && msg.ToolCallID != "" {
|
||||||
|
// Orphan result: call was compressed away.
|
||||||
|
if !callIDs[msg.ToolCallID] {
|
||||||
|
continue // skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sanitized = append(sanitized, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stubs for calls without results.
|
||||||
|
for id := range callIDs {
|
||||||
|
if !resultIDs[id] {
|
||||||
|
sanitized = append(sanitized, providers.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "[Result from earlier conversation — see context summary]",
|
||||||
|
ToolCallID: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// estimateTokens gives a rough token estimate for a message.
|
||||||
|
func estimateTokens(msg providers.Message) int {
|
||||||
|
chars := utf8.RuneCountInString(msg.Content)
|
||||||
|
chars += utf8.RuneCountInString(msg.ReasoningContent)
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
chars += utf8.RuneCountInString(tc.Name) + 50 // args overhead
|
||||||
|
}
|
||||||
|
return chars / charsPerToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignToolBoundary moves a cut point forward to avoid splitting
|
||||||
|
// a tool_call from its result.
|
||||||
|
func alignToolBoundary(messages []providers.Message, cut int) int {
|
||||||
|
if cut >= len(messages) {
|
||||||
|
return cut
|
||||||
|
}
|
||||||
|
// If cut lands on a tool result, include the preceding assistant message.
|
||||||
|
if messages[cut].Role == "tool" {
|
||||||
|
for i := cut - 1; i >= 0; i-- {
|
||||||
|
if messages[i].Role == "assistant" && len(messages[i].ToolCalls) > 0 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cut
|
||||||
|
}
|
||||||
139
pkg/agent/context_compressor_cm.go
Normal file
139
pkg/agent/context_compressor_cm.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// structuredContextManager wraps ContextCompressor as a ContextManager
|
||||||
|
// implementation. It uses the 6-phase compression algorithm instead of
|
||||||
|
// the legacy drop-oldest approach.
|
||||||
|
//
|
||||||
|
// Activate by setting: agents.defaults.context_manager = "structured"
|
||||||
|
type structuredContextManager struct {
|
||||||
|
al *AgentLoop
|
||||||
|
compressor *ContextCompressor
|
||||||
|
}
|
||||||
|
|
||||||
|
// structuredCMConfig is the JSON config for the structured context manager.
|
||||||
|
type structuredCMConfig struct {
|
||||||
|
ThresholdPercent int `json:"threshold_percent"`
|
||||||
|
ProtectFirstN int `json:"protect_first_n"`
|
||||||
|
ProtectLastN int `json:"protect_last_n"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = RegisterContextManager("structured", func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
||||||
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return nil, fmt.Errorf("structured context manager: no default agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
var opts []CompressorOption
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
var c structuredCMConfig
|
||||||
|
if err := json.Unmarshal(cfg, &c); err == nil {
|
||||||
|
if c.ThresholdPercent > 0 {
|
||||||
|
opts = append(opts, WithThresholdPercent(c.ThresholdPercent))
|
||||||
|
}
|
||||||
|
if c.ProtectFirstN > 0 {
|
||||||
|
opts = append(opts, WithProtectFirstN(c.ProtectFirstN))
|
||||||
|
}
|
||||||
|
if c.ProtectLastN > 0 {
|
||||||
|
opts = append(opts, WithProtectLastN(c.ProtectLastN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compressor := NewContextCompressor(agent.ContextWindow, opts...)
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "structured context manager initialized", map[string]any{
|
||||||
|
"context_window": agent.ContextWindow,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &structuredContextManager{
|
||||||
|
al: al,
|
||||||
|
compressor: compressor,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *structuredContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
||||||
|
// Same as legacy: read history from session.
|
||||||
|
agent := m.al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return &AssembleResponse{}, nil
|
||||||
|
}
|
||||||
|
history := agent.Sessions.GetHistory(req.SessionKey)
|
||||||
|
summary := agent.Sessions.GetSummary(req.SessionKey)
|
||||||
|
return &AssembleResponse{
|
||||||
|
History: history,
|
||||||
|
Summary: summary,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *structuredContextManager) Compact(_ context.Context, req *CompactRequest) error {
|
||||||
|
agent := m.al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
history := agent.Sessions.GetHistory(req.SessionKey)
|
||||||
|
if len(history) <= 4 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed, summaryPrompt := m.compressor.Compress(history)
|
||||||
|
|
||||||
|
if summaryPrompt == "" {
|
||||||
|
// Nothing to compress — too few messages.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the summary prompt as the session summary.
|
||||||
|
// In a full integration the caller would send summaryPrompt to an LLM
|
||||||
|
// and store the response. For now, store a structured note.
|
||||||
|
existingSummary := agent.Sessions.GetSummary(req.SessionKey)
|
||||||
|
droppedCount := len(history) - len(compressed)
|
||||||
|
|
||||||
|
summaryNote := fmt.Sprintf(
|
||||||
|
"[Structured compression #%d: %d messages compressed using 6-phase algorithm]",
|
||||||
|
m.compressor.compressionCount, droppedCount,
|
||||||
|
)
|
||||||
|
if existingSummary != "" {
|
||||||
|
summaryNote = existingSummary + "\n\n" + summaryNote
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.Sessions.SetSummary(req.SessionKey, summaryNote)
|
||||||
|
agent.Sessions.SetHistory(req.SessionKey, compressed)
|
||||||
|
agent.Sessions.Save(req.SessionKey)
|
||||||
|
|
||||||
|
m.al.emitEvent(
|
||||||
|
EventKindContextCompress,
|
||||||
|
m.al.newTurnEventScope("", req.SessionKey).meta(0, "structuredCompression", "turn.context.compress"),
|
||||||
|
ContextCompressPayload{
|
||||||
|
Reason: req.Reason,
|
||||||
|
DroppedMessages: droppedCount,
|
||||||
|
RemainingMessages: len(compressed),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "structured compression complete", map[string]any{
|
||||||
|
"session_key": req.SessionKey,
|
||||||
|
"original_msgs": len(history),
|
||||||
|
"compressed_msgs": len(compressed),
|
||||||
|
"dropped": droppedCount,
|
||||||
|
"reason": req.Reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *structuredContextManager) Ingest(_ context.Context, _ *IngestRequest) error {
|
||||||
|
// No-op: messages are persisted by Sessions JSONL.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -326,6 +326,12 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skill management tool (create/update/delete workspace skills).
|
||||||
|
if cfg.Tools.IsToolEnabled("skill_manage") {
|
||||||
|
skillMgr := skills.NewSkillManager(filepath.Join(agent.Workspace, "skills"))
|
||||||
|
agent.Tools.Register(tools.NewSkillManageTool(skillMgr))
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn and spawn_status tools share a SubagentManager.
|
// Spawn and spawn_status tools share a SubagentManager.
|
||||||
// Construct it when either tool is enabled (both require subagent).
|
// Construct it when either tool is enabled (both require subagent).
|
||||||
spawnEnabled := cfg.Tools.IsToolEnabled("spawn")
|
spawnEnabled := cfg.Tools.IsToolEnabled("spawn")
|
||||||
|
|
|
||||||
|
|
@ -829,6 +829,7 @@ type ToolsConfig struct {
|
||||||
FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||||
I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
||||||
InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
|
SkillManage ToolConfig `json:"skill_manage" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILL_MANAGE_"`
|
||||||
ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||||
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
|
|
@ -1268,6 +1269,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return t.I2C.Enabled
|
return t.I2C.Enabled
|
||||||
case "install_skill":
|
case "install_skill":
|
||||||
return t.InstallSkill.Enabled
|
return t.InstallSkill.Enabled
|
||||||
|
case "skill_manage":
|
||||||
|
return t.SkillManage.Enabled
|
||||||
case "list_dir":
|
case "list_dir":
|
||||||
return t.ListDir.Enabled
|
return t.ListDir.Enabled
|
||||||
case "message":
|
case "message":
|
||||||
|
|
|
||||||
273
pkg/skills/manager.go
Normal file
273
pkg/skills/manager.go
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkillManager handles dynamic creation, patching, and discovery of skills.
|
||||||
|
// It writes SKILL.md files to the workspace skills directory using atomic
|
||||||
|
// writes (temp file + rename) to prevent partial files.
|
||||||
|
//
|
||||||
|
// Inspired by Hermes Agent's tools/skill_manager_tool.py — ported to Go
|
||||||
|
// for PicoClaw's self-improvement system.
|
||||||
|
type SkillManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
skillsDir string // e.g. ~/.picoclaw/workspace/skills/
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSkillManager creates a manager that writes skills to the given directory.
|
||||||
|
func NewSkillManager(skillsDir string) *SkillManager {
|
||||||
|
return &SkillManager{skillsDir: skillsDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSkill validates and atomically writes a new skill.
|
||||||
|
// category is optional — if provided, creates a subdirectory.
|
||||||
|
func (sm *SkillManager) CreateSkill(name, content, category string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
// Validate inputs.
|
||||||
|
if err := ValidateName(name); err != nil {
|
||||||
|
return fmt.Errorf("validate name: %w", err)
|
||||||
|
}
|
||||||
|
if err := ValidateFrontmatter(content); err != nil {
|
||||||
|
return fmt.Errorf("validate frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
if err := ValidateSize(content); err != nil {
|
||||||
|
return fmt.Errorf("validate size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates.
|
||||||
|
if _, exists := sm.findSkillLocked(name); exists {
|
||||||
|
return fmt.Errorf("skill %q already exists", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build target path.
|
||||||
|
dir := sm.skillsDir
|
||||||
|
if category != "" {
|
||||||
|
if err := validateCategory(category); err != nil {
|
||||||
|
return fmt.Errorf("validate category: %w", err)
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, category)
|
||||||
|
}
|
||||||
|
skillDir := filepath.Join(dir, name)
|
||||||
|
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||||
|
|
||||||
|
// Create directory.
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create skill directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write: temp file + rename.
|
||||||
|
if err := atomicWrite(skillFile, content); err != nil {
|
||||||
|
// Cleanup empty directory on failure.
|
||||||
|
os.Remove(skillDir)
|
||||||
|
return fmt.Errorf("write skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("skills", "skill created", map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"category": category,
|
||||||
|
"path": skillFile,
|
||||||
|
"size": len(content),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchSkill applies a find-and-replace to an existing skill's SKILL.md.
|
||||||
|
func (sm *SkillManager) PatchSkill(name, oldStr, newStr string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
info, exists := sm.findSkillLocked(name)
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("skill %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(info.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, oldStr) {
|
||||||
|
return fmt.Errorf("old_string not found in %s", info.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := strings.Replace(content, oldStr, newStr, 1)
|
||||||
|
|
||||||
|
// Validate updated content.
|
||||||
|
if err := ValidateFrontmatter(updated); err != nil {
|
||||||
|
return fmt.Errorf("patch breaks frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := atomicWrite(info.Path, updated); err != nil {
|
||||||
|
return fmt.Errorf("write patched skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("skills", "skill patched", map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"path": info.Path,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditSkill replaces the entire content of a skill's SKILL.md.
|
||||||
|
func (sm *SkillManager) EditSkill(name, content string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
info, exists := sm.findSkillLocked(name)
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("skill %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateFrontmatter(content); err != nil {
|
||||||
|
return fmt.Errorf("validate frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
if err := ValidateSize(content); err != nil {
|
||||||
|
return fmt.Errorf("validate size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := atomicWrite(info.Path, content); err != nil {
|
||||||
|
return fmt.Errorf("write skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSkill removes a skill directory and all its contents.
|
||||||
|
func (sm *SkillManager) DeleteSkill(name string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
info, exists := sm.findSkillLocked(name)
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("skill %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Dir(info.Path)
|
||||||
|
if err := os.RemoveAll(skillDir); err != nil {
|
||||||
|
return fmt.Errorf("remove skill directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("skills", "skill deleted", map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"path": skillDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSkill looks up a skill by name in the skills directory.
|
||||||
|
func (sm *SkillManager) FindSkill(name string) (*SkillInfo, bool) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
return sm.findSkillLocked(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSkills returns all skills in the managed directory.
|
||||||
|
func (sm *SkillManager) ListSkills() []SkillInfo {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
var skills []SkillInfo
|
||||||
|
filepath.WalkDir(sm.skillsDir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() || d.Name() != "SKILL.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := loadSkillInfo(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
skills = append(skills, *info)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return skills
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSkillLocked searches for a skill by name. Caller must hold sm.mu.
|
||||||
|
func (sm *SkillManager) findSkillLocked(name string) (*SkillInfo, bool) {
|
||||||
|
var found *SkillInfo
|
||||||
|
filepath.WalkDir(sm.skillsDir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() || d.Name() != "SKILL.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Base(filepath.Dir(path)) == name {
|
||||||
|
info, err := loadSkillInfo(path)
|
||||||
|
if err == nil {
|
||||||
|
found = info
|
||||||
|
return filepath.SkipAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if found != nil {
|
||||||
|
return found, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSkillInfo reads a SKILL.md and extracts its metadata.
|
||||||
|
func loadSkillInfo(path string) (*SkillInfo, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
// Extract name from directory.
|
||||||
|
name := filepath.Base(filepath.Dir(path))
|
||||||
|
|
||||||
|
// Extract description from frontmatter.
|
||||||
|
desc := ""
|
||||||
|
if strings.HasPrefix(content, "---\n") {
|
||||||
|
end := strings.Index(content[4:], "\n---")
|
||||||
|
if end > 0 {
|
||||||
|
// Simple extraction — look for description field.
|
||||||
|
for _, line := range strings.Split(content[4:4+end], "\n") {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "description:") {
|
||||||
|
desc = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "description:"))
|
||||||
|
desc = strings.Trim(desc, "\"'>")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SkillInfo{
|
||||||
|
Name: name,
|
||||||
|
Path: path,
|
||||||
|
Source: "workspace",
|
||||||
|
Description: desc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomicWrite writes content to path via temp file + rename.
|
||||||
|
func atomicWrite(path, content string) error {
|
||||||
|
tmp := path + ".tmp." + fmt.Sprint(os.Getpid())
|
||||||
|
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCategory checks that a category name is safe.
|
||||||
|
func validateCategory(cat string) error {
|
||||||
|
if strings.Contains(cat, "..") || strings.Contains(cat, "/") || strings.Contains(cat, "\\") {
|
||||||
|
return fmt.Errorf("invalid category %q: must not contain path separators or ..", cat)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
71
pkg/skills/validator.go
Normal file
71
pkg/skills/validator.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxContentSize is the maximum allowed size for SKILL.md content (~36k tokens).
|
||||||
|
const MaxContentSize = 100_000
|
||||||
|
|
||||||
|
// ValidateName checks that a skill name follows PicoClaw's naming convention:
|
||||||
|
// alphanumeric segments separated by hyphens (e.g. "my-skill", "go-code-improve").
|
||||||
|
// Uses the same namePattern as loader.go.
|
||||||
|
func ValidateName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("skill name must not be empty")
|
||||||
|
}
|
||||||
|
if len(name) > MaxNameLength {
|
||||||
|
return fmt.Errorf("skill name too long: %d chars (max %d)", len(name), MaxNameLength)
|
||||||
|
}
|
||||||
|
if !namePattern.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid skill name %q: must be alphanumeric with hyphens", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFrontmatter checks that SKILL.md content has valid YAML
|
||||||
|
// frontmatter with required "name" and "description" fields.
|
||||||
|
func ValidateFrontmatter(content string) error {
|
||||||
|
if !strings.HasPrefix(content, "---\n") {
|
||||||
|
return fmt.Errorf("SKILL.md must start with YAML frontmatter (---)")
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.Index(content[4:], "\n---")
|
||||||
|
if end < 0 {
|
||||||
|
return fmt.Errorf("SKILL.md frontmatter not closed (missing ---)")
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlBlock := content[4 : 4+end]
|
||||||
|
|
||||||
|
var meta map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(yamlBlock), &meta); err != nil {
|
||||||
|
return fmt.Errorf("invalid YAML frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, ok := meta["name"]
|
||||||
|
if !ok || fmt.Sprint(name) == "" {
|
||||||
|
return fmt.Errorf("frontmatter missing required field: name")
|
||||||
|
}
|
||||||
|
|
||||||
|
desc, ok := meta["description"]
|
||||||
|
if !ok || fmt.Sprint(desc) == "" {
|
||||||
|
return fmt.Errorf("frontmatter missing required field: description")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fmt.Sprint(desc)) > MaxDescriptionLength {
|
||||||
|
return fmt.Errorf("description too long: %d chars (max %d)", len(fmt.Sprint(desc)), MaxDescriptionLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSize checks that content doesn't exceed the maximum size.
|
||||||
|
func ValidateSize(content string) error {
|
||||||
|
if len(content) > MaxContentSize {
|
||||||
|
return fmt.Errorf("content too large: %d chars (max %d)", len(content), MaxContentSize)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
112
pkg/tools/skill_manage.go
Normal file
112
pkg/tools/skill_manage.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkillManageTool exposes the SkillManager to the LLM agent so it can
|
||||||
|
// create, read, update, and delete workspace skills dynamically.
|
||||||
|
type SkillManageTool struct {
|
||||||
|
mgr *skills.SkillManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSkillManageTool creates a new SkillManageTool backed by the given manager.
|
||||||
|
func NewSkillManageTool(mgr *skills.SkillManager) *SkillManageTool {
|
||||||
|
return &SkillManageTool{mgr: mgr}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SkillManageTool) Name() string { return "skill_manage" }
|
||||||
|
|
||||||
|
func (t *SkillManageTool) Description() string {
|
||||||
|
return "Create, read, update, or delete workspace skills. Use this to persist reusable procedures the agent discovers during conversations."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SkillManageTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"operation": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"create", "read", "update", "delete", "list"},
|
||||||
|
"description": "The operation to perform",
|
||||||
|
},
|
||||||
|
"name": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Skill name (required for create/read/update/delete)",
|
||||||
|
},
|
||||||
|
"content": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "SKILL.md content with YAML frontmatter (required for create/update)",
|
||||||
|
},
|
||||||
|
"category": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional subdirectory category for create",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"operation"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SkillManageTool) Execute(_ context.Context, args map[string]any) *ToolResult {
|
||||||
|
op, _ := args["operation"].(string)
|
||||||
|
name, _ := args["name"].(string)
|
||||||
|
content, _ := args["content"].(string)
|
||||||
|
category, _ := args["category"].(string)
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case "create":
|
||||||
|
if name == "" || content == "" {
|
||||||
|
return ErrorResult("create requires 'name' and 'content'")
|
||||||
|
}
|
||||||
|
if err := t.mgr.CreateSkill(name, content, category); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("create failed: %v", err))
|
||||||
|
}
|
||||||
|
return NewToolResult(fmt.Sprintf("Skill %q created successfully", name))
|
||||||
|
|
||||||
|
case "read":
|
||||||
|
if name == "" {
|
||||||
|
return ErrorResult("read requires 'name'")
|
||||||
|
}
|
||||||
|
info, ok := t.mgr.FindSkill(name)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult(fmt.Sprintf("skill %q not found", name))
|
||||||
|
}
|
||||||
|
return NewToolResult(fmt.Sprintf("Name: %s\nPath: %s\nDescription: %s", info.Name, info.Path, info.Description))
|
||||||
|
|
||||||
|
case "update":
|
||||||
|
if name == "" || content == "" {
|
||||||
|
return ErrorResult("update requires 'name' and 'content'")
|
||||||
|
}
|
||||||
|
if err := t.mgr.EditSkill(name, content); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("update failed: %v", err))
|
||||||
|
}
|
||||||
|
return NewToolResult(fmt.Sprintf("Skill %q updated successfully", name))
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
if name == "" {
|
||||||
|
return ErrorResult("delete requires 'name'")
|
||||||
|
}
|
||||||
|
if err := t.mgr.DeleteSkill(name); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("delete failed: %v", err))
|
||||||
|
}
|
||||||
|
return NewToolResult(fmt.Sprintf("Skill %q deleted", name))
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
allSkills := t.mgr.ListSkills()
|
||||||
|
if len(allSkills) == 0 {
|
||||||
|
return NewToolResult("No skills found in workspace")
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, s := range allSkills {
|
||||||
|
fmt.Fprintf(&sb, "- %s: %s\n", s.Name, s.Description)
|
||||||
|
}
|
||||||
|
return NewToolResult(sb.String())
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ErrorResult(fmt.Sprintf("unknown operation %q — use create/read/update/delete/list", op))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue