package tools import ( "context" "fmt" "strings" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) // KnowledgeGraphSearchTool provides graph-based search for agents. type KnowledgeGraphSearchTool struct { kgStore store.KnowledgeGraphStore } // NewKnowledgeGraphSearchTool creates a new KnowledgeGraphSearchTool. func NewKnowledgeGraphSearchTool() *KnowledgeGraphSearchTool { return &KnowledgeGraphSearchTool{} } // SetKGStore sets the KnowledgeGraphStore for this tool. func (t *KnowledgeGraphSearchTool) SetKGStore(ks store.KnowledgeGraphStore) { t.kgStore = ks } func (t *KnowledgeGraphSearchTool) Name() string { return "knowledge_graph_search" } func (t *KnowledgeGraphSearchTool) Description() string { return "Search the knowledge graph to find people, projects, organizations, and how they connect. Better than memory_search when you need: who works with whom, what projects someone is involved in, dependencies between tasks, or any multi-hop relationship question. Use specific names (e.g. 'Minh', 'GoClaw') — not generic words. Use query='*' to list all known entities. Use entity_id to traverse connections from a specific entity." } func (t *KnowledgeGraphSearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "Search query for entity names or descriptions", }, "entity_type": map[string]any{ "type": "string", "description": "Filter by entity type (person, project, task, event, concept, location, organization)", }, "entity_id": map[string]any{ "type": "string", "description": "Entity ID to traverse from (for relationship discovery)", }, "max_depth": map[string]any{ "type": "number", "description": "Maximum traversal depth (default 2, max 3)", }, }, "required": []string{"query"}, } } func (t *KnowledgeGraphSearchTool) Execute(ctx context.Context, args map[string]any) *Result { if t.kgStore == nil { return NewResult("Knowledge graph is not enabled for this agent.") } agentID := store.AgentIDFromContext(ctx) if agentID == uuid.Nil { return ErrorResult("agent context not available") } userID := store.MemoryUserID(ctx) query, _ := args["query"].(string) if query == "" { return ErrorResult("query parameter is required") } entityID, _ := args["entity_id"].(string) maxDepth := 2 if md, ok := args["max_depth"].(float64); ok && md > 0 { maxDepth = min(int(md), 3) } // Traversal mode: entity_id provided if entityID != "" { return t.executeTraversal(ctx, agentID.String(), userID, entityID, maxDepth) } // List-all mode: query="*" if query == "*" { return t.executeListAll(ctx, agentID.String(), userID) } // Search mode return t.executeSearch(ctx, agentID.String(), userID, query, args) } func (t *KnowledgeGraphSearchTool) executeTraversal(ctx context.Context, agentID, userID, entityID string, maxDepth int) *Result { results, err := t.kgStore.Traverse(ctx, agentID, userID, entityID, maxDepth) if err != nil { return ErrorResult(fmt.Sprintf("graph traversal failed: %v", err)) } if len(results) == 0 { return NewResult(fmt.Sprintf("No connected entities found from entity_id=%q within depth %d.", entityID, maxDepth)) } var sb strings.Builder sb.WriteString(fmt.Sprintf("Graph traversal from %q (max depth %d):\n\n", entityID, maxDepth)) for _, r := range results { sb.WriteString(fmt.Sprintf("- [depth %d] %s (%s)", r.Depth, r.Entity.Name, r.Entity.EntityType)) if r.Via != "" { sb.WriteString(fmt.Sprintf(" via %q", r.Via)) } if r.Entity.Description != "" { sb.WriteString(fmt.Sprintf("\n %s", r.Entity.Description)) } if len(r.Path) > 0 { sb.WriteString(fmt.Sprintf("\n path: %s", strings.Join(r.Path, " → "))) } sb.WriteString("\n") } return NewResult(sb.String()) } func (t *KnowledgeGraphSearchTool) executeListAll(ctx context.Context, agentID, userID string) *Result { entities, err := t.kgStore.ListEntities(ctx, agentID, userID, store.EntityListOptions{Limit: 30}) if err != nil { return ErrorResult(fmt.Sprintf("list entities failed: %v", err)) } if len(entities) == 0 { return NewResult("Knowledge graph is empty. No entities have been extracted yet.") } var sb strings.Builder sb.WriteString(fmt.Sprintf("Knowledge graph has %d entities:\n\n", len(entities))) for _, e := range entities { sb.WriteString(fmt.Sprintf("- %s [%s] (id: %s)\n", e.Name, e.EntityType, e.ID)) if e.Description != "" { sb.WriteString(fmt.Sprintf(" %s\n", e.Description)) } } sb.WriteString("\nTip: Use entity_id parameter to traverse relationships from a specific entity.") return NewResult(sb.String()) } func (t *KnowledgeGraphSearchTool) executeSearch(ctx context.Context, agentID, userID, query string, args map[string]any) *Result { entities, err := t.kgStore.SearchEntities(ctx, agentID, userID, query, 10) if err != nil { return ErrorResult(fmt.Sprintf("entity search failed: %v", err)) } // No results: show available entities as hints if len(entities) == 0 { return t.noResultsHint(ctx, agentID, userID, query) } // Optional type filter (post-search) entityType, _ := args["entity_type"].(string) if entityType != "" { filtered := entities[:0] for _, e := range entities { if e.EntityType == entityType { filtered = append(filtered, e) } } entities = filtered if len(entities) == 0 { return NewResult(fmt.Sprintf("No entities of type %q found matching %q.", entityType, query)) } } // Build entity name lookup for relation display entityNames := make(map[string]string, len(entities)) for _, e := range entities { entityNames[e.ID] = e.Name } var sb strings.Builder sb.WriteString(fmt.Sprintf("Found %d entities matching %q:\n\n", len(entities), query)) for _, e := range entities { sb.WriteString(fmt.Sprintf("- %s [%s] (id: %s)\n", e.Name, e.EntityType, e.ID)) if e.Description != "" { sb.WriteString(fmt.Sprintf(" %s\n", e.Description)) } // Fetch relations to show connections with names relations, err := t.kgStore.ListRelations(ctx, agentID, userID, e.ID) if err == nil && len(relations) > 0 { sb.WriteString(" Relations:\n") for _, rel := range relations { srcName := t.resolveEntityName(ctx, agentID, userID, rel.SourceEntityID, entityNames) tgtName := t.resolveEntityName(ctx, agentID, userID, rel.TargetEntityID, entityNames) sb.WriteString(fmt.Sprintf(" %s —[%s]→ %s\n", srcName, rel.RelationType, tgtName)) } } } return NewResult(sb.String()) } // resolveEntityName returns a human-readable name for an entity ID, using cache or DB lookup. func (t *KnowledgeGraphSearchTool) resolveEntityName(ctx context.Context, agentID, userID, entityID string, cache map[string]string) string { if name, ok := cache[entityID]; ok { return name } e, err := t.kgStore.GetEntity(ctx, agentID, userID, entityID) if err == nil && e != nil { cache[entityID] = e.Name return e.Name } return entityID[:8] // fallback: short UUID } // noResultsHint returns top entities so the model knows what's available. func (t *KnowledgeGraphSearchTool) noResultsHint(ctx context.Context, agentID, userID, query string) *Result { top, _ := t.kgStore.ListEntities(ctx, agentID, userID, store.EntityListOptions{Limit: 10}) if len(top) == 0 { return NewResult("Knowledge graph is empty. No entities have been extracted yet.") } var sb strings.Builder sb.WriteString(fmt.Sprintf("No entities found matching %q. ", query)) sb.WriteString(fmt.Sprintf("The knowledge graph has %d entities. Here are some available ones:\n\n", len(top))) for _, e := range top { sb.WriteString(fmt.Sprintf("- %s [%s] (id: %s)\n", e.Name, e.EntityType, e.ID)) } sb.WriteString("\nTry searching with a specific name from the list above, or use query='*' to see all.") return NewResult(sb.String()) }