Files
BaaRai f3a20ef2c6 Tool RAG: lexical projection of the bound tool catalog (off by default)
When ARTEX_TOOL_RAG is enabled, AugmentTools keeps the core tools plus the
top-k (ARTEX_TOOL_RAG_TOPK, default 12) BM25-ranked bound tools for the
worker intent / planner goal. Runs after ToolResolve, so it only removes;
falls back to the full list on empty query, catalog under 20, or scorer error.
2026-11-12 15:42:31 +01:00

198 lines
6.4 KiB
Go

package agent
import (
"context"
"fmt"
"log"
"math"
"os"
"sort"
"strconv"
"strings"
"unicode"
actool "github.com/Autumn-27/norma/tool"
)
// Tool RAG: a lexical PROJECTION of an agent's already-bound tool list. It runs in
// AugmentTools after ToolResolve (binding + enabled filters), so it only ever
// removes tools from that list: it cannot add an unbound or disabled tool, never
// rewrites a description or schema, and never reads the database. Off by default.
const (
toolRAGEnv = "ARTEX_TOOL_RAG" // "1"/"true"/"on"/"yes" enables; anything else (incl. unset) = off
toolRAGTopKEnv = "ARTEX_TOOL_RAG_TOPK" // non-core tools kept; default toolRAGDefaultK
toolRAGDefaultK = 12
toolRAGMinCatalog = 20 // smaller catalogs are passed through whole
)
// toolRAGCore is always kept when bound and never counts against k.
var toolRAGCore = map[string]bool{
"record_fact": true, "insert_assets": true, "report_finding": true, "list_findings": true,
"graph_overview": true, "node_detail": true, "add_intent": true, "steer_work": true,
"ask_operator": true, "prove_goal": true, "goal_met": true, "list_assets": true,
}
type toolQueryKey struct{}
// WithToolQuery attaches the retrieval query (goal + current intent/round summary)
// read by AugmentTools. No query = no projection.
func WithToolQuery(ctx context.Context, q string) context.Context {
return context.WithValue(ctx, toolQueryKey{}, q)
}
// ToolScorer returns one relevance score per tool (same order as tools); higher is
// more relevant. It must be pure: no network, no database.
type ToolScorer func(query string, tools []actool.CoreTool) ([]float64, error)
// projectTools keeps the tools in keep (core / harness / deferred names) plus the
// k best-scoring other tools, preserving the input order. It falls back to the full
// list (projected=false) on an empty query, a catalog under toolRAGMinCatalog, or a
// scorer error.
func projectTools(query string, tools []actool.CoreTool, keep map[string]bool, k int, score ToolScorer) (out []actool.CoreTool, projected bool, err error) {
if strings.TrimSpace(query) == "" || len(tools) < toolRAGMinCatalog {
return tools, false, nil
}
var cands []actool.CoreTool
for _, t := range tools {
if !keep[t.Name()] {
cands = append(cands, t)
}
}
ranked, err := rankTools(query, cands, score)
if err != nil {
return tools, false, err
}
sel := map[string]bool{}
for i := 0; i < len(ranked) && i < k; i++ {
sel[ranked[i].Name()] = true
}
for _, t := range tools {
if keep[t.Name()] || sel[t.Name()] {
out = append(out, t)
}
}
return out, true, nil
}
// rankTools orders tools by score descending, ties broken by name ascending.
func rankTools(query string, tools []actool.CoreTool, score ToolScorer) ([]actool.CoreTool, error) {
scores, err := score(query, tools)
if err == nil && len(scores) != len(tools) {
err = fmt.Errorf("scorer returned %d scores for %d tools", len(scores), len(tools))
}
if err != nil {
return nil, err
}
idx := make([]int, len(tools))
for i := range idx {
idx[i] = i
}
sort.SliceStable(idx, func(a, b int) bool {
if sa, sb := scores[idx[a]], scores[idx[b]]; sa != sb {
return sa > sb
}
return tools[idx[a]].Name() < tools[idx[b]].Name()
})
out := make([]actool.CoreTool, len(idx))
for i, j := range idx {
out[i] = tools[j]
}
return out, nil
}
// bm25Score is the default ToolScorer: Okapi BM25 (k1=1.2, b=0.75) over each tool's
// name + description + parameter names, IDF computed across the candidate set.
func bm25Score(query string, tools []actool.CoreTool) ([]float64, error) {
const k1, b = 1.2, 0.75
docs := make([]map[string]int, len(tools))
lens := make([]float64, len(tools))
df := map[string]int{}
var total float64
for i, t := range tools {
text := t.Name() + " " + t.Description()
if props, ok := t.InputSchema()["properties"].(map[string]any); ok {
for p := range props {
text += " " + p
}
}
tf := map[string]int{}
for _, w := range tokenize(text) {
tf[w]++
lens[i]++
}
for w := range tf {
df[w]++
}
docs[i], total = tf, total+lens[i]
}
scores := make([]float64, len(tools))
n := float64(len(tools))
avg, seen := math.Max(total/math.Max(n, 1), 1), map[string]bool{}
for _, q := range tokenize(query) {
if seen[q] || df[q] == 0 {
continue
}
seen[q] = true
idf := math.Log(1 + (n-float64(df[q])+0.5)/(float64(df[q])+0.5))
for i, tf := range docs {
if f := float64(tf[q]); f > 0 {
scores[i] += idf * f * (k1 + 1) / (f + k1*(1-b+b*lens[i]/avg))
}
}
}
return scores, nil
}
// tokenize lowercases and splits on non-alphanumerics ("sql_injection" → sql, injection).
func tokenize(s string) []string {
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// toolRAGKeep: names never hidden by the projection — the core set, the harness
// tools every agent relies on (file/shell tools, interactive shell, Skill
// meta-tool), and deferred tools (schemas already withheld; dropping them would
// desync the deferred block and the skill unlock gate).
func toolRAGKeep(deferred []string) map[string]bool {
keep := map[string]bool{"Skill": true}
for n := range toolRAGCore {
keep[n] = true
}
for _, t := range append(actool.DefaultTools(), actool.ShellSessionTools()...) {
keep[t.Name()] = true
}
for _, n := range deferred {
keep[n] = true
}
return keep
}
// applyToolRAG is the AugmentTools hook. Off unless ARTEX_TOOL_RAG is enabled.
func applyToolRAG(ctx context.Context, agentKey string, tools []actool.CoreTool, deferred []string, score ToolScorer) []actool.CoreTool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(toolRAGEnv))) {
case "1", "true", "on", "yes":
default:
return tools
}
k, kerr := strconv.Atoi(strings.TrimSpace(os.Getenv(toolRAGTopKEnv)))
if kerr != nil || k <= 0 {
k = toolRAGDefaultK
}
query, _ := ctx.Value(toolQueryKey{}).(string)
out, projected, err := projectTools(query, tools, toolRAGKeep(deferred), k, score)
if err != nil {
log.Printf("[tool-rag] %s: scoring failed, using full catalog (%d tools): %v", agentKey, len(tools), err)
} else if !projected {
log.Printf("[tool-rag] %s: full catalog (%d tools; empty query or under %d)", agentKey, len(tools), toolRAGMinCatalog)
} else {
names := make([]string, len(out))
for i, t := range out {
names[i] = t.Name()
}
log.Printf("[tool-rag] %s: selected %d/%d tools: %s", agentKey, len(out), len(tools), strings.Join(names, ", "))
}
return out
}