mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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.
198 lines
6.4 KiB
Go
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
|
|
}
|