mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-14 12:26:04 +00:00
* fix(vault): prevent vault_read id-namespace collision vault_search was leaking KG/episodic entity ids into result sets even when narrow `types` were requested, and callers then passed those ids to vault_read which returned a generic "document not found". The cause was threefold: 1. `types` filter was only applied to the vault fan-out; KG and episodic ran unconditionally. Now gated by shouldFanout(types, key). 2. vault_search output lacked a per-source tool hint. Each result now ends with " → use <tool>" naming the correct follow-up (vault_read, knowledge_graph_search, or memory_search). 3. vault_read miss returned "document not found" without checking whether the id belonged to a foreign namespace. It now probes KG then episodic and returns a namespace-specific redirect error. Stores are injected via SetKGStore/SetEpisodicStore, nil-safe, tenant-scoped. Adds red→green characterization tests plus an end-to-end integration scenario seeding a vault doc + KG entity with identical basenames. * test(agent): bump none-mode prompt size budget to 3100 vault_read wiring (#948) added ~95 chars to read_file tool summary, pushing none-mode prompt from <3000 to 3075 chars. Bump budget to 3100 (~775 tokens) to match the intentional addition. * test(integration): ensure data_migrations table exists in reset helper The reset helper runs before RunPendingHooks, but RunPendingHooks is what normally creates data_migrations. On a fresh CI database the DELETE fails with 'relation does not exist'. Create the table defensively so reset works regardless of execution order. * refactor(vault): per-source id fields + wire episodic into search Align vault_search output fields with downstream tool input params: doc_id (vault_read), entity_id (knowledge_graph_search), episodic_id (memory_expand). Prevents LLMs from pattern-matching a generic `id:` and misrouting a foreign-namespace uuid into vault_read. Fallback redirect in vault_read now quotes id + names the correct param so the LLM can self-correct in one turn. Also wire stores.Episodic into VaultSearchService (stale comment claimed pending-impl; PGEpisodicStore has existed and been in use since v3). Unifies search fan-out with vault_read namespace probe. --------- Co-authored-by: viettranx <viettranx@gmail.com>
139 lines
4.6 KiB
Go
139 lines
4.6 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/internal/vault"
|
|
)
|
|
|
|
// TestVaultNamespaceFix_thuyTienScenario reproduces the thuy-tien bug:
|
|
// a vault doc and a KG entity share the same basename (KG_03_...). Prior to
|
|
// the fix, vault_search(types="context") returned the KG entity's id and the
|
|
// LLM passed it to vault_read, yielding "document not found".
|
|
//
|
|
// Validates:
|
|
// A. types="context" → results contain only vault source (no kg leak).
|
|
// B. empty types → results contain both vault + kg sources with hint markers.
|
|
// C. vault_read(KG id) → redirect error mentioning knowledge_graph_search.
|
|
// D. vault_read(vault id) → success with content.
|
|
// E. vault_read(random) → "document not found" (truly missing).
|
|
func TestVaultNamespaceFix_thuyTienScenario(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
vs := newVaultStore(db)
|
|
kg := newKGStore(t)
|
|
|
|
ws := t.TempDir()
|
|
relPath := "KG_03_Danh_Muc_San_Pham.md"
|
|
body := "Product catalog body"
|
|
if err := os.WriteFile(filepath.Join(ws, relPath), []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
|
|
userID := "kguser-" + agentID.String()[:8]
|
|
ctx := store.WithUserID(store.WithAgentID(tenantCtx(tenantID), agentID), userID)
|
|
|
|
// Seed vault doc.
|
|
vdoc := makeSharedVaultDoc(tenantID.String(), relPath, "KG_03 Danh Muc San Pham")
|
|
vdoc.DocType = "context"
|
|
if err := vs.UpsertDocument(ctx, vdoc); err != nil {
|
|
t.Fatalf("UpsertDocument: %v", err)
|
|
}
|
|
|
|
// Seed KG entity with same name.
|
|
ent := &store.Entity{
|
|
AgentID: agentID.String(),
|
|
UserID: userID,
|
|
ExternalID: "ext-kg03",
|
|
Name: "KG_03_Danh_Muc_San_Pham",
|
|
EntityType: "document",
|
|
Confidence: 0.9,
|
|
}
|
|
if err := kg.UpsertEntity(ctx, ent); err != nil {
|
|
t.Fatalf("UpsertEntity: %v", err)
|
|
}
|
|
// Resolve the DB-assigned id.
|
|
ents, err := kg.ListEntities(ctx, agentID.String(), userID, store.EntityListOptions{Limit: 10})
|
|
if err != nil || len(ents) == 0 {
|
|
t.Fatalf("ListEntities: %v (n=%d)", err, len(ents))
|
|
}
|
|
kgID := ents[0].ID
|
|
|
|
// Build the search service the same way production wires it.
|
|
svc := vault.NewVaultSearchService(vs, nil, kg)
|
|
searchTool := tools.NewVaultSearchTool()
|
|
searchTool.SetSearchService(svc)
|
|
|
|
// vault_read mirrors production wiring with the namespace-fallback stores.
|
|
readTool := tools.NewVaultReadTool()
|
|
readTool.SetVaultStore(vs)
|
|
readTool.SetKGStore(kg)
|
|
readTool.SetWorkspace(ws)
|
|
|
|
// --- Scenario A: types="context" → vault source only. ---
|
|
res := searchTool.Execute(ctx, map[string]any{
|
|
"query": "KG_03",
|
|
"types": "context",
|
|
"maxResults": float64(5),
|
|
})
|
|
if res.IsError {
|
|
t.Fatalf("A: unexpected error: %s", res.ForLLM)
|
|
}
|
|
if strings.Contains(res.ForLLM, "[kg]") {
|
|
t.Errorf("A: KG leaked into types=context search: %s", res.ForLLM)
|
|
}
|
|
|
|
// --- Scenario B: empty types → both sources present with per-source id fields. ---
|
|
resB := searchTool.Execute(ctx, map[string]any{
|
|
"query": "KG_03",
|
|
"maxResults": float64(10),
|
|
})
|
|
if resB.IsError {
|
|
t.Fatalf("B: unexpected error: %s", resB.ForLLM)
|
|
}
|
|
// Each source must carry its tool-specific id field (doc_id / entity_id)
|
|
// so the LLM cannot pattern-match a foreign uuid into vault_read.
|
|
if !strings.Contains(resB.ForLLM, "doc_id:") {
|
|
t.Errorf("B: vault result missing doc_id field: %s", resB.ForLLM)
|
|
}
|
|
if !strings.Contains(resB.ForLLM, "entity_id:") {
|
|
t.Errorf("B: kg result missing entity_id field: %s", resB.ForLLM)
|
|
}
|
|
|
|
// --- Scenario C: vault_read(KG id) → redirect, not 'document not found'. ---
|
|
resC := readTool.Execute(ctx, map[string]any{"doc_id": kgID})
|
|
if !resC.IsError {
|
|
t.Fatalf("C: expected error, got: %s", resC.ForLLM)
|
|
}
|
|
if strings.Contains(resC.ForLLM, "document not found") {
|
|
t.Errorf("C: should redirect, not generic not-found: %s", resC.ForLLM)
|
|
}
|
|
if !strings.Contains(resC.ForLLM, "knowledge_graph") {
|
|
t.Errorf("C: redirect must mention knowledge_graph: %s", resC.ForLLM)
|
|
}
|
|
|
|
// --- Scenario D: vault_read(vault id) → success. ---
|
|
resD := readTool.Execute(ctx, map[string]any{"doc_id": vdoc.ID})
|
|
if resD.IsError {
|
|
t.Fatalf("D: unexpected error: %s", resD.ForLLM)
|
|
}
|
|
if !strings.Contains(resD.ForLLM, body) {
|
|
t.Errorf("D: content missing: %s", resD.ForLLM)
|
|
}
|
|
|
|
// --- Scenario E: random UUID → truly not found. ---
|
|
resE := readTool.Execute(ctx, map[string]any{"doc_id": uuid.New().String()})
|
|
if !resE.IsError || !strings.Contains(resE.ForLLM, "not found") {
|
|
t.Errorf("E: expected 'not found', got: %s", resE.ForLLM)
|
|
}
|
|
}
|