Skip to content

Commit 4db5244

Browse files
AbirAbbasclaude
andauthored
feat(agentic): ranked reasoner search + ambient load metadata (beta-plan: Santosh's recommendations) (#784)
* feat(agentic): ranked reasoner search + ambient load metadata Two beta-plan items ("Santosh's recommendations") for the sub-harness flow, where a coding agent drives AgentField through `af agent` and the agentfield-use skill: 1. Reasoner discovery at scale. With hundreds of reasoners installed the brain must search, not list. New GET /api/v1/agentic/reasoners ?q=<free text>&agent=<id>&limit=<1..50> ranks reasoners with a dependency-free BM25F-lite index built per request from the same registration data discovery serves (id boost 3.0, tags 2.0, agent 1.5, metadata description 1.0; snake/kebab/camelCase-aware tokenizer; deterministic tie-break). Each hit carries invocation_target + agent_health so the brain dispatches with no second lookup. CLI: `af agent search "<text>" [--agent] [--limit]`. 2. Ambient machine-load metadata. Every agentic response now carries meta.load = {running_agents, total_agents, active_executions, cpu_cores, recommended_max_concurrent} via a load provider stamped into respondOK (2s TTL cache; omitted silently on error — never fails a response). recommended_max_concurrent = max(1, cores/2), cores-based; memory-aware refinement noted as follow-up. The `af agent` CLI already forwards server meta, so the driving agent gets load data with zero extra round-trips. The agentfield-use skill (v0.2.0 -> v0.3.0, embedded mirror synced) teaches both: search-first discovery past ~20 reasoners, and pacing — if active_executions >= recommended_max_concurrent, finish in-flight work before launching more. Verified end-to-end on a live control plane with 78 registered reasoners: "review pull request" ranks pr-af-go:review_dimension / review on top, --agent filters, empty q -> structured missing_query, meta.load rides every response (16 cores -> recommendation 8). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agentic): constant allocation cap for reasoner search results CodeQL (go/uncontrolled-allocation-size) does not track the limit clamp through getIntQuery. Allocate at the constant max (50) instead - the loop still stops at the requested limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e04a84f commit 4db5244

13 files changed

Lines changed: 1162 additions & 5 deletions

File tree

