Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion control-plane/internal/cli/agent_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func NewAgentCommand() *cobra.Command {

cmd.AddCommand(newAgentStatusCmd())
cmd.AddCommand(newAgentDiscoverCmd())
cmd.AddCommand(newAgentSearchCmd())
cmd.AddCommand(newAgentQueryCmd())
cmd.AddCommand(newAgentRunCmd())
cmd.AddCommand(newAgentAgentSummaryCmd())
Expand Down Expand Up @@ -127,6 +128,42 @@ func newAgentDiscoverCmd() *cobra.Command {
return cmd
}

func newAgentSearchCmd() *cobra.Command {
var agentID string
var limit int

cmd := &cobra.Command{
Use: "search <query>",
Short: "Rank installed reasoners by a free-text query (BM25)",
Long: "Ranked reasoner search across all registered agents. Prefer this over " +
"dumping full capabilities when many reasoners are installed — use the " +
"invocation_target from each result to call the reasoner directly.",
Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) {
query := strings.TrimSpace(strings.Join(args, " "))
if query == "" {
agentError("missing_query", "a search query is required",
"Provide free text, for example: af agent search \"review pull request\"")
}

params := url.Values{}
params.Set("q", query)
if v := strings.TrimSpace(agentID); v != "" {
params.Set("agent", v)
}
if limit > 0 {
params.Set("limit", strconv.Itoa(limit))
}

proxyToServer(http.MethodGet, "/api/v1/agentic/reasoners?"+params.Encode(), nil)
},
}

cmd.Flags().StringVar(&agentID, "agent", "", "Restrict search to a single agent ID")
cmd.Flags().IntVar(&limit, "limit", 10, "Maximum results (default 10, max 50)")
return cmd
}

