Files
viettranx c7b2df9e34 feat(vault): chat_id isolation for isolated teams
- Add vault_documents.chat_id + composite index (migration 000056)
- Filter vault_search by chat_id when team.workspace_scope=isolated
- Stamp chat_id on AfterWrite/AfterWriteMedia for isolated teams
- Deny cross-chat vault_read in isolated teams (M2 fix)
- RunContext.TeamIsolated flag resolved once per run
- Fallback WorkspaceChatID → ChatID in loop_context for entry points
  that don't set WorkspaceChatID explicitly (WS direct, HTTP, cron)

Fixes cross-chat doc leak where agent in chat A could see vault docs
from chat B within the same isolated team.
2026-04-23 18:43:31 +07:00

149 lines
4.5 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/vault"
)
// VaultSearchTool provides unified search across all knowledge sources.
type VaultSearchTool struct {
searchSvc *vault.VaultSearchService
}
func NewVaultSearchTool() *VaultSearchTool {
return &VaultSearchTool{}
}
func (t *VaultSearchTool) SetSearchService(svc *vault.VaultSearchService) {
t.searchSvc = svc
}
func (t *VaultSearchTool) Name() string { return "vault_search" }
func (t *VaultSearchTool) Description() string {
return "Primary discovery tool: search across ALL knowledge sources (vault docs, memory, knowledge graph). Each result carries a source-specific id field (doc_id / entity_id / episodic_id) that matches the input param of its follow-up tool — pass doc_id to vault_read, entity_id to knowledge_graph_search, episodic_id to memory_expand. Narrow the search with types=\"context,note\" or types=\"kg\" when relevant."
}
// sourceIDField returns the (field-name, follow-up-hint) pair for a result
// source. Field names intentionally match the input param of the follow-up
// tool so the LLM cannot misroute an id: `doc_id` is vault_read's param,
// `entity_id` is knowledge_graph_search's param, `episodic_id` pairs with
// memory_expand's `id` param (kept distinct to signal namespace).
func sourceIDField(source string) (field, hint string) {
switch source {
case "vault":
return "doc_id", " → vault_read(doc_id)"
case "kg":
return "entity_id", " → knowledge_graph_search(entity_id) — NOT vault_read"
case "episodic":
return "episodic_id", " → memory_expand(id=episodic_id) — NOT vault_read"
default:
return "id", ""
}
}
func (t *VaultSearchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "Natural language search query",
},
"scope": map[string]any{
"type": "string",
"description": "Scope filter: personal, team, or shared (default: all)",
},
"types": map[string]any{
"type": "string",
"description": "Comma-separated doc types: context, memory, note, skill, episodic, kg (default: all sources)",
},
"maxResults": map[string]any{
"type": "number",
"description": "Maximum results (default: 10)",
},
},
"required": []string{"query"},
}
}
func (t *VaultSearchTool) Execute(ctx context.Context, args map[string]any) *Result {
query, _ := args["query"].(string)
if query == "" {
return ErrorResult("query parameter is required")
}
agentID := store.AgentIDFromContext(ctx)
tenantID := store.TenantIDFromContext(ctx)
if t.searchSvc == nil || agentID == uuid.Nil {
return ErrorResult("vault search not available")
}
userID := store.MemoryUserID(ctx)
opts := vault.UnifiedSearchOptions{
Query: query,
AgentID: agentID.String(),
UserID: userID,
TenantID: tenantID.String(),
}
// Team + chat context from RunContext — cannot be spoofed via tool args.
if rc := store.RunContextFromCtx(ctx); rc != nil && rc.TeamID != "" {
opts.TeamID = &rc.TeamID
if rc.TeamIsolated {
opts.TeamIsolated = true
if chatID := WorkspaceChatIDFromCtx(ctx); chatID != "" {
opts.ChatID = &chatID
}
}
}
if scope, ok := args["scope"].(string); ok && scope != "" {
opts.Scope = scope
}
if types, ok := args["types"].(string); ok && types != "" {
for t := range strings.SplitSeq(types, ",") {
opts.DocTypes = append(opts.DocTypes, strings.TrimSpace(t))
}
}
if mr, ok := args["maxResults"].(float64); ok && mr > 0 {
opts.MaxResults = int(mr)
}
results, err := t.searchSvc.Search(ctx, opts)
if err != nil {
return ErrorResult(fmt.Sprintf("vault search failed: %v", err))
}
if len(results) == 0 {
return NewResult("No results found. Try memory_search for memory-specific queries or kg_search for relationship traversal.")
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d results:\n\n", len(results)))
for i, r := range results {
sb.WriteString(fmt.Sprintf("%d. [%s] %s", i+1, r.Source, r.Title))
if r.Path != "" {
sb.WriteString(fmt.Sprintf(" (%s)", r.Path))
}
sb.WriteString(fmt.Sprintf(" — score: %.2f", r.Score))
field, hint := sourceIDField(r.Source)
if r.ID != "" {
sb.WriteString(fmt.Sprintf(" — %s: %s", field, r.ID))
}
if hint != "" {
sb.WriteString(hint)
}
if r.Snippet != "" {
sb.WriteString(fmt.Sprintf("\n %s", r.Snippet))
}
sb.WriteByte('\n')
}
return NewResult(sb.String())
}