Files
egutierrez 69607b3a65 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>
2026-03-06 23:02:39 +00:00

209 lines
4.9 KiB
Go

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)
}
}
}