func newAgentQueryCmd() *cobra.Command {
var resource string
var status string
Expand Down Expand Up @@ -658,6 +695,16 @@ func agentHelpData() map[string]interface{} {
},
"example": "af agent discover -q execute --group agentic --method GET --limit 10",
},
{
"name": "search",
"description": "Rank installed reasoners by a free-text query (BM25)",
"usage": "af agent search \"<free text>\" [--agent <id>] [--limit N]",
"flags": []map[string]string{
{"name": "agent", "short": "", "type": "string"},
{"name": "limit", "short": "", "type": "int"},
},
"example": "af agent search \"review pull request\" --limit 5",
},
{
"name": "query",
"description": "Query runs/executions/agents/workflows/sessions",
Expand Down Expand Up @@ -741,6 +788,7 @@ func agentHelpData() map[string]interface{} {
"quick_start": []string{
"af agent status",
"af agent discover -q run",
"af agent search \"review pull request\"",
"af agent query -r runs --limit 10",
"af agent run --id <run_id>",
"af agent kb topics",
Expand All @@ -749,7 +797,7 @@ func agentHelpData() map[string]interface{} {
"auth": map[string]interface{}{
"method": "Set X-API-Key header via --api-key or AGENTFIELD_API_KEY",
"public_endpoints": []string{"GET /api/v1/agentic/kb/topics", "GET /api/v1/agentic/kb/articles", "GET /api/v1/agentic/kb/articles/:article_id", "GET /api/v1/agentic/kb/guide"},
"requires_auth": []string{"GET /api/v1/agentic/status", "GET /api/v1/agentic/discover", "POST /api/v1/agentic/query", "GET /api/v1/agentic/run/:run_id", "GET /api/v1/agentic/agent/:agent_id/summary", "POST /api/v1/agentic/batch"},
"requires_auth": []string{"GET /api/v1/agentic/status", "GET /api/v1/agentic/discover", "GET /api/v1/agentic/reasoners", "POST /api/v1/agentic/query", "GET /api/v1/agentic/run/:run_id", "GET /api/v1/agentic/agent/:agent_id/summary", "POST /api/v1/agentic/batch"},
},
"response_schemas": map[string]interface{}{
"success": map[string]interface{}{
Expand Down
3 changes: 2 additions & 1 deletion control-plane/internal/cli/agent_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ installed:
require.Contains(t, output, "Installed agent nodes (1)")
require.Contains(t, output, "demo")
require.Contains(t, output, "v1.2.3")
require.Contains(t, output, "8123") // running node's port cell
require.Contains(t, output, "8123") // running node's port cell
require.Contains(t, output, "running") // status badge
_ = port
_ = pid
Expand Down Expand Up @@ -300,6 +300,7 @@ func TestAgentCommandSubcommands(t *testing.T) {
}{
{name: "status", args: []string{"status"}, wantMethod: http.MethodGet, wantPath: "/api/v1/agentic/status"},
{name: "discover", args: []string{"discover", "--query", "runs", "--group", "agentic", "--method", "get", "--limit", "5"}, wantMethod: http.MethodGet, wantPath: "/api/v1/agentic/discover", wantQuery: "group=agentic&limit=5&method=GET&q=runs"},
{name: "search", args: []string{"search", "review pull request", "--agent", "pr-af", "--limit", "5"}, wantMethod: http.MethodGet, wantPath: "/api/v1/agentic/reasoners", wantQuery: "agent=pr-af&limit=5&q=review+pull+request"},
{name: "query", args: []string{"query", "--resource", "runs", "--status", "completed", "--agent-id", "agent-1", "--run-id", "run-1", "--since", "2026-01-01T00:00:00Z", "--until", "2026-01-02T00:00:00Z", "--limit", "5", "--offset", "2", "--include", "steps,metrics"}, wantMethod: http.MethodPost, wantPath: "/api/v1/agentic/query", wantBodyPart: `"resource":"runs"`},
{name: "run", args: []string{"run", "--id", "run/1"}, wantMethod: http.MethodGet, wantPath: "/api/v1/agentic/run/run/1"},
{name: "agent summary", args: []string{"agent-summary", "--id", "agent/1"}, wantMethod: http.MethodGet, wantPath: "/api/v1/agentic/agent/agent/1/summary"},
Expand Down
217 changes: 217 additions & 0 deletions control-plane/internal/handlers/agentic/bm25.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package agentic

import (
"math"
"sort"
"strings"
"unicode"
)

// BM25 saturation parameters. k1 controls term-frequency saturation and b the
// document-length normalization — the standard defaults work well for the small
// reasoner corpus (a few thousand docs at most), which is rebuilt per request.
const (
bm25K1 = 1.2
bm25B = 0.75
)

// searchField is one weighted text field of a searchable document. The boost is
// applied at term-frequency time, giving a BM25F-lite ranker: a term found in a
// high-boost field (e.g. the reasoner id) contributes proportionally more than
// the same term in a low-boost field (e.g. the owning agent id).
type searchField struct {
boost float64
text string
}

// searchDoc is one document fed to the index, identified by an opaque stable id
// (used both for tie-breaking and for mapping a hit back to its source record).
type searchDoc struct {
id string
fields []searchField
}

// searchHit is a scored document, highest score first.
type searchHit struct {
id string
score float64
}

// scoredDoc is the indexed form of a searchDoc: per-term weighted frequencies
// plus the (boost-weighted) document length used for length normalization.
type scoredDoc struct {
id string
weights map[string]float64
length float64
}

// bm25Index is a self-contained, in-memory BM25F-lite index. It carries no
// external dependencies and is cheap enough to rebuild on every request.
type bm25Index struct {
k1 float64
b float64
docs []scoredDoc
df map[string]int
numDoc int
avgdl float64
}

// newBM25Index tokenizes and indexes the supplied documents. Field boosts are
// folded into each term's weighted frequency, and document frequency counts a
// term once per document regardless of how many fields it appears in.
func newBM25Index(docs []searchDoc) *bm25Index {
idx := &bm25Index{
k1: bm25K1,
b: bm25B,
df: make(map[string]int),
numDoc: len(docs),
}

var totalLen float64
for _, d := range docs {
weights := make(map[string]float64)
var length float64
for _, f := range d.fields {
toks := tokenize(f.text)
if len(toks) == 0 {
continue
}
length += float64(len(toks)) * f.boost
for _, t := range toks {
weights[t] += f.boost
}
}
for t := range weights {
idx.df[t]++
}
idx.docs = append(idx.docs, scoredDoc{id: d.id, weights: weights, length: length})
totalLen += length
}

if idx.numDoc > 0 {
idx.avgdl = totalLen / float64(idx.numDoc)
}
return idx
}

// Search ranks every indexed document against the free-text query and returns
// the matches (score > 0) ordered by descending score, ties broken by ascending
// document id for deterministic output.
func (idx *bm25Index) Search(query string) []searchHit {
if idx.numDoc == 0 {
return nil
}
qterms := uniqueTokens(query)
if len(qterms) == 0 {
return nil
}

hits := make([]searchHit, 0)
for _, d := range idx.docs {
var score float64
for _, qt := range qterms {
wtf, ok := d.weights[qt]
if !ok || wtf <= 0 {
continue
}
df := idx.df[qt]
idf := math.Log(1 + (float64(idx.numDoc)-float64(df)+0.5)/(float64(df)+0.5))

lengthRatio := 1.0
if idx.avgdl > 0 {
lengthRatio = d.length / idx.avgdl
}
denom := wtf + idx.k1*(1-idx.b+idx.b*lengthRatio)
if denom == 0 {
continue
}
score += idf * (wtf * (idx.k1 + 1)) / denom
}
if score > 0 {
hits = append(hits, searchHit{id: d.id, score: score})
}
}

sort.SliceStable(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
return hits[i].id < hits[j].id
})
return hits
}

// tokenize lowercases and splits text on non-alphanumeric boundaries, then
// further splits camelCase / PascalCase / acronym runs. This makes reasoner ids
// like "run_pr_resolver" and "reviewPRRequest" match natural-language queries
// such as "pr resolve" or "review request".
func tokenize(s string) []string {
if s == "" {
return nil
}
fields := strings.FieldsFunc(s, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
tokens := make([]string, 0, len(fields))
for _, f := range fields {
for _, part := range splitCamel(f) {
part = strings.ToLower(part)
if part != "" {
tokens = append(tokens, part)
}
}
}
return tokens
}

// uniqueTokens tokenizes and de-duplicates, preserving first-seen order. Query
// terms are scored once each regardless of repetition.
func uniqueTokens(s string) []string {
toks := tokenize(s)
seen := make(map[string]struct{}, len(toks))
out := make([]string, 0, len(toks))
for _, t := range toks {
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
return out
}

// splitCamel breaks a single alphanumeric word at camelCase and acronym
// boundaries: "reviewPR" -> [review, PR]; "PRReview" -> [PR, Review];
// "v2Handler" -> [v2, Handler]. Words with no internal boundary pass through
// unchanged.
func splitCamel(word string) []string {
if word == "" {
return nil
}
runes := []rune(word)
if len(runes) == 1 {
return []string{word}
}

var parts []string
start := 0
for i := 1; i < len(runes); i++ {
prev := runes[i-1]
cur := runes[i]
boundary := false
switch {
case (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(cur):
// lowercase/digit -> uppercase: "foo|Bar", "v2|Handler"
boundary = true
case unicode.IsUpper(prev) && unicode.IsUpper(cur) && i+1 < len(runes) && unicode.IsLower(runes[i+1]):
// acronym -> word: "HTTP|Server"
boundary = true
}
if boundary {
parts = append(parts, string(runes[start:i]))
start = i
}
}
parts = append(parts, string(runes[start:]))
return parts
}
Loading
Loading