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,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
|
||||
@@ -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
|
||||
+42
-5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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/<id>/knowledge/
|
||||
dbPath string // path to agents/<id>/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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