From 6da3c308669a42b7561a7251258d9a3e4913d2d8 Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Wed, 18 Feb 2026 15:54:06 +0000 Subject: [PATCH] feat(tools): add agentic retrieval, focus, and skill discovery tools Add three new tool families for the agent loop: - Retrieval tools: keyword_search, semantic_search, chunk_read for querying archival memory - Focus tools: context focusing and relevance filtering - Skill tools: skill_search, skill_read, skill_traverse for progressive skill disclosure --- pkg/tools/focus.go | 288 +++++++++++++++++++++++++++++++++++ pkg/tools/focus_test.go | 296 ++++++++++++++++++++++++++++++++++++ pkg/tools/retrieval.go | 216 ++++++++++++++++++++++++++ pkg/tools/retrieval_test.go | 76 +++++++++ pkg/tools/skills.go | 211 +++++++++++++++++++++++++ pkg/tools/skills_test.go | 142 +++++++++++++++++ 6 files changed, 1229 insertions(+) create mode 100644 pkg/tools/focus.go create mode 100644 pkg/tools/focus_test.go create mode 100644 pkg/tools/retrieval.go create mode 100644 pkg/tools/retrieval_test.go create mode 100644 pkg/tools/skills.go create mode 100644 pkg/tools/skills_test.go diff --git a/pkg/tools/focus.go b/pkg/tools/focus.go new file mode 100644 index 000000000..d39a3d9b5 --- /dev/null +++ b/pkg/tools/focus.go @@ -0,0 +1,288 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/messages" + "github.com/sipeed/picoclaw/pkg/session" +) + +// KVStore is the minimal interface needed for focus persistence. +type KVStore interface { + GetKV(ctx context.Context, agentID, key string) (string, error) + UpsertKV(ctx context.Context, agentID, key, value string) error + DeleteKV(ctx context.Context, agentID, key string) error +} + +const ( + focusKVPrefix = "focus:" + knowledgeKVPrefix = "knowledge:" + focusAgentID = "picoclaw" +) + +// FocusState tracks an active focus investigation. +type FocusState struct { + Topic string `json:"topic"` + CheckpointIndex int `json:"checkpoint_index"` + StartedAt time.Time `json:"started_at"` +} + +// KnowledgeBlock stores accumulated summaries from completed focus sessions. +type KnowledgeBlock struct { + Entries []KnowledgeEntry `json:"entries"` +} + +// KnowledgeEntry is a single completed focus summary. +type KnowledgeEntry struct { + Topic string `json:"topic"` + Summary string `json:"summary"` + CreatedAt time.Time `json:"created_at"` +} + +// FormatBlock renders the knowledge block for system prompt injection. +func (kb *KnowledgeBlock) FormatBlock() string { + if len(kb.Entries) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("# Knowledge\n\n") + for _, e := range kb.Entries { + sb.WriteString(fmt.Sprintf("## %s\n%s\n\n", e.Topic, e.Summary)) + } + return sb.String() +} + +// StartFocusTool declares an investigation topic and creates a checkpoint. +type StartFocusTool struct { + delegate KVStore + sessions *session.SessionManager + sessionKey func() string +} + +func NewStartFocusTool(delegate KVStore, sessions *session.SessionManager, sessionKeyFn func() string) *StartFocusTool { + return &StartFocusTool{ + delegate: delegate, + sessions: sessions, + sessionKey: sessionKeyFn, + } +} + +func (t *StartFocusTool) Name() string { return "start_focus" } + +func (t *StartFocusTool) Description() string { + return "Declare an investigation topic and create a context checkpoint. After exploring the topic (typically 10-15 tool calls), use complete_focus to summarize findings and prune working context. This keeps your context window lean and focused." +} + +func (t *StartFocusTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "topic": map[string]interface{}{ + "type": "string", + "description": "What you are investigating or working on (e.g., 'debug authentication flow', 'implement caching layer')", + }, + }, + "required": []string{"topic"}, + } +} + +func (t *StartFocusTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + topic, _ := args["topic"].(string) + if topic == "" { + return ErrorResult("topic is required") + } + + sk := t.sessionKey() + if sk == "" { + return ErrorResult("no active session") + } + + history := t.sessions.GetHistory(sk) + state := FocusState{ + Topic: topic, + CheckpointIndex: len(history), + StartedAt: time.Now(), + } + + data, err := json.Marshal(state) + if err != nil { + return ErrorResult(fmt.Sprintf("marshal focus state: %v", err)) + } + + key := focusKVPrefix + sk + if err := t.delegate.UpsertKV(ctx, focusAgentID, key, string(data)); err != nil { + return ErrorResult(fmt.Sprintf("save focus state: %v", err)) + } + + logger.InfoCF("focus", "Focus started", + map[string]interface{}{ + "topic": topic, + "checkpoint": state.CheckpointIndex, + "session": sk, + }) + + return SilentResult(fmt.Sprintf("Focus started on: %s\nCheckpoint at message %d. Explore freely, then call complete_focus when done.", topic, state.CheckpointIndex)) +} + +// CompleteFocusTool summarizes findings, persists to knowledge, and prunes context. +type CompleteFocusTool struct { + delegate KVStore + sessions *session.SessionManager + sessionKey func() string +} + +func NewCompleteFocusTool(delegate KVStore, sessions *session.SessionManager, sessionKeyFn func() string) *CompleteFocusTool { + return &CompleteFocusTool{ + delegate: delegate, + sessions: sessions, + sessionKey: sessionKeyFn, + } +} + +func (t *CompleteFocusTool) Name() string { return "complete_focus" } + +func (t *CompleteFocusTool) Description() string { + return "Complete a focus investigation. Provide a summary of what was attempted, what was learned, and the outcome. The summary is saved to persistent Knowledge and the investigation messages are pruned from context, freeing tokens for future work." +} + +func (t *CompleteFocusTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "summary": map[string]interface{}{ + "type": "string", + "description": "Concise summary of the investigation: what was attempted, what was learned, and the outcome", + }, + }, + "required": []string{"summary"}, + } +} + +func (t *CompleteFocusTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + summary, _ := args["summary"].(string) + if summary == "" { + return ErrorResult("summary is required") + } + + sk := t.sessionKey() + if sk == "" { + return ErrorResult("no active session") + } + + focusKey := focusKVPrefix + sk + raw, err := t.delegate.GetKV(ctx, focusAgentID, focusKey) + if err != nil || raw == "" { + return ErrorResult("no active focus session — call start_focus first") + } + + var state FocusState + if err := json.Unmarshal([]byte(raw), &state); err != nil { + return ErrorResult(fmt.Sprintf("corrupt focus state: %v", err)) + } + + // Append to knowledge block + if err := t.appendKnowledge(ctx, sk, state.Topic, summary); err != nil { + return ErrorResult(fmt.Sprintf("save knowledge: %v", err)) + } + + // Prune messages between checkpoint and current position. + // Keep everything before checkpoint and the last few messages for continuity. + history := t.sessions.GetHistory(sk) + pruned := t.pruneHistory(history, state.CheckpointIndex) + t.sessions.SetHistory(sk, pruned) + + // Clean up focus state + _ = t.delegate.DeleteKV(ctx, focusAgentID, focusKey) + + msgsBefore := len(history) + msgsAfter := len(pruned) + logger.InfoCF("focus", "Focus completed", + map[string]interface{}{ + "topic": state.Topic, + "msgs_before": msgsBefore, + "msgs_after": msgsAfter, + "pruned": msgsBefore - msgsAfter, + "session": sk, + }) + + return SilentResult(fmt.Sprintf( + "Focus completed: '%s'\nSummary saved to Knowledge block.\nPruned %d messages (%d → %d). Context is now lean.", + state.Topic, msgsBefore-msgsAfter, msgsBefore, msgsAfter, + )) +} + +// pruneHistory removes investigation messages between checkpoint and end, +// keeping pre-checkpoint context and a small tail for continuity. +func (t *CompleteFocusTool) pruneHistory(history []messages.Message, checkpointIdx int) []messages.Message { + if checkpointIdx >= len(history) { + return history + } + + const keepTail = 4 + + pre := history[:checkpointIdx] + + tailStart := len(history) - keepTail + if tailStart < checkpointIdx { + tailStart = checkpointIdx + } + + // Tool-call-aware: don't start tail on a "tool" message + for tailStart > checkpointIdx && tailStart < len(history) && history[tailStart].Role == "tool" { + tailStart-- + } + + tail := history[tailStart:] + + result := make([]messages.Message, 0, len(pre)+len(tail)) + result = append(result, pre...) + result = append(result, tail...) + return result +} + +func (t *CompleteFocusTool) appendKnowledge(ctx context.Context, sessionKey, topic, summary string) error { + kvKey := knowledgeKVPrefix + sessionKey + + kb := &KnowledgeBlock{} + raw, err := t.delegate.GetKV(ctx, focusAgentID, kvKey) + if err == nil && raw != "" { + _ = json.Unmarshal([]byte(raw), kb) + } + + kb.Entries = append(kb.Entries, KnowledgeEntry{ + Topic: topic, + Summary: summary, + CreatedAt: time.Now(), + }) + + data, err := json.Marshal(kb) + if err != nil { + return err + } + + return t.delegate.UpsertKV(ctx, focusAgentID, kvKey, string(data)) +} + +// LoadKnowledgeBlock loads the persistent knowledge block for a session. +func LoadKnowledgeBlock(ctx context.Context, delegate KVStore, sessionKey string) string { + if delegate == nil { + return "" + } + kvKey := knowledgeKVPrefix + sessionKey + raw, err := delegate.GetKV(ctx, focusAgentID, kvKey) + if err != nil || raw == "" { + return "" + } + + var kb KnowledgeBlock + if err := json.Unmarshal([]byte(raw), &kb); err != nil { + return "" + } + + return kb.FormatBlock() +} diff --git a/pkg/tools/focus_test.go b/pkg/tools/focus_test.go new file mode 100644 index 000000000..982bf04a4 --- /dev/null +++ b/pkg/tools/focus_test.go @@ -0,0 +1,296 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" + + "github.com/sipeed/picoclaw/pkg/messages" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockKVStore struct { + data map[string]string +} + +func newMockKV() *mockKVStore { + return &mockKVStore{data: make(map[string]string)} +} + +func (m *mockKVStore) GetKV(_ context.Context, agentID, key string) (string, error) { + return m.data[agentID+":"+key], nil +} + +func (m *mockKVStore) UpsertKV(_ context.Context, agentID, key, value string) error { + m.data[agentID+":"+key] = value + return nil +} + +func (m *mockKVStore) DeleteKV(_ context.Context, agentID, key string) error { + delete(m.data, agentID+":"+key) + return nil +} + +type mockFocusDelegate struct { + kv *mockKVStore +} + +func newMockFocusDelegate() *mockFocusDelegate { + return &mockFocusDelegate{kv: newMockKV()} +} + +func (m *mockFocusDelegate) GetKV(ctx context.Context, agentID, key string) (string, error) { + return m.kv.GetKV(ctx, agentID, key) +} +func (m *mockFocusDelegate) UpsertKV(ctx context.Context, agentID, key, value string) error { + return m.kv.UpsertKV(ctx, agentID, key, value) +} +func (m *mockFocusDelegate) DeleteKV(ctx context.Context, agentID, key string) error { + return m.kv.DeleteKV(ctx, agentID, key) +} + +func TestStartFocus(t *testing.T) { + sm := session.NewSessionManager("") + sk := "test-session" + sm.GetOrCreate(sk) + + sm.AddMessage(sk, "user", "hello") + sm.AddMessage(sk, "assistant", "hi there") + + delegate := newMockFocusDelegate() + tool := NewStartFocusTool(delegate, sm, func() string { return sk }) + + ctx := context.Background() + result := tool.Execute(ctx, map[string]interface{}{ + "topic": "investigate auth bug", + }) + + require.NotNil(t, result) + assert.Contains(t, result.ForLLM, "Focus started on: investigate auth bug") + assert.Contains(t, result.ForLLM, "Checkpoint at message 2") + + raw, _ := delegate.GetKV(ctx, focusAgentID, focusKVPrefix+sk) + require.NotEmpty(t, raw) + + var state FocusState + require.NoError(t, json.Unmarshal([]byte(raw), &state)) + assert.Equal(t, "investigate auth bug", state.Topic) + assert.Equal(t, 2, state.CheckpointIndex) +} + +func TestStartFocus_MissingTopic(t *testing.T) { + sm := session.NewSessionManager("") + delegate := newMockFocusDelegate() + tool := NewStartFocusTool(delegate, sm, func() string { return "s" }) + + result := tool.Execute(context.Background(), map[string]interface{}{}) + assert.Contains(t, result.ForLLM, "topic is required") +} + +func TestCompleteFocus(t *testing.T) { + sm := session.NewSessionManager("") + sk := "test-session" + sm.GetOrCreate(sk) + + sm.AddMessage(sk, "user", "hello") + sm.AddMessage(sk, "assistant", "hi") + + delegate := newMockFocusDelegate() + + startTool := NewStartFocusTool(delegate, sm, func() string { return sk }) + ctx := context.Background() + startTool.Execute(ctx, map[string]interface{}{"topic": "debug auth"}) + + sm.AddMessage(sk, "user", "check logs") + sm.AddMessage(sk, "assistant", "found the issue in auth.go") + sm.AddMessage(sk, "user", "fix it") + sm.AddMessage(sk, "assistant", "done, applied patch") + sm.AddMessage(sk, "user", "test it") + sm.AddMessage(sk, "assistant", "all tests pass") + + historyBefore := sm.GetHistory(sk) + require.Equal(t, 8, len(historyBefore)) + + completeTool := NewCompleteFocusTool(delegate, sm, func() string { return sk }) + result := completeTool.Execute(ctx, map[string]interface{}{ + "summary": "Found auth bug in token validation. Fixed by adding expiry check.", + }) + + require.NotNil(t, result) + assert.Contains(t, result.ForLLM, "Focus completed") + assert.Contains(t, result.ForLLM, "debug auth") + + historyAfter := sm.GetHistory(sk) + assert.Less(t, len(historyAfter), len(historyBefore)) + + // Pre-checkpoint messages should be preserved + assert.Equal(t, "hello", historyAfter[0].Content) + assert.Equal(t, "hi", historyAfter[1].Content) + + // Knowledge should be persisted + knowledgeRaw, _ := delegate.GetKV(ctx, focusAgentID, knowledgeKVPrefix+sk) + require.NotEmpty(t, knowledgeRaw) + + var kb KnowledgeBlock + require.NoError(t, json.Unmarshal([]byte(knowledgeRaw), &kb)) + require.Len(t, kb.Entries, 1) + assert.Equal(t, "debug auth", kb.Entries[0].Topic) + assert.Contains(t, kb.Entries[0].Summary, "token validation") + + // Focus state should be cleaned up + focusRaw, _ := delegate.GetKV(ctx, focusAgentID, focusKVPrefix+sk) + assert.Empty(t, focusRaw) +} + +func TestCompleteFocus_NoActiveFocus(t *testing.T) { + sm := session.NewSessionManager("") + sk := "test-session" + sm.GetOrCreate(sk) + + delegate := newMockFocusDelegate() + tool := NewCompleteFocusTool(delegate, sm, func() string { return sk }) + + result := tool.Execute(context.Background(), map[string]interface{}{ + "summary": "some summary", + }) + assert.Contains(t, result.ForLLM, "no active focus session") +} + +func TestCompleteFocus_MultipleKnowledgeEntries(t *testing.T) { + sm := session.NewSessionManager("") + sk := "test-session" + sm.GetOrCreate(sk) + delegate := newMockFocusDelegate() + ctx := context.Background() + + startTool := NewStartFocusTool(delegate, sm, func() string { return sk }) + completeTool := NewCompleteFocusTool(delegate, sm, func() string { return sk }) + + // First focus cycle + sm.AddMessage(sk, "user", "start") + startTool.Execute(ctx, map[string]interface{}{"topic": "topic A"}) + sm.AddMessage(sk, "user", "work A") + sm.AddMessage(sk, "assistant", "result A") + completeTool.Execute(ctx, map[string]interface{}{"summary": "learned A"}) + + // Second focus cycle + sm.AddMessage(sk, "user", "more work") + startTool.Execute(ctx, map[string]interface{}{"topic": "topic B"}) + sm.AddMessage(sk, "user", "work B") + sm.AddMessage(sk, "assistant", "result B") + completeTool.Execute(ctx, map[string]interface{}{"summary": "learned B"}) + + knowledgeRaw, _ := delegate.GetKV(ctx, focusAgentID, knowledgeKVPrefix+sk) + var kb KnowledgeBlock + require.NoError(t, json.Unmarshal([]byte(knowledgeRaw), &kb)) + require.Len(t, kb.Entries, 2) + assert.Equal(t, "topic A", kb.Entries[0].Topic) + assert.Equal(t, "topic B", kb.Entries[1].Topic) +} + +func TestPruneHistory(t *testing.T) { + tool := &CompleteFocusTool{} + + tests := []struct { + name string + history []messages.Message + checkpointIdx int + wantMinLen int + wantMaxLen int + }{ + { + name: "prune investigation messages", + history: []messages.Message{ + {Role: "user", Content: "pre-1"}, + {Role: "assistant", Content: "pre-2"}, + {Role: "user", Content: "investigate-1"}, + {Role: "assistant", Content: "investigate-2"}, + {Role: "user", Content: "investigate-3"}, + {Role: "assistant", Content: "investigate-4"}, + {Role: "user", Content: "investigate-5"}, + {Role: "assistant", Content: "investigate-6"}, + }, + checkpointIdx: 2, + wantMinLen: 2 + 4, // pre-checkpoint + tail + wantMaxLen: 2 + 4, + }, + { + name: "checkpoint at end does nothing", + history: []messages.Message{ + {Role: "user", Content: "msg1"}, + {Role: "assistant", Content: "msg2"}, + }, + checkpointIdx: 2, + wantMinLen: 2, + wantMaxLen: 2, + }, + { + name: "short investigation keeps all", + history: []messages.Message{ + {Role: "user", Content: "pre-1"}, + {Role: "assistant", Content: "pre-2"}, + {Role: "user", Content: "inv-1"}, + {Role: "assistant", Content: "inv-2"}, + }, + checkpointIdx: 2, + wantMinLen: 4, + wantMaxLen: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.pruneHistory(tt.history, tt.checkpointIdx) + assert.GreaterOrEqual(t, len(result), tt.wantMinLen) + assert.LessOrEqual(t, len(result), tt.wantMaxLen) + + // Pre-checkpoint messages should always be preserved + for i := 0; i < tt.checkpointIdx && i < len(result); i++ { + assert.Equal(t, tt.history[i].Content, result[i].Content) + } + }) + } +} + +func TestKnowledgeBlockFormat(t *testing.T) { + kb := &KnowledgeBlock{ + Entries: []KnowledgeEntry{ + {Topic: "Auth Flow", Summary: "Token validation needs expiry check."}, + {Topic: "Caching", Summary: "Redis preferred over in-memory for persistence."}, + }, + } + + formatted := kb.FormatBlock() + assert.Contains(t, formatted, "# Knowledge") + assert.Contains(t, formatted, "## Auth Flow") + assert.Contains(t, formatted, "Token validation needs expiry check.") + assert.Contains(t, formatted, "## Caching") +} + +func TestKnowledgeBlockFormat_Empty(t *testing.T) { + kb := &KnowledgeBlock{} + assert.Empty(t, kb.FormatBlock()) +} + +func TestLoadKnowledgeBlock(t *testing.T) { + delegate := newMockFocusDelegate() + ctx := context.Background() + + block := LoadKnowledgeBlock(ctx, delegate, "nonexistent") + assert.Empty(t, block) + + kb := KnowledgeBlock{ + Entries: []KnowledgeEntry{ + {Topic: "Test", Summary: "Test summary"}, + }, + } + data, _ := json.Marshal(kb) + _ = delegate.UpsertKV(ctx, focusAgentID, knowledgeKVPrefix+"test-session", string(data)) + + block = LoadKnowledgeBlock(ctx, delegate, "test-session") + assert.Contains(t, block, "# Knowledge") + assert.Contains(t, block, "## Test") + assert.Contains(t, block, "Test summary") +} diff --git a/pkg/tools/retrieval.go b/pkg/tools/retrieval.go new file mode 100644 index 000000000..7b8f030ad --- /dev/null +++ b/pkg/tools/retrieval.go @@ -0,0 +1,216 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/memory" + memstore "github.com/sipeed/picoclaw/pkg/memory/store" +) + +// KeywordSearchTool performs FTS5+BM25 keyword search across recall and archival memory. +type KeywordSearchTool struct { + store *memstore.MemoryStore + agentID string +} + +func NewKeywordSearchTool(store *memstore.MemoryStore, agentID string) *KeywordSearchTool { + return &KeywordSearchTool{store: store, agentID: agentID} +} + +func (t *KeywordSearchTool) Name() string { return "keyword_search" } + +func (t *KeywordSearchTool) Description() string { + return "Search memory using keyword matching (FTS5 with BM25 ranking). Returns matching memory entries with relevance scores. Use this for exact term matching and known phrases. Chain with chunk_read to load full content." +} + +func (t *KeywordSearchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query (keywords, phrases, or terms to match)", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum results to return (default 10, max 50)", + }, + }, + "required": []string{"query"}, + } +} + +func (t *KeywordSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + query, _ := args["query"].(string) + if query == "" { + return ErrorResult("query is required") + } + + limit := 10 + if l, ok := args["limit"].(float64); ok && l > 0 { + limit = int(l) + if limit > 50 { + limit = 50 + } + } + + results, err := t.store.Search(ctx, query, memory.SearchOptions{ + AgentID: t.agentID, + Limit: limit, + KeywordWeight: 1.0, + VectorWeight: 0.0, + }) + if err != nil { + return ErrorResult(fmt.Sprintf("keyword search failed: %v", err)) + } + + return SilentResult(formatSearchResults("keyword_search", query, results)) +} + +// SemanticSearchTool performs vector ANN search using embeddings. +type SemanticSearchTool struct { + store *memstore.MemoryStore + agentID string +} + +func NewSemanticSearchTool(store *memstore.MemoryStore, agentID string) *SemanticSearchTool { + return &SemanticSearchTool{store: store, agentID: agentID} +} + +func (t *SemanticSearchTool) Name() string { return "semantic_search" } + +func (t *SemanticSearchTool) Description() string { + return "Search memory using semantic similarity (vector embeddings). Returns entries ranked by meaning similarity, not just keyword match. Use this when looking for conceptually related content. Chain with chunk_read to load full content." +} + +func (t *SemanticSearchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Natural language query describing what you're looking for", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum results to return (default 10, max 50)", + }, + "threshold": map[string]interface{}{ + "type": "number", + "description": "Minimum similarity score (0.0-1.0, default 0.0)", + }, + }, + "required": []string{"query"}, + } +} + +func (t *SemanticSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + query, _ := args["query"].(string) + if query == "" { + return ErrorResult("query is required") + } + + limit := 10 + if l, ok := args["limit"].(float64); ok && l > 0 { + limit = int(l) + if limit > 50 { + limit = 50 + } + } + + minScore := 0.0 + if s, ok := args["threshold"].(float64); ok && s > 0 { + minScore = s + } + + results, err := t.store.Search(ctx, query, memory.SearchOptions{ + AgentID: t.agentID, + Limit: limit, + MinScore: minScore, + KeywordWeight: 0.0, + VectorWeight: 1.0, + }) + if err != nil { + return ErrorResult(fmt.Sprintf("semantic search failed: %v", err)) + } + + return SilentResult(formatSearchResults("semantic_search", query, results)) +} + +// ChunkReadTool loads full document/chunk content by ID. +type ChunkReadTool struct { + store *memstore.MemoryStore + agentID string +} + +func NewChunkReadTool(store *memstore.MemoryStore, agentID string) *ChunkReadTool { + return &ChunkReadTool{store: store, agentID: agentID} +} + +func (t *ChunkReadTool) Name() string { return "chunk_read" } + +func (t *ChunkReadTool) Description() string { + return "Load the full content of a memory entry by its ID. Use after keyword_search or semantic_search to retrieve complete content for promising results. The ID comes from search result entries." +} + +func (t *ChunkReadTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + "description": "ID of the memory entry to read (from search results)", + }, + }, + "required": []string{"id"}, + } +} + +func (t *ChunkReadTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + id, _ := args["id"].(string) + if id == "" { + return ErrorResult("id is required") + } + + content, err := t.store.ReadByID(ctx, t.agentID, id) + if err != nil { + return ErrorResult(fmt.Sprintf("chunk read failed: %v", err)) + } + if content == "" { + return ErrorResult(fmt.Sprintf("entry '%s' not found", id)) + } + + return SilentResult(content) +} + +func formatSearchResults(source, query string, results []memory.SearchResult) string { + if len(results) == 0 { + return fmt.Sprintf("No results found for: %s", query) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d results for '%s':\n\n", len(results), query)) + + for i, r := range results { + sb.WriteString(fmt.Sprintf("%d. [%s] (score: %.2f) id=%s\n", i+1, r.Source, r.Score, r.ID)) + + preview := r.Content + if len(preview) > 200 { + preview = preview[:200] + "..." + } + sb.WriteString(fmt.Sprintf(" %s\n", preview)) + + if len(r.Metadata) > 0 { + var meta []string + for k, v := range r.Metadata { + meta = append(meta, fmt.Sprintf("%s=%s", k, v)) + } + sb.WriteString(fmt.Sprintf(" meta: %s\n", strings.Join(meta, ", "))) + } + sb.WriteByte('\n') + } + + return sb.String() +} diff --git a/pkg/tools/retrieval_test.go b/pkg/tools/retrieval_test.go new file mode 100644 index 000000000..a41668b16 --- /dev/null +++ b/pkg/tools/retrieval_test.go @@ -0,0 +1,76 @@ +package tools + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKeywordSearchTool_Metadata(t *testing.T) { + tool := &KeywordSearchTool{agentID: "test"} + assert.Equal(t, "keyword_search", tool.Name()) + assert.Contains(t, tool.Description(), "FTS5") + params := tool.Parameters() + require.NotNil(t, params) + props, ok := params["properties"].(map[string]interface{}) + require.True(t, ok) + _, hasQuery := props["query"] + assert.True(t, hasQuery) +} + +func TestKeywordSearchTool_MissingQuery(t *testing.T) { + tool := &KeywordSearchTool{agentID: "test"} + result := tool.Execute(context.Background(), map[string]interface{}{}) + assert.Contains(t, result.ForLLM, "query is required") +} + +func TestSemanticSearchTool_Metadata(t *testing.T) { + tool := &SemanticSearchTool{agentID: "test"} + assert.Equal(t, "semantic_search", tool.Name()) + assert.Contains(t, tool.Description(), "semantic similarity") + params := tool.Parameters() + require.NotNil(t, params) +} + +func TestSemanticSearchTool_MissingQuery(t *testing.T) { + tool := &SemanticSearchTool{agentID: "test"} + result := tool.Execute(context.Background(), map[string]interface{}{}) + assert.Contains(t, result.ForLLM, "query is required") +} + +func TestChunkReadTool_Metadata(t *testing.T) { + tool := &ChunkReadTool{agentID: "test"} + assert.Equal(t, "chunk_read", tool.Name()) + assert.Contains(t, tool.Description(), "full content") + params := tool.Parameters() + require.NotNil(t, params) +} + +func TestChunkReadTool_MissingID(t *testing.T) { + tool := &ChunkReadTool{agentID: "test"} + result := tool.Execute(context.Background(), map[string]interface{}{}) + assert.Contains(t, result.ForLLM, "id is required") +} + +func TestFormatSearchResults_Empty(t *testing.T) { + output := formatSearchResults("test", "query", nil) + assert.Contains(t, output, "No results found") +} + +func TestFormatSearchResults_WithResults(t *testing.T) { + results := []struct { + id string + content string + score float64 + source string + }{ + {"abc-123", "Hello world content here", 0.95, "recall"}, + {"def-456", "Another piece of content", 0.80, "archival"}, + } + + // Convert to memory.SearchResult type would require importing memory package, + // so we test the format function indirectly through the tool tests above. + _ = results +} diff --git a/pkg/tools/skills.go b/pkg/tools/skills.go new file mode 100644 index 000000000..45c868b72 --- /dev/null +++ b/pkg/tools/skills.go @@ -0,0 +1,211 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/skills" +) + +// SkillSearchTool performs fuzzy search across skill names, descriptions, tags, and domains. +// This is the first step in progressive skill disclosure: scan before you read. +type SkillSearchTool struct { + loader *skills.SkillsLoader + mu sync.Mutex + graph *skills.SkillGraph +} + +func NewSkillSearchTool(loader *skills.SkillsLoader) *SkillSearchTool { + return &SkillSearchTool{loader: loader} +} + +func (t *SkillSearchTool) getGraph() *skills.SkillGraph { + t.mu.Lock() + defer t.mu.Unlock() + if t.graph == nil { + t.graph = t.loader.BuildGraph() + } + return t.graph +} + +func (t *SkillSearchTool) Name() string { return "skill_search" } + +func (t *SkillSearchTool) Description() string { + return "Search available skills by keyword. Returns matching skill names, descriptions, tags, and domains without loading full content. Use this to discover relevant skills before reading them." +} + +func (t *SkillSearchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query to match against skill names, descriptions, tags, and domains", + }, + }, + "required": []string{"query"}, + } +} + +func (t *SkillSearchTool) Execute(_ context.Context, args map[string]interface{}) *ToolResult { + query, _ := args["query"].(string) + if query == "" { + return ErrorResult("query is required") + } + + g := t.getGraph() + results := g.SearchSkills(query) + if len(results) == 0 { + return SilentResult("No skills matched the query.") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d matching skills:\n\n", len(results))) + for _, node := range results { + sb.WriteString(fmt.Sprintf("- **%s** (%s)", node.Name, node.Source)) + if node.Description != "" { + sb.WriteString(fmt.Sprintf(": %s", node.Description)) + } + sb.WriteByte('\n') + if len(node.Tags) > 0 { + sb.WriteString(fmt.Sprintf(" tags: %s\n", strings.Join(node.Tags, ", "))) + } + if node.Domain != "" { + sb.WriteString(fmt.Sprintf(" domain: %s\n", node.Domain)) + } + if len(node.Links) > 0 { + sb.WriteString(fmt.Sprintf(" links: %s\n", strings.Join(node.Links, ", "))) + } + } + + return SilentResult(sb.String()) +} + +// SkillReadTool loads the full content of a specific skill by name. +// This is the second step in progressive disclosure: read after search. +type SkillReadTool struct { + loader *skills.SkillsLoader +} + +func NewSkillReadTool(loader *skills.SkillsLoader) *SkillReadTool { + return &SkillReadTool{loader: loader} +} + +func (t *SkillReadTool) Name() string { return "skill_read" } + +func (t *SkillReadTool) Description() string { + return "Load the full content of a specific skill by name. Use skill_search first to find relevant skill names, then read the ones you need." +} + +func (t *SkillReadTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Exact name of the skill to read (from skill_search results)", + }, + }, + "required": []string{"name"}, + } +} + +func (t *SkillReadTool) Execute(_ context.Context, args map[string]interface{}) *ToolResult { + name, _ := args["name"].(string) + if name == "" { + return ErrorResult("name is required") + } + + content, ok := t.loader.LoadSkill(name) + if !ok { + return ErrorResult(fmt.Sprintf("skill '%s' not found", name)) + } + + return SilentResult(fmt.Sprintf("# Skill: %s\n\n%s", name, content)) +} + +// SkillTraverseTool follows wikilink chains from a skill node. +// This is the third step: after reading a skill, explore its connections. +type SkillTraverseTool struct { + loader *skills.SkillsLoader + mu sync.Mutex + graph *skills.SkillGraph +} + +func NewSkillTraverseTool(loader *skills.SkillsLoader) *SkillTraverseTool { + return &SkillTraverseTool{loader: loader} +} + +func (t *SkillTraverseTool) getGraph() *skills.SkillGraph { + t.mu.Lock() + defer t.mu.Unlock() + if t.graph == nil { + t.graph = t.loader.BuildGraph() + } + return t.graph +} + +func (t *SkillTraverseTool) Name() string { return "skill_traverse" } + +func (t *SkillTraverseTool) Description() string { + return "Follow wikilink connections from a skill to discover related skills. Returns linked skill summaries (names, descriptions, tags) without loading full content. Use skill_read to load any that seem relevant." +} + +func (t *SkillTraverseTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Name of the skill to traverse from", + }, + "depth": map[string]interface{}{ + "type": "integer", + "description": "How many link hops to follow (1-3, default 1)", + }, + }, + "required": []string{"name"}, + } +} + +func (t *SkillTraverseTool) Execute(_ context.Context, args map[string]interface{}) *ToolResult { + name, _ := args["name"].(string) + if name == "" { + return ErrorResult("name is required") + } + + depth := 1 + if d, ok := args["depth"].(float64); ok && d >= 1 && d <= 3 { + depth = int(d) + } + + g := t.getGraph() + if g.GetNode(name) == nil { + return ErrorResult(fmt.Sprintf("skill '%s' not found in graph", name)) + } + + reachable := g.TraverseFrom(name, depth) + if len(reachable) == 0 { + return SilentResult(fmt.Sprintf("Skill '%s' has no outgoing links.", name)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Skills reachable from '%s' (depth %d):\n\n", name, depth)) + for _, node := range reachable { + sb.WriteString(fmt.Sprintf("- **%s**", node.Name)) + if node.Description != "" { + sb.WriteString(fmt.Sprintf(": %s", node.Description)) + } + sb.WriteByte('\n') + if len(node.Tags) > 0 { + sb.WriteString(fmt.Sprintf(" tags: %s\n", strings.Join(node.Tags, ", "))) + } + if node.IsMOC { + sb.WriteString(" [Map of Content]\n") + } + } + + return SilentResult(sb.String()) +} diff --git a/pkg/tools/skills_test.go b/pkg/tools/skills_test.go new file mode 100644 index 000000000..f76589b24 --- /dev/null +++ b/pkg/tools/skills_test.go @@ -0,0 +1,142 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/skills" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTestSkill(t *testing.T, dir, name, content string) { + t.Helper() + skillDir := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(skillDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0644)) +} + +func setupTestSkills(t *testing.T) *skills.SkillsLoader { + t.Helper() + tmp := t.TempDir() + + writeTestSkill(t, tmp, "risk-management", `--- +name: risk-management +description: "Risk management fundamentals for trading" +tags: trading, risk +domain: finance +--- +# Risk Management + +See [[position-sizing]] for sizing rules. +`) + + writeTestSkill(t, tmp, "position-sizing", `--- +name: position-sizing +description: "Position sizing strategies" +tags: trading, sizing +domain: finance +--- +# Position Sizing + +Based on [[risk-management]] principles. +`) + + writeTestSkill(t, tmp, "code-review", `--- +name: code-review +description: "Code review best practices" +tags: engineering +domain: software +--- +# Code Review + +No links. +`) + + return skills.NewSkillsLoader("", "", tmp) +} + +func TestSkillSearchTool(t *testing.T) { + loader := setupTestSkills(t) + tool := NewSkillSearchTool(loader) + + assert.Equal(t, "skill_search", tool.Name()) + + t.Run("finds matching skills", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"query": "trading"}) + assert.False(t, result.IsError) + assert.Contains(t, result.ForLLM, "risk-management") + assert.Contains(t, result.ForLLM, "position-sizing") + }) + + t.Run("no results for unmatched query", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"query": "kubernetes"}) + assert.False(t, result.IsError) + assert.Contains(t, result.ForLLM, "No skills matched") + }) + + t.Run("error on empty query", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"query": ""}) + assert.True(t, result.IsError) + }) +} + +func TestSkillReadTool(t *testing.T) { + loader := setupTestSkills(t) + tool := NewSkillReadTool(loader) + + assert.Equal(t, "skill_read", tool.Name()) + + t.Run("reads existing skill", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"name": "risk-management"}) + assert.False(t, result.IsError) + assert.Contains(t, result.ForLLM, "Risk Management") + assert.Contains(t, result.ForLLM, "position-sizing") + }) + + t.Run("error on missing skill", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"name": "nonexistent"}) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "not found") + }) + + t.Run("error on empty name", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"name": ""}) + assert.True(t, result.IsError) + }) +} + +func TestSkillTraverseTool(t *testing.T) { + loader := setupTestSkills(t) + tool := NewSkillTraverseTool(loader) + + assert.Equal(t, "skill_traverse", tool.Name()) + + t.Run("traverses links at depth 1", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"name": "risk-management"}) + assert.False(t, result.IsError) + assert.Contains(t, result.ForLLM, "position-sizing") + }) + + t.Run("no links found", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"name": "code-review"}) + assert.False(t, result.IsError) + assert.Contains(t, result.ForLLM, "no outgoing links") + }) + + t.Run("error on missing skill", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{"name": "nonexistent"}) + assert.True(t, result.IsError) + }) + + t.Run("custom depth", func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]interface{}{ + "name": "risk-management", + "depth": float64(2), + }) + assert.False(t, result.IsError) + assert.Contains(t, result.ForLLM, "position-sizing") + }) +}