diff --git a/agents/asistente-2/knowledge/about-me.md b/agents/asistente-2/knowledge/about-me.md new file mode 100644 index 0000000..e177864 --- /dev/null +++ b/agents/asistente-2/knowledge/about-me.md @@ -0,0 +1,14 @@ +# About Me + +Soy Asistente 2, un asistente con herramientas que opera en Matrix. + +## Capacidades +- Responder preguntas generales +- Consultar la hora y fecha actual +- Recordar información usando memoria a largo plazo +- Buscar y mantener una base de conocimiento +- Resumir texto y documentos + +## Servidor +- Homeserver: matrix-af2f3d.organic-machine.com +- Idioma principal: español diff --git a/agents/assistant-bot/knowledge/about-me.md b/agents/assistant-bot/knowledge/about-me.md new file mode 100644 index 0000000..cd76ee2 --- /dev/null +++ b/agents/assistant-bot/knowledge/about-me.md @@ -0,0 +1,16 @@ +# About Me + +Soy Assistant Bot, un asistente conversacional general que opera en Matrix. + +## Capacidades +- Responder preguntas generales +- Resumir texto y documentos +- Redactar textos, emails, documentación +- Explicar conceptos técnicos y no técnicos +- Ayudar con código: revisar, corregir, explicar +- Recordar información usando memoria a largo plazo +- Buscar y mantener una base de conocimiento + +## Servidor +- Homeserver: matrix-af2f3d.organic-machine.com +- Idioma principal: español diff --git a/agents/runtime.go b/agents/runtime.go index 0285352..f846b58 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -21,6 +21,7 @@ import ( "github.com/enmanuel/agents/pkg/personality" "github.com/enmanuel/agents/shell/bus" "github.com/enmanuel/agents/shell/effects" + shellknowledge "github.com/enmanuel/agents/shell/knowledge" shelllm "github.com/enmanuel/agents/shell/llm" "github.com/enmanuel/agents/shell/matrix" shellmem "github.com/enmanuel/agents/shell/memory" @@ -53,6 +54,9 @@ type Agent struct { windowSize int roomCtx *tools.RoomContext + // Knowledge store — non-nil when knowledge is enabled + knowledgeStore *shellknowledge.FileStore + // Bus — set via SetBus() when running under the unified launcher agentBus *bus.Bus } @@ -159,8 +163,27 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (* logger.Info("memory enabled", "window_size", windowSize, "db", dbPath) } + // Knowledge store + var kStore *shellknowledge.FileStore + if cfg.Tools.Knowledge.Enabled { + knowledgeDir := cfg.Tools.Knowledge.Dir + if knowledgeDir == "" { + knowledgeDir = filepath.Join("agents", cfg.Agent.ID, "knowledge") + } + knowledgeDBPath := filepath.Join("agents", cfg.Agent.ID, "data", "knowledge.db") + var kErr error + kStore, kErr = shellknowledge.New(knowledgeDir, knowledgeDBPath, logger) + if kErr != nil { + logger.Error("knowledge_store_init_failed", "err", kErr) + } else { + if syncErr := kStore.Sync(context.Background()); syncErr != nil { + logger.Error("knowledge_sync_failed", "err", syncErr) + } + } + } + // Tool registry — register tools enabled in config - toolReg := buildToolRegistry(cfg, sshExec, matrixClient, memStore, roomCtx, logger) + toolReg := buildToolRegistry(cfg, sshExec, matrixClient, memStore, kStore, roomCtx, logger) a := &Agent{ cfg: cfg, @@ -171,10 +194,11 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (* toolReg: toolReg, logger: logger, cryptoStore: cryptoStore, - windows: make(map[string]memory.Window), - memStore: memStore, - windowSize: windowSize, - roomCtx: roomCtx, + windows: make(map[string]memory.Window), + memStore: memStore, + knowledgeStore: kStore, + windowSize: windowSize, + roomCtx: roomCtx, } // Register memory_clear_context with self as WindowClearer (after a is created) @@ -217,6 +241,9 @@ func (a *Agent) Run(ctx context.Context) error { if a.memStore != nil { defer a.memStore.Close() } + if a.knowledgeStore != nil { + defer a.knowledgeStore.Close() + } a.logger.Info("agent starting", "id", a.cfg.Agent.ID, "name", a.cfg.Agent.Name, @@ -605,6 +632,7 @@ func buildToolRegistry( sshExec *ssh.Executor, matrixClient *matrix.Client, memStore memory.Store, + kStore *shellknowledge.FileStore, roomCtx *tools.RoomContext, logger *slog.Logger, ) *tools.Registry { @@ -643,5 +671,14 @@ func buildToolRegistry( logger.Debug("registered memory tools") } + // Knowledge tools + if cfg.Tools.Knowledge.Enabled && kStore != nil { + reg.Register(tools.NewKnowledgeSearch(kStore)) + reg.Register(tools.NewKnowledgeRead(kStore)) + reg.Register(tools.NewKnowledgeWrite(kStore)) + reg.Register(tools.NewKnowledgeList(kStore)) + logger.Debug("registered knowledge tools") + } + return reg } diff --git a/internal/config/schema.go b/internal/config/schema.go index a4fc233..b6c44df 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -121,12 +121,18 @@ type LLMRateLimitCfg struct { // ── Tools ───────────────────────────────────────────────────────────────── type ToolsCfg struct { - SSH SSHToolCfg `yaml:"ssh"` - HTTP HTTPToolCfg `yaml:"http"` - Scripts ScriptsCfg `yaml:"scripts"` - FileOps FileOpsCfg `yaml:"file_ops"` - MCP MCPToolCfg `yaml:"mcp"` - Memory MemoryToolCfg `yaml:"memory"` + SSH SSHToolCfg `yaml:"ssh"` + HTTP HTTPToolCfg `yaml:"http"` + Scripts ScriptsCfg `yaml:"scripts"` + FileOps FileOpsCfg `yaml:"file_ops"` + MCP MCPToolCfg `yaml:"mcp"` + Memory MemoryToolCfg `yaml:"memory"` + Knowledge KnowledgeToolCfg `yaml:"knowledge"` +} + +type KnowledgeToolCfg struct { + Enabled bool `yaml:"enabled"` + Dir string `yaml:"dir"` // default: "./knowledge" (relative to agent dir) } type SSHToolCfg struct { diff --git a/pkg/knowledge/store.go b/pkg/knowledge/store.go new file mode 100644 index 0000000..faa9ba9 --- /dev/null +++ b/pkg/knowledge/store.go @@ -0,0 +1,25 @@ +package knowledge + +import "context" + +// Store is the pure interface for knowledge operations. +// Implemented by shell/knowledge. +type Store interface { + // Search performs full-text search across all documents. + Search(ctx context.Context, query string, limit int) ([]SearchResult, error) + + // Get retrieves a document by slug. + Get(ctx context.Context, slug string) (*Document, error) + + // Put creates or updates a document (file + index). + Put(ctx context.Context, doc Document) error + + // List returns all document slugs with titles. + List(ctx context.Context) ([]Document, error) + + // Sync re-indexes all files from disk. Called on startup. + Sync(ctx context.Context) error + + // Close releases resources. + Close() error +} diff --git a/pkg/knowledge/types.go b/pkg/knowledge/types.go new file mode 100644 index 0000000..2a37547 --- /dev/null +++ b/pkg/knowledge/types.go @@ -0,0 +1,20 @@ +// Package knowledge provides pure types for the agent knowledge base. +package knowledge + +import "time" + +// Document represents a knowledge document. +type Document struct { + Slug string // filename without extension, e.g. "go-patterns" + Title string // first H1 line from markdown, or humanized slug + Content string // full file content + UpdatedAt time.Time // file mtime +} + +// SearchResult is a document matched by a search query. +type SearchResult struct { + Slug string + Title string + Snippet string // relevant fragment with match highlights + Rank float64 // FTS5 relevance score +} diff --git a/shell/knowledge/sqlite_test.go b/shell/knowledge/sqlite_test.go new file mode 100644 index 0000000..0b397a9 --- /dev/null +++ b/shell/knowledge/sqlite_test.go @@ -0,0 +1,12 @@ +package shellknowledge + +import ( + "database/sql" + + moderncsqlite "modernc.org/sqlite" +) + +func init() { + // Register pure-Go SQLite driver as "sqlite3" for tests. + sql.Register("sqlite3", &moderncsqlite.Driver{}) +} diff --git a/shell/knowledge/store.go b/shell/knowledge/store.go new file mode 100644 index 0000000..d56dd6f --- /dev/null +++ b/shell/knowledge/store.go @@ -0,0 +1,291 @@ +// Package shellknowledge implements the knowledge store using files + SQLite FTS5. +package shellknowledge + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/enmanuel/agents/pkg/knowledge" +) + +const ftsSchema = ` +CREATE VIRTUAL TABLE IF NOT EXISTS documents USING fts5( + slug, + title, + content, + updated_at UNINDEXED +); +` + +var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`) + +// ValidSlug returns true if s is a valid document slug. +func ValidSlug(s string) bool { + if len(s) < 2 || len(s) > 64 { + return false + } + return slugRe.MatchString(s) +} + +// FileStore implements knowledge.Store using markdown files + SQLite FTS5 index. +type FileStore struct { + dir string // path to agents//knowledge/ + dbPath string // path to agents//data/knowledge.db + db *sql.DB + logger *slog.Logger +} + +// New creates a FileStore. It ensures the knowledge dir and DB dir exist, +// opens the SQLite database, and creates the FTS5 table if needed. +func New(dir, dbPath string, logger *slog.Logger) (*FileStore, error) { + log := logger.With("component", "knowledge", "dir", dir, "db_path", dbPath) + + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create knowledge dir: %w", err) + } + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + return nil, fmt.Errorf("create knowledge db dir: %w", err) + } + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("open knowledge db: %w", err) + } + if _, err := db.Exec(ftsSchema); err != nil { + db.Close() + return nil, fmt.Errorf("create knowledge fts5 table: %w", err) + } + + log.Info("knowledge_store_ready") + return &FileStore{dir: dir, dbPath: dbPath, db: db, logger: log}, nil +} + +// Sync re-indexes all .md files from disk into the FTS5 table. +func (s *FileStore) Sync(ctx context.Context) error { + entries, err := os.ReadDir(s.dir) + if err != nil { + return fmt.Errorf("read knowledge dir: %w", err) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin sync tx: %w", err) + } + defer tx.Rollback() + + // Clear existing index + if _, err := tx.ExecContext(ctx, `DELETE FROM documents`); err != nil { + return fmt.Errorf("clear fts5 index: %w", err) + } + + count := 0 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + slug := strings.TrimSuffix(e.Name(), ".md") + if !ValidSlug(slug) { + s.logger.Warn("skipping invalid slug", "file", e.Name()) + continue + } + + content, err := os.ReadFile(filepath.Join(s.dir, e.Name())) + if err != nil { + s.logger.Warn("skipping unreadable file", "file", e.Name(), "err", err) + continue + } + + info, err := e.Info() + if err != nil { + s.logger.Warn("skipping file without info", "file", e.Name(), "err", err) + continue + } + + title := extractTitle(string(content), slug) + mtime := info.ModTime().UTC().Format(time.RFC3339) + + if _, err := tx.ExecContext(ctx, + `INSERT INTO documents (slug, title, content, updated_at) VALUES (?, ?, ?, ?)`, + slug, title, string(content), mtime, + ); err != nil { + s.logger.Warn("failed to index file", "slug", slug, "err", err) + continue + } + count++ + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit sync tx: %w", err) + } + + s.logger.Info("knowledge_sync", "count", count) + return nil +} + +// Search performs full-text search on the FTS5 index. +func (s *FileStore) Search(ctx context.Context, query string, limit int) ([]knowledge.SearchResult, error) { + if limit <= 0 { + limit = 5 + } + + rows, err := s.db.QueryContext(ctx, + `SELECT slug, title, snippet(documents, 2, '**', '**', '…', 32), rank + FROM documents WHERE documents MATCH ? + ORDER BY rank LIMIT ?`, + query, limit, + ) + if err != nil { + return nil, fmt.Errorf("knowledge search: %w", err) + } + defer rows.Close() + + var results []knowledge.SearchResult + for rows.Next() { + var r knowledge.SearchResult + if err := rows.Scan(&r.Slug, &r.Title, &r.Snippet, &r.Rank); err != nil { + return nil, err + } + results = append(results, r) + } + return results, rows.Err() +} + +// Get reads a document from disk by slug. +func (s *FileStore) Get(ctx context.Context, slug string) (*knowledge.Document, error) { + if !ValidSlug(slug) { + return nil, fmt.Errorf("invalid slug: %q", slug) + } + + path := filepath.Join(s.dir, slug+".md") + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("document not found: %q", slug) + } + return nil, fmt.Errorf("read document: %w", err) + } + + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat document: %w", err) + } + + return &knowledge.Document{ + Slug: slug, + Title: extractTitle(string(content), slug), + Content: string(content), + UpdatedAt: info.ModTime().UTC(), + }, nil +} + +// Put writes a document to disk and updates the FTS5 index. +func (s *FileStore) Put(ctx context.Context, doc knowledge.Document) error { + if !ValidSlug(doc.Slug) { + return fmt.Errorf("invalid slug: %q", doc.Slug) + } + if len(doc.Content) > 64*1024 { + return fmt.Errorf("document too large: %d bytes (max 65536)", len(doc.Content)) + } + + path := filepath.Join(s.dir, doc.Slug+".md") + if err := os.WriteFile(path, []byte(doc.Content), 0o644); err != nil { + return fmt.Errorf("write document: %w", err) + } + + title := extractTitle(doc.Content, doc.Slug) + now := time.Now().UTC().Format(time.RFC3339) + + // Upsert: delete old + insert new (FTS5 doesn't support UPDATE well) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin put tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM documents WHERE slug = ?`, doc.Slug); err != nil { + return fmt.Errorf("delete old index: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO documents (slug, title, content, updated_at) VALUES (?, ?, ?, ?)`, + doc.Slug, title, doc.Content, now, + ); err != nil { + return fmt.Errorf("insert index: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit put tx: %w", err) + } + + s.logger.Debug("knowledge_put", "slug", doc.Slug, "size", len(doc.Content)) + return nil +} + +// Delete removes a document from disk and the FTS5 index. +func (s *FileStore) Delete(ctx context.Context, slug string) error { + if !ValidSlug(slug) { + return fmt.Errorf("invalid slug: %q", slug) + } + + path := filepath.Join(s.dir, slug+".md") + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove document: %w", err) + } + + if _, err := s.db.ExecContext(ctx, `DELETE FROM documents WHERE slug = ?`, slug); err != nil { + return fmt.Errorf("delete from index: %w", err) + } + + s.logger.Debug("knowledge_delete", "slug", slug) + return nil +} + +// List returns all documents from the FTS5 index. +func (s *FileStore) List(ctx context.Context) ([]knowledge.Document, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT slug, title, updated_at FROM documents ORDER BY slug`) + if err != nil { + return nil, fmt.Errorf("knowledge list: %w", err) + } + defer rows.Close() + + var docs []knowledge.Document + for rows.Next() { + var d knowledge.Document + var updatedAt string + if err := rows.Scan(&d.Slug, &d.Title, &updatedAt); err != nil { + return nil, err + } + d.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + docs = append(docs, d) + } + return docs, rows.Err() +} + +// Close releases the SQLite database. +func (s *FileStore) Close() error { + s.logger.Info("knowledge_store_closed") + return s.db.Close() +} + +// extractTitle returns the first H1 heading from markdown content, or a humanized slug. +func extractTitle(content, slug string) string { + for _, line := range strings.SplitN(content, "\n", 20) { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# ") { + return strings.TrimPrefix(line, "# ") + } + } + // Humanize slug: "go-patterns" → "Go patterns" + humanized := strings.ReplaceAll(slug, "-", " ") + if len(humanized) > 0 { + humanized = strings.ToUpper(humanized[:1]) + humanized[1:] + } + return humanized +} diff --git a/shell/knowledge/store_test.go b/shell/knowledge/store_test.go new file mode 100644 index 0000000..0c543f9 --- /dev/null +++ b/shell/knowledge/store_test.go @@ -0,0 +1,208 @@ +package shellknowledge + +import ( + "context" + "os" + "path/filepath" + "testing" + + "log/slog" + + "github.com/enmanuel/agents/pkg/knowledge" +) + +func testStore(t *testing.T) (*FileStore, string) { + t.Helper() + dir := t.TempDir() + knowledgeDir := filepath.Join(dir, "knowledge") + dbPath := filepath.Join(dir, "data", "knowledge.db") + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + store, err := New(knowledgeDir, dbPath, logger) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + return store, knowledgeDir +} + +func TestValidSlug(t *testing.T) { + tests := []struct { + slug string + want bool + }{ + {"go-patterns", true}, + {"ab", true}, + {"a-b", true}, + {"abc123", true}, + {"a", false}, // too short + {"A-B", false}, // uppercase + {"-bad", false}, // starts with hyphen + {"bad-", false}, // ends with hyphen + {"has space", false}, // space + {"has_underscore", false}, // underscore + {"", false}, + } + for _, tt := range tests { + if got := ValidSlug(tt.slug); got != tt.want { + t.Errorf("ValidSlug(%q) = %v, want %v", tt.slug, got, tt.want) + } + } +} + +func TestPutAndGet(t *testing.T) { + store, _ := testStore(t) + ctx := context.Background() + + doc := knowledge.Document{ + Slug: "test-doc", + Content: "# Test Document\n\nThis is a test.", + } + + if err := store.Put(ctx, doc); err != nil { + t.Fatal(err) + } + + got, err := store.Get(ctx, "test-doc") + if err != nil { + t.Fatal(err) + } + if got.Content != doc.Content { + t.Errorf("content mismatch: got %q, want %q", got.Content, doc.Content) + } + if got.Title != "Test Document" { + t.Errorf("title = %q, want %q", got.Title, "Test Document") + } +} + +func TestPutInvalidSlug(t *testing.T) { + store, _ := testStore(t) + ctx := context.Background() + + err := store.Put(ctx, knowledge.Document{Slug: "BAD", Content: "test"}) + if err == nil { + t.Error("expected error for invalid slug") + } +} + +func TestPutTooLarge(t *testing.T) { + store, _ := testStore(t) + ctx := context.Background() + + bigContent := make([]byte, 65*1024) + for i := range bigContent { + bigContent[i] = 'x' + } + err := store.Put(ctx, knowledge.Document{Slug: "too-big", Content: string(bigContent)}) + if err == nil { + t.Error("expected error for oversized document") + } +} + +func TestSyncAndSearch(t *testing.T) { + store, knowledgeDir := testStore(t) + ctx := context.Background() + + // Write files directly to disk + os.WriteFile(filepath.Join(knowledgeDir, "go-patterns.md"), + []byte("# Go Patterns\n\nUse interfaces for dependency injection."), 0o644) + os.WriteFile(filepath.Join(knowledgeDir, "matrix-tips.md"), + []byte("# Matrix Tips\n\nUse mautrix-go for Matrix bots."), 0o644) + + if err := store.Sync(ctx); err != nil { + t.Fatal(err) + } + + // Search for "interfaces" + results, err := store.Search(ctx, "interfaces", 5) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected at least 1 search result") + } + if results[0].Slug != "go-patterns" { + t.Errorf("expected slug go-patterns, got %q", results[0].Slug) + } +} + +func TestList(t *testing.T) { + store, _ := testStore(t) + ctx := context.Background() + + // Empty initially + docs, err := store.List(ctx) + if err != nil { + t.Fatal(err) + } + if len(docs) != 0 { + t.Errorf("expected 0 docs, got %d", len(docs)) + } + + // Add two docs + store.Put(ctx, knowledge.Document{Slug: "alpha", Content: "# Alpha\nContent A"}) + store.Put(ctx, knowledge.Document{Slug: "beta", Content: "# Beta\nContent B"}) + + docs, err = store.List(ctx) + if err != nil { + t.Fatal(err) + } + if len(docs) != 2 { + t.Fatalf("expected 2 docs, got %d", len(docs)) + } +} + +func TestDelete(t *testing.T) { + store, knowledgeDir := testStore(t) + ctx := context.Background() + + store.Put(ctx, knowledge.Document{Slug: "to-delete", Content: "# Delete Me\nGoodbye"}) + + // Verify file exists + if _, err := os.Stat(filepath.Join(knowledgeDir, "to-delete.md")); err != nil { + t.Fatal("file should exist after Put") + } + + if err := store.Delete(ctx, "to-delete"); err != nil { + t.Fatal(err) + } + + // File removed + if _, err := os.Stat(filepath.Join(knowledgeDir, "to-delete.md")); !os.IsNotExist(err) { + t.Error("file should be removed after Delete") + } + + // Not in index + _, err := store.Get(ctx, "to-delete") + if err == nil { + t.Error("expected error for deleted document") + } +} + +func TestGetNotFound(t *testing.T) { + store, _ := testStore(t) + ctx := context.Background() + + _, err := store.Get(ctx, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent document") + } +} + +func TestExtractTitle(t *testing.T) { + tests := []struct { + content string + slug string + want string + }{ + {"# My Title\nBody", "slug", "My Title"}, + {"No heading here", "my-doc", "My doc"}, + {"", "empty-doc", "Empty doc"}, + {"\n\n# Late Title\n", "slug", "Late Title"}, + } + for _, tt := range tests { + got := extractTitle(tt.content, tt.slug) + if got != tt.want { + t.Errorf("extractTitle(%q, %q) = %q, want %q", tt.content, tt.slug, got, tt.want) + } + } +} diff --git a/tools/knowledge.go b/tools/knowledge.go new file mode 100644 index 0000000..4643e14 --- /dev/null +++ b/tools/knowledge.go @@ -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 + } +} diff --git a/tools/knowledge_test.go b/tools/knowledge_test.go new file mode 100644 index 0000000..94b553f --- /dev/null +++ b/tools/knowledge_test.go @@ -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 +}