69607b3a65
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>
209 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|