mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
7f4f4a238e
- Add hasKG flag to MemorySearchTool, inject hint in results when KG is enabled - Wire SetHasKG(true) in gateway when KG store is available - Improve knowledge_graph_search tool description with concrete use cases - Update system prompt KG guidance to be more actionable
224 lines
7.8 KiB
Go
224 lines
7.8 KiB
Go
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())
|
|
}
|