feat: añadir sistema de knowledge por agente
Implementa una base de conocimiento persistente por agente siguiendo el patrón pure core / impure shell: - pkg/knowledge/: tipos puros (Document, Store interface) - shell/knowledge/: FileStore con SQLite para indexación y archivos .md - tools/knowledge.go: 4 tools LLM (search, read, write, list) - tools/knowledge_test.go: tests unitarios de las tools - internal/config/schema.go: nuevo KnowledgeToolCfg en ToolsCfg - agents/runtime.go: inicialización del store y registro de tools - agents/*/knowledge/about-me.md: documentos semilla para cada agente Cada agente puede buscar, leer, crear y actualizar documentos de conocimiento. Los archivos .md viven en agents/<id>/knowledge/ y se indexan en SQLite (agents/<id>/data/knowledge.db). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/enmanuel/agents/pkg/knowledge"
|
||||
)
|
||||
|
||||
// KnowledgeStore is the subset of knowledge.Store needed by knowledge tools.
|
||||
type KnowledgeStore interface {
|
||||
Search(ctx context.Context, query string, limit int) ([]knowledge.SearchResult, error)
|
||||
Get(ctx context.Context, slug string) (*knowledge.Document, error)
|
||||
Put(ctx context.Context, doc knowledge.Document) error
|
||||
List(ctx context.Context) ([]knowledge.Document, error)
|
||||
}
|
||||
|
||||
// NewKnowledgeSearch creates a tool that searches the knowledge base.
|
||||
func NewKnowledgeSearch(store KnowledgeStore) Tool {
|
||||
return Tool{
|
||||
Def: Def{
|
||||
Name: "knowledge_search",
|
||||
Description: "Search your knowledge base for relevant documents. Returns matching snippets ranked by relevance.",
|
||||
Parameters: []Param{
|
||||
{Name: "query", Type: "string", Description: "Search terms or phrase", Required: true},
|
||||
{Name: "limit", Type: "integer", Description: "Max results (default 5)", Required: false},
|
||||
},
|
||||
},
|
||||
Exec: func(ctx context.Context, args map[string]any) Result {
|
||||
query := getString(args, "query")
|
||||
if query == "" {
|
||||
return Result{Err: fmt.Errorf("knowledge_search: query is required")}
|
||||
}
|
||||
limit := getInt(args, "limit")
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
|
||||
results, err := store.Search(ctx, query, limit)
|
||||
if err != nil {
|
||||
return Result{Err: fmt.Errorf("knowledge_search: %w", err)}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return Result{Output: "no documents found matching your query"}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, r := range results {
|
||||
fmt.Fprintf(&sb, "%d. **%s** (`%s`)\n %s\n", i+1, r.Title, r.Slug, r.Snippet)
|
||||
}
|
||||
return Result{Output: sb.String()}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewKnowledgeRead creates a tool that reads a knowledge document.
|
||||
func NewKnowledgeRead(store KnowledgeStore) Tool {
|
||||
return Tool{
|
||||
Def: Def{
|
||||
Name: "knowledge_read",
|
||||
Description: "Read the full content of a knowledge document by its slug.",
|
||||
Parameters: []Param{
|
||||
{Name: "slug", Type: "string", Description: "Document slug (e.g. \"go-patterns\")", Required: true},
|
||||
},
|
||||
},
|
||||
Exec: func(ctx context.Context, args map[string]any) Result {
|
||||
slug := getString(args, "slug")
|
||||
if slug == "" {
|
||||
return Result{Err: fmt.Errorf("knowledge_read: slug is required")}
|
||||
}
|
||||
|
||||
doc, err := store.Get(ctx, slug)
|
||||
if err != nil {
|
||||
return Result{Err: fmt.Errorf("knowledge_read: %w", err)}
|
||||
}
|
||||
return Result{Output: doc.Content}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewKnowledgeWrite creates a tool that writes a knowledge document.
|
||||
func NewKnowledgeWrite(store KnowledgeStore) Tool {
|
||||
return Tool{
|
||||
Def: Def{
|
||||
Name: "knowledge_write",
|
||||
Description: "Create or update a knowledge document. Use this to save new knowledge or improve existing documents.",
|
||||
Parameters: []Param{
|
||||
{Name: "slug", Type: "string", Description: "Document slug (lowercase, hyphens, e.g. \"matrix-tips\")", Required: true},
|
||||
{Name: "content", Type: "string", Description: "Full markdown content of the document", Required: true},
|
||||
},
|
||||
},
|
||||
Exec: func(ctx context.Context, args map[string]any) Result {
|
||||
slug := getString(args, "slug")
|
||||
content := getString(args, "content")
|
||||
if slug == "" || content == "" {
|
||||
return Result{Err: fmt.Errorf("knowledge_write: slug and content are required")}
|
||||
}
|
||||
|
||||
err := store.Put(ctx, knowledge.Document{
|
||||
Slug: slug,
|
||||
Content: content,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{Err: fmt.Errorf("knowledge_write: %w", err)}
|
||||
}
|
||||
return Result{Output: fmt.Sprintf("document saved: %s (%d bytes)", slug, len(content))}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewKnowledgeList creates a tool that lists all knowledge documents.
|
||||
func NewKnowledgeList(store KnowledgeStore) Tool {
|
||||
return Tool{
|
||||
Def: Def{
|
||||
Name: "knowledge_list",
|
||||
Description: "List all documents in your knowledge base with their titles.",
|
||||
Parameters: []Param{},
|
||||
},
|
||||
Exec: func(ctx context.Context, args map[string]any) Result {
|
||||
docs, err := store.List(ctx)
|
||||
if err != nil {
|
||||
return Result{Err: fmt.Errorf("knowledge_list: %w", err)}
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return Result{Output: "knowledge base is empty"}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, d := range docs {
|
||||
fmt.Fprintf(&sb, "- `%s`: %s (updated %s)\n",
|
||||
d.Slug, d.Title, d.UpdatedAt.Format("2006-01-02"))
|
||||
}
|
||||
return Result{Output: sb.String()}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getInt extracts an integer argument by name, returning 0 if missing or wrong type.
|
||||
func getInt(args map[string]any, key string) int {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/enmanuel/agents/pkg/knowledge"
|
||||
)
|
||||
|
||||
// mockKnowledgeStore implements KnowledgeStore for testing.
|
||||
type mockKnowledgeStore struct {
|
||||
docs map[string]knowledge.Document
|
||||
}
|
||||
|
||||
func newMockKnowledgeStore() *mockKnowledgeStore {
|
||||
return &mockKnowledgeStore{docs: make(map[string]knowledge.Document)}
|
||||
}
|
||||
|
||||
func (m *mockKnowledgeStore) Search(_ context.Context, query string, limit int) ([]knowledge.SearchResult, error) {
|
||||
var results []knowledge.SearchResult
|
||||
for _, d := range m.docs {
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
results = append(results, knowledge.SearchResult{
|
||||
Slug: d.Slug,
|
||||
Title: d.Title,
|
||||
Snippet: d.Content[:min(len(d.Content), 50)],
|
||||
Rank: 1.0,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *mockKnowledgeStore) Get(_ context.Context, slug string) (*knowledge.Document, error) {
|
||||
d, ok := m.docs[slug]
|
||||
if !ok {
|
||||
return nil, ¬FoundError{slug}
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (m *mockKnowledgeStore) Put(_ context.Context, doc knowledge.Document) error {
|
||||
m.docs[doc.Slug] = doc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockKnowledgeStore) List(_ context.Context) ([]knowledge.Document, error) {
|
||||
var docs []knowledge.Document
|
||||
for _, d := range m.docs {
|
||||
docs = append(docs, d)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
type notFoundError struct{ slug string }
|
||||
|
||||
func (e *notFoundError) Error() string { return "not found: " + e.slug }
|
||||
|
||||
func TestKnowledgeSearchTool(t *testing.T) {
|
||||
store := newMockKnowledgeStore()
|
||||
store.docs["go-patterns"] = knowledge.Document{
|
||||
Slug: "go-patterns", Title: "Go Patterns", Content: "Use interfaces",
|
||||
}
|
||||
|
||||
tool := NewKnowledgeSearch(store)
|
||||
if tool.Def.Name != "knowledge_search" {
|
||||
t.Errorf("name = %q, want knowledge_search", tool.Def.Name)
|
||||
}
|
||||
|
||||
// Missing query
|
||||
r := tool.Exec(context.Background(), map[string]any{})
|
||||
if r.Err == nil {
|
||||
t.Error("expected error for missing query")
|
||||
}
|
||||
|
||||
// Valid search
|
||||
r = tool.Exec(context.Background(), map[string]any{"query": "go"})
|
||||
if r.Err != nil {
|
||||
t.Errorf("unexpected error: %v", r.Err)
|
||||
}
|
||||
if r.Output == "" {
|
||||
t.Error("expected non-empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeReadTool(t *testing.T) {
|
||||
store := newMockKnowledgeStore()
|
||||
store.docs["test-doc"] = knowledge.Document{
|
||||
Slug: "test-doc", Title: "Test", Content: "Hello world",
|
||||
}
|
||||
|
||||
tool := NewKnowledgeRead(store)
|
||||
|
||||
// Missing slug
|
||||
r := tool.Exec(context.Background(), map[string]any{})
|
||||
if r.Err == nil {
|
||||
t.Error("expected error for missing slug")
|
||||
}
|
||||
|
||||
// Valid read
|
||||
r = tool.Exec(context.Background(), map[string]any{"slug": "test-doc"})
|
||||
if r.Err != nil {
|
||||
t.Errorf("unexpected error: %v", r.Err)
|
||||
}
|
||||
if r.Output != "Hello world" {
|
||||
t.Errorf("output = %q, want %q", r.Output, "Hello world")
|
||||
}
|
||||
|
||||
// Not found
|
||||
r = tool.Exec(context.Background(), map[string]any{"slug": "nope"})
|
||||
if r.Err == nil {
|
||||
t.Error("expected error for nonexistent doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeWriteTool(t *testing.T) {
|
||||
store := newMockKnowledgeStore()
|
||||
tool := NewKnowledgeWrite(store)
|
||||
|
||||
// Missing params
|
||||
r := tool.Exec(context.Background(), map[string]any{"slug": "test"})
|
||||
if r.Err == nil {
|
||||
t.Error("expected error for missing content")
|
||||
}
|
||||
|
||||
// Valid write
|
||||
r = tool.Exec(context.Background(), map[string]any{
|
||||
"slug": "new-doc",
|
||||
"content": "# New Doc\nSome content",
|
||||
})
|
||||
if r.Err != nil {
|
||||
t.Errorf("unexpected error: %v", r.Err)
|
||||
}
|
||||
if _, ok := store.docs["new-doc"]; !ok {
|
||||
t.Error("document was not stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeListTool(t *testing.T) {
|
||||
store := newMockKnowledgeStore()
|
||||
tool := NewKnowledgeList(store)
|
||||
|
||||
// Empty
|
||||
r := tool.Exec(context.Background(), map[string]any{})
|
||||
if r.Err != nil {
|
||||
t.Errorf("unexpected error: %v", r.Err)
|
||||
}
|
||||
if r.Output != "knowledge base is empty" {
|
||||
t.Errorf("expected empty message, got %q", r.Output)
|
||||
}
|
||||
|
||||
// With docs
|
||||
store.docs["doc1"] = knowledge.Document{Slug: "doc1", Title: "Doc 1"}
|
||||
r = tool.Exec(context.Background(), map[string]any{})
|
||||
if r.Err != nil {
|
||||
t.Errorf("unexpected error: %v", r.Err)
|
||||
}
|
||||
if r.Output == "knowledge base is empty" {
|
||||
t.Error("expected non-empty output after adding docs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
args map[string]any
|
||||
key string
|
||||
want int
|
||||
}{
|
||||
{map[string]any{"n": float64(5)}, "n", 5},
|
||||
{map[string]any{"n": 3}, "n", 3},
|
||||
{map[string]any{"n": "str"}, "n", 0},
|
||||
{map[string]any{}, "n", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := getInt(tt.args, tt.key)
|
||||
if got != tt.want {
|
||||
t.Errorf("getInt(%v, %q) = %d, want %d", tt.args, tt.key, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user