control-plane/internal/cli/agent_commands.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func NewAgentCommand() *cobra.Command {
6767

6868
cmd.AddCommand(newAgentStatusCmd())
6969
cmd.AddCommand(newAgentDiscoverCmd())
70+
cmd.AddCommand(newAgentSearchCmd())
7071
cmd.AddCommand(newAgentQueryCmd())
7172
cmd.AddCommand(newAgentRunCmd())
7273
cmd.AddCommand(newAgentAgentSummaryCmd())
@@ -127,6 +128,42 @@ func newAgentDiscoverCmd() *cobra.Command {
127128
return cmd
128129
}
129130

131+
func newAgentSearchCmd() *cobra.Command {
132+
var agentID string
133+
var limit int
134+
135+
cmd := &cobra.Command{
136+
Use: "search <query>",
137+
Short: "Rank installed reasoners by a free-text query (BM25)",
138+
Long: "Ranked reasoner search across all registered agents. Prefer this over " +
139+
"dumping full capabilities when many reasoners are installed — use the " +
140+
"invocation_target from each result to call the reasoner directly.",
141+
Args: cobra.ArbitraryArgs,
142+
Run: func(cmd *cobra.Command, args []string) {
143+
query := strings.TrimSpace(strings.Join(args, " "))
144+
if query == "" {
145+
agentError("missing_query", "a search query is required",
146+
"Provide free text, for example: af agent search \"review pull request\"")
147+
}
148+
149+
params := url.Values{}
150+
params.Set("q", query)
151+
if v := strings.TrimSpace(agentID); v != "" {
152+
params.Set("agent", v)
153+
}
154+
if limit > 0 {
155+
params.Set("limit", strconv.Itoa(limit))
156+
}
157+
158+
proxyToServer(http.MethodGet, "/api/v1/agentic/reasoners?"+params.Encode(), nil)
159+
},
160+
}
161+
162+
cmd.Flags().StringVar(&agentID, "agent", "", "Restrict search to a single agent ID")
163+
cmd.Flags().IntVar(&limit, "limit", 10, "Maximum results (default 10, max 50)")
164+
return cmd
165+
}
166+
130167
func newAgentQueryCmd() *cobra.Command {
131168
var resource string
132169
var status string
@@ -658,6 +695,16 @@ func agentHelpData() map[string]interface{} {
658695
},
659696
"example": "af agent discover -q execute --group agentic --method GET --limit 10",
660697
},
698+
{
699+
"name": "search",
700+
"description": "Rank installed reasoners by a free-text query (BM25)",
701+
"usage": "af agent search \"<free text>\" [--agent <id>] [--limit N]",
702+
"flags": []map[string]string{
703+
{"name": "agent", "short": "", "type": "string"},
704+
{"name": "limit", "short": "", "type": "int"},
705+
},
706+
"example": "af agent search \"review pull request\" --limit 5",
707+
},
661708
{
662709
"name": "query",
663710
"description": "Query runs/executions/agents/workflows/sessions",
@@ -741,6 +788,7 @@ func agentHelpData() map[string]interface{} {
741788
"quick_start": []string{
742789
"af agent status",
743790
"af agent discover -q run",
791+
"af agent search \"review pull request\"",
744792
"af agent query -r runs --limit 10",
745793
"af agent run --id <run_id>",
746794
"af agent kb topics",
@@ -749,7 +797,7 @@ func agentHelpData() map[string]interface{} {
749797
"auth": map[string]interface{}{
750798
"method": "Set X-API-Key header via --api-key or AGENTFIELD_API_KEY",
751799
"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"},
752-
"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"},
800+
"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"},
753801
},
754802
"response_schemas": map[string]interface{}{
755803
"success": map[string]interface{}{

control-plane/internal/cli/agent_helpers_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ installed:
213213
require.Contains(t, output, "Installed agent nodes (1)")
214214
require.Contains(t, output, "demo")
215215
require.Contains(t, output, "v1.2.3")
216-
require.Contains(t, output, "8123") // running node's port cell
216+
require.Contains(t, output, "8123") // running node's port cell
217217
require.Contains(t, output, "running") // status badge
218218
_ = port
219219
_ = pid
@@ -300,6 +300,7 @@ func TestAgentCommandSubcommands(t *testing.T) {
300300
}{
301301
{name: "status", args: []string{"status"}, wantMethod: http.MethodGet, wantPath: "/api/v1/agentic/status"},
302302
{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"},
303+
{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"},
303304
{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"`},
304305
{name: "run", args: []string{"run", "--id", "run/1"}, wantMethod: http.MethodGet, wantPath: "/api/v1/agentic/run/run/1"},
305306
{name: "agent summary", args: []string{"agent-summary", "--id", "agent/1"}, wantMethod: http.MethodGet, wantPath: "/api/v1/agentic/agent/agent/1/summary"},
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package agentic
2+
3+
import (
4+
"math"
5+
"sort"
6+
"strings"
7+
"unicode"
8+
)
9+
10+
// BM25 saturation parameters. k1 controls term-frequency saturation and b the
11+
// document-length normalization — the standard defaults work well for the small
12+
// reasoner corpus (a few thousand docs at most), which is rebuilt per request.
13+
const (
14+
bm25K1 = 1.2
15+
bm25B = 0.75
16+
)
17+
18+
// searchField is one weighted text field of a searchable document. The boost is
19+
// applied at term-frequency time, giving a BM25F-lite ranker: a term found in a
20+
// high-boost field (e.g. the reasoner id) contributes proportionally more than
21+
// the same term in a low-boost field (e.g. the owning agent id).
22+
type searchField struct {
23+
boost float64
24+
text string
25+
}
26+
27+
// searchDoc is one document fed to the index, identified by an opaque stable id
28+
// (used both for tie-breaking and for mapping a hit back to its source record).
29+
type searchDoc struct {
30+
id string
31+
fields []searchField
32+
}
33+
34+
// searchHit is a scored document, highest score first.
35+
type searchHit struct {
36+
id string
37+
score float64
38+
}
39+
40+
// scoredDoc is the indexed form of a searchDoc: per-term weighted frequencies
41+
// plus the (boost-weighted) document length used for length normalization.
42+
type scoredDoc struct {
43+
id string
44+
weights map[string]float64
45+
length float64
46+
}
47+
48+
// bm25Index is a self-contained, in-memory BM25F-lite index. It carries no
49+
// external dependencies and is cheap enough to rebuild on every request.
50+
type bm25Index struct {
51+
k1 float64
52+
b float64
53+
docs []scoredDoc
54+
df map[string]int
55+
numDoc int
56+
avgdl float64
57+
}
58+
59+
// newBM25Index tokenizes and indexes the supplied documents. Field boosts are
60+
// folded into each term's weighted frequency, and document frequency counts a
61+
// term once per document regardless of how many fields it appears in.
62+
func newBM25Index(docs []searchDoc) *bm25Index {
63+
idx := &bm25Index{
64+
k1: bm25K1,
65+
b: bm25B,
66+
df: make(map[string]int),
67+
numDoc: len(docs),
68+
}
69+
70+
var totalLen float64
71+
for _, d := range docs {
72+
weights := make(map[string]float64)
73+
var length float64
74+
for _, f := range d.fields {
75+
toks := tokenize(f.text)
76+
if len(toks) == 0 {
77+
continue
78+
}
79+
length += float64(len(toks)) * f.boost
80+
for _, t := range toks {
81+
weights[t] += f.boost
82+
}
83+
}
84+
for t := range weights {
85+
idx.df[t]++
86+
}
87+
idx.docs = append(idx.docs, scoredDoc{id: d.id, weights: weights, length: length})
88+
totalLen += length
89+
}
90+
91+
if idx.numDoc > 0 {
92+
idx.avgdl = totalLen / float64(idx.numDoc)
93+
}
94+
return idx
95+
}
96+
97+
// Search ranks every indexed document against the free-text query and returns
98+
// the matches (score > 0) ordered by descending score, ties broken by ascending
99+
// document id for deterministic output.
100+
func (idx *bm25Index) Search(query string) []searchHit {
101+
if idx.numDoc == 0 {
102+
return nil
103+
}
104+
qterms := uniqueTokens(query)
105+
if len(qterms) == 0 {
106+
return nil
107+
}
108+
109+
hits := make([]searchHit, 0)
110+
for _, d := range idx.docs {
111+
var score float64
112+
for _, qt := range qterms {
113+
wtf, ok := d.weights[qt]
114+
if !ok || wtf <= 0 {
115+
continue
116+
}
117+
df := idx.df[qt]
118+
idf := math.Log(1 + (float64(idx.numDoc)-float64(df)+0.5)/(float64(df)+0.5))
119+
120+
lengthRatio := 1.0
121+
if idx.avgdl > 0 {
122+
lengthRatio = d.length / idx.avgdl
123+
}
124+
denom := wtf + idx.k1*(1-idx.b+idx.b*lengthRatio)
125+
if denom == 0 {
126+
continue
127+
}
128+
score += idf * (wtf * (idx.k1 + 1)) / denom
129+
}
130+
if score > 0 {
131+
hits = append(hits, searchHit{id: d.id, score: score})
132+
}
133+
}
134+
135+
sort.SliceStable(hits, func(i, j int) bool {
136+
if hits[i].score != hits[j].score {
137+
return hits[i].score > hits[j].score
138+
}
139+
return hits[i].id < hits[j].id
140+
})
141+
return hits
142+
}
143+
144+
// tokenize lowercases and splits text on non-alphanumeric boundaries, then
145+
// further splits camelCase / PascalCase / acronym runs. This makes reasoner ids
146+
// like "run_pr_resolver" and "reviewPRRequest" match natural-language queries
147+
// such as "pr resolve" or "review request".
148+
func tokenize(s string) []string {
149+
if s == "" {
150+
return nil
151+
}
152+
fields := strings.FieldsFunc(s, func(r rune) bool {
153+
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
154+
})
155+
tokens := make([]string, 0, len(fields))
156+
for _, f := range fields {
157+
for _, part := range splitCamel(f) {
158+
part = strings.ToLower(part)
159+
if part != "" {
160+
tokens = append(tokens, part)
161+
}
162+
}
163+
}
164+
return tokens
165+
}
166+
167+
// uniqueTokens tokenizes and de-duplicates, preserving first-seen order. Query
168+
// terms are scored once each regardless of repetition.
169+
func uniqueTokens(s string) []string {
170+
toks := tokenize(s)
171+
seen := make(map[string]struct{}, len(toks))
172+
out := make([]string, 0, len(toks))
173+
for _, t := range toks {
174+
if _, ok := seen[t]; ok {
175+
continue
176+
}
177+
seen[t] = struct{}{}
178+
out = append(out, t)
179+
}
180+
return out
181+
}
182+
183+
// splitCamel breaks a single alphanumeric word at camelCase and acronym
184+
// boundaries: "reviewPR" -> [review, PR]; "PRReview" -> [PR, Review];
185+
// "v2Handler" -> [v2, Handler]. Words with no internal boundary pass through
186+
// unchanged.
187+
func splitCamel(word string) []string {
188+
if word == "" {
189+
return nil
190+
}
191+
runes := []rune(word)
192+
if len(runes) == 1 {
193+
return []string{word}
194+
}
195+
196+
var parts []string
197+
start := 0
198+
for i := 1; i < len(runes); i++ {
199+
prev := runes[i-1]
200+
cur := runes[i]
201+
boundary := false
202+
switch {
203+
case (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(cur):
204+
// lowercase/digit -> uppercase: "foo|Bar", "v2|Handler"
205+
boundary = true
206+
case unicode.IsUpper(prev) && unicode.IsUpper(cur) && i+1 < len(runes) && unicode.IsLower(runes[i+1]):
207+
// acronym -> word: "HTTP|Server"
208+
boundary = true
209+
}
210+
if boundary {
211+
parts = append(parts, string(runes[start:i]))
212+
start = i
213+
}
214+
}
215+
parts = append(parts, string(runes[start:]))
216+
return parts
217+
}

0 commit comments

Comments
 (0)