Files
goclaw/internal/store/pg/memory_search.go
T
Luan Vu 2fdb791802 fix: honor per-agent DB settings for restrict, subagents, memory, sandbox (#145)
Four per-agent settings stored in the database (and configurable via UI)
were silently ignored at runtime because the tool/system layer always
used the global config defaults instead.

**restrict_to_workspace**: Tools used the global config default baked at
startup. Fix: pass per-agent value through context; tools check context
override before falling back to constructor default.

**subagents_config**: ParseSubagentsConfig() existed but was never called.
All agents shared one SubagentManager with global limits. Fix: resolve
per-agent config in the agent resolver, store it on each spawned task,
and use it for limit checks, deny lists, and system prompt generation.

**memory_config**: Only the enabled toggle was read per-agent; search
weights (vector_weight, text_weight, max_results, min_score) were
hardcoded from PGMemoryStore defaults. Fix: extend MemorySearchOptions
with weight overrides, read per-agent config from context in the
memory_search tool.

**sandbox_config**: Only workspace_access was extracted per-agent; mode,
image, memory, CPU, timeout, network settings were discarded. Fix: pass
full sandbox.Config through context; Manager.Get() accepts an optional
config override for new containers.

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
2026-03-11 16:05:56 +07:00

218 lines
5.6 KiB
Go

package pg
import (
"context"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Search performs hybrid search (FTS + vector) over memory_chunks.
// Merges global (user_id IS NULL) + per-user chunks, with user boost.
func (s *PGMemoryStore) Search(ctx context.Context, query string, agentID, userID string, opts store.MemorySearchOptions) ([]store.MemorySearchResult, error) {
maxResults := opts.MaxResults
if maxResults <= 0 {
maxResults = s.cfg.MaxResults
}
aid := mustParseUUID(agentID)
// FTS search using tsvector
ftsResults, err := s.ftsSearch(ctx, query, aid, userID, maxResults*2)
if err != nil {
return nil, err
}
// Vector search if provider available
var vecResults []scoredChunk
if s.provider != nil {
embeddings, err := s.provider.Embed(ctx, []string{query})
if err == nil && len(embeddings) > 0 {
vecResults, err = s.vectorSearch(ctx, embeddings[0], aid, userID, maxResults*2)
if err != nil {
vecResults = nil
}
}
}
// Merge results — use per-query overrides if set, else store defaults
textW, vecW := s.cfg.TextWeight, s.cfg.VectorWeight
if opts.TextWeight > 0 {
textW = opts.TextWeight
}
if opts.VectorWeight > 0 {
vecW = opts.VectorWeight
}
if len(ftsResults) == 0 && len(vecResults) > 0 {
textW, vecW = 0, 1.0
} else if len(vecResults) == 0 && len(ftsResults) > 0 {
textW, vecW = 1.0, 0
}
merged := hybridMerge(ftsResults, vecResults, textW, vecW, userID)
// Apply min score filter
var filtered []store.MemorySearchResult
for _, m := range merged {
if opts.MinScore > 0 && m.Score < opts.MinScore {
continue
}
if opts.PathPrefix != "" && len(m.Path) < len(opts.PathPrefix) {
continue
}
filtered = append(filtered, m)
if len(filtered) >= maxResults {
break
}
}
return filtered, nil
}
type scoredChunk struct {
Path string
StartLine int
EndLine int
Text string
Score float64
UserID *string
}
func (s *PGMemoryStore) ftsSearch(ctx context.Context, query string, agentID any, userID string, limit int) ([]scoredChunk, error) {
var q string
var args []any
if userID != "" {
q = `SELECT path, start_line, end_line, text, user_id,
ts_rank(tsv, plainto_tsquery('simple', $1)) AS score
FROM memory_chunks
WHERE agent_id = $2 AND tsv @@ plainto_tsquery('simple', $3)
AND (user_id IS NULL OR user_id = $4)
ORDER BY score DESC LIMIT $5`
args = []any{query, agentID, query, userID, limit}
} else {
q = `SELECT path, start_line, end_line, text, user_id,
ts_rank(tsv, plainto_tsquery('simple', $1)) AS score
FROM memory_chunks
WHERE agent_id = $2 AND tsv @@ plainto_tsquery('simple', $3)
AND user_id IS NULL
ORDER BY score DESC LIMIT $4`
args = []any{query, agentID, query, limit}
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []scoredChunk
for rows.Next() {
var r scoredChunk
rows.Scan(&r.Path, &r.StartLine, &r.EndLine, &r.Text, &r.UserID, &r.Score)
results = append(results, r)
}
return results, nil
}
func (s *PGMemoryStore) vectorSearch(ctx context.Context, embedding []float32, agentID any, userID string, limit int) ([]scoredChunk, error) {
vecStr := vectorToString(embedding)
var q string
var args []any
if userID != "" {
q = `SELECT path, start_line, end_line, text, user_id,
1 - (embedding <=> $1::vector) AS score
FROM memory_chunks
WHERE agent_id = $2 AND embedding IS NOT NULL
AND (user_id IS NULL OR user_id = $3)
ORDER BY embedding <=> $4::vector LIMIT $5`
args = []any{vecStr, agentID, userID, vecStr, limit}
} else {
q = `SELECT path, start_line, end_line, text, user_id,
1 - (embedding <=> $1::vector) AS score
FROM memory_chunks
WHERE agent_id = $2 AND embedding IS NOT NULL
AND user_id IS NULL
ORDER BY embedding <=> $3::vector LIMIT $4`
args = []any{vecStr, agentID, vecStr, limit}
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []scoredChunk
for rows.Next() {
var r scoredChunk
rows.Scan(&r.Path, &r.StartLine, &r.EndLine, &r.Text, &r.UserID, &r.Score)
results = append(results, r)
}
return results, nil
}
// hybridMerge combines FTS and vector results with weighted scoring.
// Per-user results get a 1.2x boost. Deduplication: user copy wins over global.
func hybridMerge(fts, vec []scoredChunk, textWeight, vectorWeight float64, currentUserID string) []store.MemorySearchResult {
type key struct {
Path string
StartLine int
}
seen := make(map[key]*store.MemorySearchResult)
addResult := func(r scoredChunk, weight float64) {
k := key{r.Path, r.StartLine}
scope := "global"
boost := 1.0
if r.UserID != nil && *r.UserID != "" {
scope = "personal"
boost = 1.2
}
score := r.Score * weight * boost
if existing, ok := seen[k]; ok {
existing.Score += score
// User copy wins
if scope == "personal" {
existing.Scope = "personal"
existing.Snippet = r.Text
}
} else {
seen[k] = &store.MemorySearchResult{
Path: r.Path,
StartLine: r.StartLine,
EndLine: r.EndLine,
Score: score,
Snippet: r.Text,
Source: "memory",
Scope: scope,
}
}
}
for _, r := range fts {
addResult(r, textWeight)
}
for _, r := range vec {
addResult(r, vectorWeight)
}
// Collect and sort by score
results := make([]store.MemorySearchResult, 0, len(seen))
for _, r := range seen {
results = append(results, *r)
}
// Simple sort (descending score)
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].Score > results[i].Score {
results[i], results[j] = results[j], results[i]
}
}
}
return results
}