diff --git a/control-plane/internal/cli/agent_commands.go b/control-plane/internal/cli/agent_commands.go index af5bdaf22..964f9b833 100644 --- a/control-plane/internal/cli/agent_commands.go +++ b/control-plane/internal/cli/agent_commands.go @@ -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()) @@ -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 ", + 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 @@ -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 \"\" [--agent ] [--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", @@ -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 ", "af agent kb topics", @@ -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{}{ diff --git a/control-plane/internal/cli/agent_helpers_test.go b/control-plane/internal/cli/agent_helpers_test.go index fe3f637f0..6185ea3b2 100644 --- a/control-plane/internal/cli/agent_helpers_test.go +++ b/control-plane/internal/cli/agent_helpers_test.go @@ -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 @@ -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"}, diff --git a/control-plane/internal/handlers/agentic/bm25.go b/control-plane/internal/handlers/agentic/bm25.go new file mode 100644 index 000000000..6f3c157b2 --- /dev/null +++ b/control-plane/internal/handlers/agentic/bm25.go @@ -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 +} diff --git a/control-plane/internal/handlers/agentic/bm25_test.go b/control-plane/internal/handlers/agentic/bm25_test.go new file mode 100644 index 000000000..fcb81a2e6 --- /dev/null +++ b/control-plane/internal/handlers/agentic/bm25_test.go @@ -0,0 +1,115 @@ +package agentic + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenize(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {name: "empty", in: "", want: nil}, + {name: "snake_case", in: "run_pr_resolver", want: []string{"run", "pr", "resolver"}}, + {name: "kebab-case", in: "review-pull-request", want: []string{"review", "pull", "request"}}, + {name: "camelCase", in: "reviewPullRequest", want: []string{"review", "pull", "request"}}, + {name: "PascalCase", in: "PlanTask", want: []string{"plan", "task"}}, + {name: "acronym then word", in: "reviewPRRequest", want: []string{"review", "pr", "request"}}, + {name: "leading acronym", in: "HTTPServer", want: []string{"http", "server"}}, + {name: "digit boundary", in: "v2Handler", want: []string{"v2", "handler"}}, + {name: "mixed separators", in: "get forecast, now!", want: []string{"get", "forecast", "now"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tokenize(tt.in)) + }) + } +} + +// buildIndex is a small corpus of reasoner-shaped documents used across the +// ranking tests. It mirrors the field-weighting the handler applies. +func fieldsFor(id, tags, agentID string) []searchField { + return []searchField{ + {boost: reasonerFieldBoostID, text: id}, + {boost: reasonerFieldBoostTags, text: tags}, + {boost: reasonerFieldBoostAgentID, text: agentID}, + } +} + +func rankedIDs(hits []searchHit) []string { + ids := make([]string, len(hits)) + for i, h := range hits { + ids[i] = h.id + } + return ids +} + +func TestBM25Search(t *testing.T) { + docs := []searchDoc{ + {id: "pr-af:review_pull_request", fields: fieldsFor("review_pull_request", "pr code-review", "pr-af")}, + {id: "swe:plan_task", fields: fieldsFor("plan_task", "planning", "swe")}, + {id: "misc:run_pr_resolver", fields: fieldsFor("run_pr_resolver", "automation", "misc")}, + {id: "sec:audit", fields: fieldsFor("audit", "security vulnerability", "sec")}, + {id: "weather:get_forecast", fields: fieldsFor("get_forecast", "weather", "weather-agent")}, + } + idx := newBM25Index(docs) + + t.Run("exact id match ranks first", func(t *testing.T) { + hits := idx.Search("plan_task") + require.NotEmpty(t, hits) + assert.Equal(t, "swe:plan_task", hits[0].id) + }) + + t.Run("multi-token query ranks pr-review reasoner first", func(t *testing.T) { + hits := idx.Search("review pull request") + require.NotEmpty(t, hits) + assert.Equal(t, "pr-af:review_pull_request", hits[0].id) + }) + + t.Run("tag-only match is found", func(t *testing.T) { + hits := idx.Search("vulnerability") + ids := rankedIDs(hits) + require.Len(t, ids, 1) + assert.Equal(t, "sec:audit", ids[0]) + }) + + t.Run("snake_case splitting lets pr resolve match run_pr_resolver", func(t *testing.T) { + hits := idx.Search("pr resolve") + ids := rankedIDs(hits) + assert.Contains(t, ids, "misc:run_pr_resolver") + }) + + t.Run("no match returns empty", func(t *testing.T) { + assert.Empty(t, idx.Search("kubernetes")) + }) + + t.Run("empty query returns empty", func(t *testing.T) { + assert.Empty(t, idx.Search(" ")) + }) +} + +func TestBM25DeterministicTieBreak(t *testing.T) { + // Two documents with byte-identical searchable content: scores tie, so the + // order must be deterministic — ascending by document id. + docs := []searchDoc{ + {id: "zeta:same", fields: fieldsFor("same", "", "")}, + {id: "alpha:same", fields: fieldsFor("same", "", "")}, + } + idx := newBM25Index(docs) + + hits := idx.Search("same") + require.Len(t, hits, 2) + assert.Equal(t, hits[0].score, hits[1].score, "scores should tie") + assert.Equal(t, "alpha:same", hits[0].id) + assert.Equal(t, "zeta:same", hits[1].id) +} + +func TestBM25EmptyCorpus(t *testing.T) { + idx := newBM25Index(nil) + assert.Empty(t, idx.Search("anything")) +} diff --git a/control-plane/internal/handlers/agentic/helpers.go b/control-plane/internal/handlers/agentic/helpers.go index d9564d080..d965ab4a3 100644 --- a/control-plane/internal/handlers/agentic/helpers.go +++ b/control-plane/internal/handlers/agentic/helpers.go @@ -27,6 +27,10 @@ type ErrorInfo struct { type MetaInfo struct { TokenEstimate int `json:"token_estimate,omitempty"` Hint string `json:"hint,omitempty"` + // Load is ambient machine-load metadata attached to every successful + // response so the driving agent can pace heavy work without extra + // round-trips. Omitted when no load provider is registered. + Load *LoadInfo `json:"load,omitempty"` } // respondOK sends a successful agentic response. @@ -36,6 +40,11 @@ func respondOK(c *gin.Context, data interface{}) { if tokens > 0 { c.Header("X-Token-Estimate", fmt.Sprintf("%d", tokens)) } + // Ambient load metadata — best-effort. If no provider is set (or it errors + // and returns nil), meta.load is silently omitted; it never fails a response. + if load := currentLoad(); load != nil { + resp.Meta = &MetaInfo{Load: load} + } c.JSON(http.StatusOK, resp) } diff --git a/control-plane/internal/handlers/agentic/load.go b/control-plane/internal/handlers/agentic/load.go new file mode 100644 index 000000000..ae769f67b --- /dev/null +++ b/control-plane/internal/handlers/agentic/load.go @@ -0,0 +1,166 @@ +package agentic + +import ( + "context" + "runtime" + "sync" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/storage" + "github.com/Agent-Field/agentfield/control-plane/pkg/types" +) + +// LoadInfo is ambient machine-load metadata stamped onto every successful +// agentic response (meta.load). It lets the driving agent decide whether to +// launch more heavy work with zero extra round-trips. +type LoadInfo struct { + // RunningAgents is the count of health-active registered agents. + RunningAgents int `json:"running_agents"` + // TotalAgents is every registered agent, active or not. + TotalAgents int `json:"total_agents"` + // ActiveExecutions is the number of non-terminal executions in flight now. + ActiveExecutions int `json:"active_executions"` + // CPUCores is runtime.NumCPU() on the control-plane host. + CPUCores int `json:"cpu_cores"` + // RecommendedMaxConcurrent is a suggested ceiling on concurrent heavy runs. + RecommendedMaxConcurrent int `json:"recommended_max_concurrent"` +} + +var ( + loadProviderMu sync.RWMutex + loadProvider func() *LoadInfo +) + +// SetLoadProvider registers the package-level provider respondOK uses to stamp +// meta.load onto every successful response. Pass nil to disable. Wiring happens +// once at route registration (see registerAgenticRoutes). A nil provider — or a +// provider that returns nil — simply omits meta.load; load metadata never fails +// a response. +func SetLoadProvider(fn func() *LoadInfo) { + loadProviderMu.Lock() + loadProvider = fn + loadProviderMu.Unlock() +} + +// currentLoad invokes the registered provider, returning nil when unset. It is +// as cheap as the provider itself (the storage-backed provider caches). +func currentLoad() *LoadInfo { + loadProviderMu.RLock() + fn := loadProvider + loadProviderMu.RUnlock() + if fn == nil { + return nil + } + return fn() +} + +// RecommendedMaxConcurrent derives a safe ceiling on concurrent heavy runs from +// the CPU core count: half the cores, floored at 1. This is deliberately +// cores-based — the Go stdlib cannot portably read total system RAM, so a +// memory-aware refinement (accounting for each run's memory footprint) is a +// follow-up. +func RecommendedMaxConcurrent(cpuCores int) int { + rec := cpuCores / 2 + if rec < 1 { + rec = 1 + } + return rec +} + +// LoadStore is the minimal storage surface the load provider reads. +type LoadStore interface { + ListAgents(ctx context.Context, filters types.AgentFilters) ([]*types.AgentNode, error) + QueryRunSummaries(ctx context.Context, filter types.ExecutionFilter) ([]*storage.RunSummaryAggregation, int, error) +} + +// cachedLoadProvider memoizes a compute function for a short TTL so a burst of +// agentic calls doesn't hammer storage — the LoadInfo is recomputed at most +// once per ttl window. +type cachedLoadProvider struct { + ttl time.Duration + compute func() (*LoadInfo, error) + + mu sync.Mutex + cached *LoadInfo + cachedAt time.Time +} + +// Load returns the cached LoadInfo when still fresh, otherwise recomputes. On a +// compute error it falls back to the last good value (possibly nil), so a +// transient storage hiccup degrades to omitting meta.load rather than failing +// the response. +func (p *cachedLoadProvider) Load() *LoadInfo { + p.mu.Lock() + defer p.mu.Unlock() + + if p.cached != nil && time.Since(p.cachedAt) < p.ttl { + return p.cached + } + info, err := p.compute() + if err != nil || info == nil { + return p.cached + } + p.cached = info + p.cachedAt = time.Now() + return info +} + +// NewStorageLoadProvider builds a cached provider backed by storage, suitable +// for SetLoadProvider. running_agents counts health-active registered agents; +// active_executions sums non-terminal executions across in-flight runs (the +// same definition the executions/active handler uses). +func NewStorageLoadProvider(store LoadStore, ttl time.Duration) func() *LoadInfo { + p := &cachedLoadProvider{ + ttl: ttl, + compute: func() (*LoadInfo, error) { return computeStorageLoad(store) }, + } + return p.Load +} + +func computeStorageLoad(store LoadStore) (*LoadInfo, error) { + ctx := context.Background() + + agents, err := store.ListAgents(ctx, types.AgentFilters{}) + if err != nil { + return nil, err + } + running := 0 + for _, a := range agents { + if a != nil && a.HealthStatus == types.HealthStatusActive { + running++ + } + } + + // active_executions is best-effort: a failure here should not sink the rest + // of the load snapshot, so a query error just leaves the count at zero. + active := 0 + if summaries, _, err := store.QueryRunSummaries(ctx, types.ExecutionFilter{ActiveOnly: true}); err == nil { + for _, agg := range summaries { + if agg == nil { + continue + } + active += activeExecutionCount(agg.StatusCounts) + } + } + + cores := runtime.NumCPU() + return &LoadInfo{ + RunningAgents: running, + TotalAgents: len(agents), + ActiveExecutions: active, + CPUCores: cores, + RecommendedMaxConcurrent: RecommendedMaxConcurrent(cores), + }, nil +} + +// activeExecutionCount sums every non-terminal execution in a run's status +// counts — mirrors the definition the executions/active handler uses. +func activeExecutionCount(statusCounts map[string]int) int { + active := 0 + for status, count := range statusCounts { + if !types.IsTerminalExecutionStatus(status) { + active += count + } + } + return active +} diff --git a/control-plane/internal/handlers/agentic/load_test.go b/control-plane/internal/handlers/agentic/load_test.go new file mode 100644 index 000000000..a388513ac --- /dev/null +++ b/control-plane/internal/handlers/agentic/load_test.go @@ -0,0 +1,197 @@ +package agentic + +import ( + "encoding/json" + "errors" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecommendedMaxConcurrent(t *testing.T) { + cases := []struct { + cores int + want int + }{ + {cores: 0, want: 1}, + {cores: 1, want: 1}, + {cores: 2, want: 1}, + {cores: 3, want: 1}, + {cores: 4, want: 2}, + {cores: 8, want: 4}, + {cores: 16, want: 8}, + } + for _, tc := range cases { + got := RecommendedMaxConcurrent(tc.cores) + assert.Equal(t, tc.want, got, "cores=%d", tc.cores) + assert.GreaterOrEqual(t, got, 1, "recommendation must never drop below 1") + } +} + +func TestCachedLoadProvider_CachesWithinTTL(t *testing.T) { + var calls int + p := &cachedLoadProvider{ + ttl: time.Minute, + compute: func() (*LoadInfo, error) { + calls++ + return &LoadInfo{RunningAgents: 1, TotalAgents: 2, CPUCores: 4, RecommendedMaxConcurrent: 2}, nil + }, + } + + first := p.Load() + second := p.Load() + require.NotNil(t, first) + assert.Equal(t, 1, calls, "second call within TTL must be served from cache") + assert.Same(t, first, second, "cached call returns the same snapshot") +} + +func TestCachedLoadProvider_RecomputesAfterTTL(t *testing.T) { + var calls int + p := &cachedLoadProvider{ + ttl: time.Minute, + compute: func() (*LoadInfo, error) { + calls++ + return &LoadInfo{RunningAgents: calls}, nil + }, + } + + p.Load() + require.Equal(t, 1, calls) + + // Force the cache to look stale without sleeping. + p.cachedAt = time.Now().Add(-2 * p.ttl) + third := p.Load() + assert.Equal(t, 2, calls) + assert.Equal(t, 2, third.RunningAgents) +} + +func TestCachedLoadProvider_ErrorFallsBackToCache(t *testing.T) { + var fail bool + p := &cachedLoadProvider{ + ttl: time.Minute, + compute: func() (*LoadInfo, error) { + if fail { + return nil, errors.New("boom") + } + return &LoadInfo{RunningAgents: 7}, nil + }, + } + + first := p.Load() + require.NotNil(t, first) + + p.cachedAt = time.Now().Add(-2 * p.ttl) // expire + fail = true + second := p.Load() + assert.Same(t, first, second, "compute error should return the last good snapshot") +} + +func TestCachedLoadProvider_ErrorWithNoCacheReturnsNil(t *testing.T) { + p := &cachedLoadProvider{ + ttl: time.Minute, + compute: func() (*LoadInfo, error) { return nil, errors.New("boom") }, + } + assert.Nil(t, p.Load()) +} + +func TestRespondOK_AttachesLoadWhenProviderSet(t *testing.T) { + SetLoadProvider(func() *LoadInfo { + return &LoadInfo{ + RunningAgents: 2, + TotalAgents: 3, + ActiveExecutions: 1, + CPUCores: 8, + RecommendedMaxConcurrent: 4, + } + }) + defer SetLoadProvider(nil) + + router := gin.New() + router.GET("/t", func(c *gin.Context) { respondOK(c, gin.H{"k": "v"}) }) + req := httptest.NewRequest("GET", "/t", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var resp AgenticResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.NotNil(t, resp.Meta) + require.NotNil(t, resp.Meta.Load) + assert.Equal(t, 2, resp.Meta.Load.RunningAgents) + assert.Equal(t, 3, resp.Meta.Load.TotalAgents) + assert.Equal(t, 1, resp.Meta.Load.ActiveExecutions) + assert.GreaterOrEqual(t, resp.Meta.Load.RecommendedMaxConcurrent, 1) +} + +func TestRespondOK_OmitsLoadWhenProviderUnset(t *testing.T) { + SetLoadProvider(nil) + + router := gin.New() + router.GET("/t", func(c *gin.Context) { respondOK(c, gin.H{"k": "v"}) }) + req := httptest.NewRequest("GET", "/t", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var resp AgenticResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Nil(t, resp.Meta, "meta.load must be omitted when no provider is registered") +} + +func TestRespondOK_OmitsLoadWhenProviderReturnsNil(t *testing.T) { + SetLoadProvider(func() *LoadInfo { return nil }) + defer SetLoadProvider(nil) + + router := gin.New() + router.GET("/t", func(c *gin.Context) { respondOK(c, gin.H{"k": "v"}) }) + req := httptest.NewRequest("GET", "/t", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var resp AgenticResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Nil(t, resp.Meta) +} + +// TestMetaLoadSurvivesCLIMetaMerge proves the envelope marshals meta.load as +// plain nested JSON, so the `af agent` CLI meta merge (which decodes the body +// into map[string]interface{} and copies payload["meta"] key by key) preserves +// load without any typed knowledge of it. +func TestMetaLoadSurvivesCLIMetaMerge(t *testing.T) { + resp := AgenticResponse{ + OK: true, + Data: map[string]interface{}{"x": 1}, + Meta: &MetaInfo{Load: &LoadInfo{ + RunningAgents: 1, + TotalAgents: 2, + ActiveExecutions: 3, + CPUCores: 4, + RecommendedMaxConcurrent: 2, + }}, + } + raw, err := json.Marshal(resp) + require.NoError(t, err) + + // Mimic proxyToServer's generic decode + meta merge in agent_commands.go. + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &decoded)) + + existing, ok := decoded["meta"].(map[string]interface{}) + require.True(t, ok, "meta must decode as a generic object") + + merged := map[string]interface{}{} + for k, v := range existing { + merged[k] = v + } + merged["server"] = "http://localhost:8080" + + load, ok := merged["load"].(map[string]interface{}) + require.True(t, ok, "meta.load must survive the merge as a nested object") + assert.Equal(t, float64(1), load["running_agents"]) + assert.Equal(t, float64(2), load["total_agents"]) + assert.Equal(t, float64(3), load["active_executions"]) + assert.Equal(t, float64(4), load["cpu_cores"]) + assert.Equal(t, float64(2), load["recommended_max_concurrent"]) +} diff --git a/control-plane/internal/handlers/agentic/reasoners.go b/control-plane/internal/handlers/agentic/reasoners.go new file mode 100644 index 000000000..073468a43 --- /dev/null +++ b/control-plane/internal/handlers/agentic/reasoners.go @@ -0,0 +1,173 @@ +package agentic + +import ( + "math" + "net/http" + "strings" + + "github.com/Agent-Field/agentfield/control-plane/internal/storage" + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + "github.com/gin-gonic/gin" +) + +// Field boosts for reasoner search (BM25F-lite). The reasoner id is the +// strongest signal, then its tags, then the owning agent id, then any +// human-authored description carried in agent metadata. +const ( + reasonerFieldBoostID = 3.0 + reasonerFieldBoostTags = 2.0 + reasonerFieldBoostAgentID = 1.5 + reasonerFieldBoostDescription = 1.0 +) + +const ( + reasonerSearchDefaultLimit = 10 + reasonerSearchMaxLimit = 50 +) + +// ReasonerSearchResult is one ranked reasoner. It carries everything the +// driving agent needs to invoke the reasoner immediately, without a second +// lookup: the invocation target and the owning agent's current health. +type ReasonerSearchResult struct { + ReasonerID string `json:"reasoner_id"` + AgentID string `json:"agent_id"` + InvocationTarget string `json:"invocation_target"` + Tags []string `json:"tags,omitempty"` + Score float64 `json:"score"` + AgentHealth string `json:"agent_health"` +} + +// ReasonersHandler ranks installed reasoners against a free-text query using a +// self-contained BM25F-lite ranker. It reads the same registration data the +// discovery surface exposes (store.ListAgents) so search results never drift +// from discovery. The corpus is rebuilt per request — acceptable for the small +// reasoner population and far simpler than a background index. +// +// GET /api/v1/agentic/reasoners?q=&agent=&limit=<1..50> +func ReasonersHandler(store storage.StorageProvider) gin.HandlerFunc { + return func(c *gin.Context) { + query := strings.TrimSpace(c.Query("q")) + if query == "" { + respondError(c, http.StatusBadRequest, "missing_query", + "q is required — pass free text describing the reasoner you need, e.g. ?q=review+pull+request") + return + } + + limit := getIntQuery(c, "limit", reasonerSearchDefaultLimit) + if limit <= 0 { + limit = reasonerSearchDefaultLimit + } + if limit > reasonerSearchMaxLimit { + limit = reasonerSearchMaxLimit + } + + agentFilter := strings.TrimSpace(c.Query("agent")) + + agents, err := store.ListAgents(c.Request.Context(), types.AgentFilters{}) + if err != nil { + respondError(c, http.StatusInternalServerError, "query_failed", err.Error()) + return + } + + docs, meta := buildReasonerCorpus(agents, agentFilter) + idx := newBM25Index(docs) + hits := idx.Search(query) + + // Capacity is the constant maximum, not the request-supplied limit, so + // the allocation size is provably attacker-independent (CodeQL + // go/uncontrolled-allocation-size); the loop below still stops at limit. + results := make([]ReasonerSearchResult, 0, reasonerSearchMaxLimit) + for _, hit := range hits { + if len(results) >= limit { + break + } + record, ok := meta[hit.id] + if !ok { + continue + } + record.Score = roundScore(hit.score) + results = append(results, record) + } + + respondOK(c, gin.H{ + "query": query, + "results": results, + "total_indexed": len(docs), + }) + } +} + +// buildReasonerCorpus turns the registered agents into one searchable document +// per reasoner, keyed by its invocation target (unique across agents). It also +// returns a lookup of the result payload for each key so a ranked hit maps back +// to its source record without re-scanning the agent list. +func buildReasonerCorpus(agents []*types.AgentNode, agentFilter string) ([]searchDoc, map[string]ReasonerSearchResult) { + docs := make([]searchDoc, 0) + meta := make(map[string]ReasonerSearchResult) + + for _, agent := range agents { + if agent == nil { + continue + } + if agentFilter != "" && agent.ID != agentFilter { + continue + } + for _, reasoner := range agent.Reasoners { + // Matches the discovery surface's invocation_target format + // (agent:reasoner, colon-delimited). Unique per reasoner. + target := agent.ID + ":" + reasoner.ID + + fields := []searchField{ + {boost: reasonerFieldBoostID, text: reasoner.ID}, + {boost: reasonerFieldBoostTags, text: strings.Join(reasoner.Tags, " ")}, + {boost: reasonerFieldBoostAgentID, text: agent.ID}, + } + if desc := reasonerDescription(agent, reasoner.ID); desc != "" { + fields = append(fields, searchField{boost: reasonerFieldBoostDescription, text: desc}) + } + + docs = append(docs, searchDoc{id: target, fields: fields}) + meta[target] = ReasonerSearchResult{ + ReasonerID: reasoner.ID, + AgentID: agent.ID, + InvocationTarget: target, + Tags: reasoner.Tags, + AgentHealth: string(agent.HealthStatus), + } + } + } + return docs, meta +} + +// reasonerDescription pulls an optional human description for a reasoner from +// agent metadata (metadata.custom.descriptions[]) — the same +// side-channel the discovery handler reads. ReasonerDefinition itself carries no +// description field, so absence is normal and returns an empty string. +func reasonerDescription(agent *types.AgentNode, reasonerID string) string { + if agent.Metadata.Custom == nil { + return "" + } + raw, ok := agent.Metadata.Custom["descriptions"] + if !ok { + return "" + } + m, ok := raw.(map[string]interface{}) + if !ok { + return "" + } + desc, ok := m[reasonerID] + if !ok { + return "" + } + text, ok := desc.(string) + if !ok { + return "" + } + return strings.TrimSpace(text) +} + +// roundScore trims BM25 scores to six decimals so the JSON is tidy and stable +// across platforms without affecting ranking. +func roundScore(v float64) float64 { + return math.Round(v*1e6) / 1e6 +} diff --git a/control-plane/internal/handlers/agentic/reasoners_test.go b/control-plane/internal/handlers/agentic/reasoners_test.go new file mode 100644 index 000000000..39e924d7e --- /dev/null +++ b/control-plane/internal/handlers/agentic/reasoners_test.go @@ -0,0 +1,175 @@ +package agentic + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func reasonerTestAgents() []*types.AgentNode { + return []*types.AgentNode{ + { + ID: "pr-af", + HealthStatus: types.HealthStatusActive, + Reasoners: []types.ReasonerDefinition{ + {ID: "review_pull_request", Tags: []string{"pr", "code-review"}}, + {ID: "summarize_diff", Tags: []string{"pr"}}, + }, + }, + { + ID: "weather", + HealthStatus: types.HealthStatusInactive, + Reasoners: []types.ReasonerDefinition{ + {ID: "get_forecast", Tags: []string{"weather"}}, + }, + }, + } +} + +func serveReasoners(t *testing.T, store *mockStatusStorage, rawQuery string) (*httptest.ResponseRecorder, AgenticResponse) { + t.Helper() + router := gin.New() + router.GET("/api/v1/agentic/reasoners", ReasonersHandler(store)) + + req := httptest.NewRequest("GET", "/api/v1/agentic/reasoners?"+rawQuery, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var resp AgenticResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return rec, resp +} + +func TestReasonersHandler_Results(t *testing.T) { + store := new(mockStatusStorage) + store.On("ListAgents", mock.Anything, mock.Anything).Return(reasonerTestAgents(), nil) + + rec, resp := serveReasoners(t, store, "q=review+pull+request") + require.Equal(t, http.StatusOK, rec.Code) + assert.True(t, resp.OK) + + data := resp.Data.(map[string]interface{}) + assert.Equal(t, "review pull request", data["query"]) + assert.Equal(t, float64(3), data["total_indexed"]) + + results := data["results"].([]interface{}) + require.NotEmpty(t, results) + first := results[0].(map[string]interface{}) + assert.Equal(t, "review_pull_request", first["reasoner_id"]) + assert.Equal(t, "pr-af", first["agent_id"]) + assert.Equal(t, "pr-af:review_pull_request", first["invocation_target"]) + assert.Equal(t, "active", first["agent_health"]) + assert.Greater(t, first["score"].(float64), 0.0) +} + +func TestReasonersHandler_EmptyQuery(t *testing.T) { + store := new(mockStatusStorage) + // ListAgents must not be called when q is empty. + + rec, resp := serveReasoners(t, store, "") + require.Equal(t, http.StatusBadRequest, rec.Code) + assert.False(t, resp.OK) + require.NotNil(t, resp.Error) + assert.Equal(t, "missing_query", resp.Error.Code) + store.AssertNotCalled(t, "ListAgents", mock.Anything, mock.Anything) +} + +func TestReasonersHandler_AgentFilter(t *testing.T) { + store := new(mockStatusStorage) + store.On("ListAgents", mock.Anything, mock.Anything).Return(reasonerTestAgents(), nil) + + rec, resp := serveReasoners(t, store, "q=forecast&agent=weather") + require.Equal(t, http.StatusOK, rec.Code) + + data := resp.Data.(map[string]interface{}) + // Only the weather agent's single reasoner is indexed. + assert.Equal(t, float64(1), data["total_indexed"]) + results := data["results"].([]interface{}) + require.Len(t, results, 1) + first := results[0].(map[string]interface{}) + assert.Equal(t, "get_forecast", first["reasoner_id"]) + assert.Equal(t, "weather", first["agent_id"]) +} + +func TestReasonersHandler_LimitRespected(t *testing.T) { + store := new(mockStatusStorage) + store.On("ListAgents", mock.Anything, mock.Anything).Return(reasonerTestAgents(), nil) + + // Both pr-af reasoners are tagged "pr"; limit=1 must return exactly one. + rec, resp := serveReasoners(t, store, "q=pr&limit=1") + require.Equal(t, http.StatusOK, rec.Code) + + data := resp.Data.(map[string]interface{}) + results := data["results"].([]interface{}) + require.Len(t, results, 1) +} + +func TestReasonersHandler_LimitClampedToMax(t *testing.T) { + // 60 reasoners all matching "task"; a limit above the max (50) is clamped. + reasoners := make([]types.ReasonerDefinition, 0, 60) + for i := 0; i < 60; i++ { + reasoners = append(reasoners, types.ReasonerDefinition{ + ID: fmt.Sprintf("task_%d", i), + Tags: []string{"task"}, + }) + } + agents := []*types.AgentNode{{ID: "bulk", HealthStatus: types.HealthStatusActive, Reasoners: reasoners}} + + store := new(mockStatusStorage) + store.On("ListAgents", mock.Anything, mock.Anything).Return(agents, nil) + + rec, resp := serveReasoners(t, store, "q=task&limit=100") + require.Equal(t, http.StatusOK, rec.Code) + + data := resp.Data.(map[string]interface{}) + assert.Equal(t, float64(60), data["total_indexed"]) + results := data["results"].([]interface{}) + assert.Len(t, results, reasonerSearchMaxLimit) +} + +func TestReasonersHandler_StorageError(t *testing.T) { + store := new(mockStatusStorage) + store.On("ListAgents", mock.Anything, mock.Anything).Return(nil, errors.New("db down")) + + rec, resp := serveReasoners(t, store, "q=anything") + require.Equal(t, http.StatusInternalServerError, rec.Code) + assert.False(t, resp.OK) + require.NotNil(t, resp.Error) + assert.Equal(t, "query_failed", resp.Error.Code) +} + +func TestReasonersHandler_DescriptionIndexed(t *testing.T) { + // A term that appears only in the metadata description must still match. + agents := []*types.AgentNode{ + { + ID: "docs", + HealthStatus: types.HealthStatusActive, + Reasoners: []types.ReasonerDefinition{{ID: "handler_a", Tags: []string{"a"}}}, + Metadata: types.AgentMetadata{ + Custom: map[string]interface{}{ + "descriptions": map[string]interface{}{ + "handler_a": "generates quarterly compliance paperwork", + }, + }, + }, + }, + } + store := new(mockStatusStorage) + store.On("ListAgents", mock.Anything, mock.Anything).Return(agents, nil) + + rec, resp := serveReasoners(t, store, "q=compliance") + require.Equal(t, http.StatusOK, rec.Code) + data := resp.Data.(map[string]interface{}) + results := data["results"].([]interface{}) + require.Len(t, results, 1) + assert.Equal(t, "handler_a", results[0].(map[string]interface{})["reasoner_id"]) +} diff --git a/control-plane/internal/server/routes_agentic.go b/control-plane/internal/server/routes_agentic.go index a1a149cd6..00963c254 100644 --- a/control-plane/internal/server/routes_agentic.go +++ b/control-plane/internal/server/routes_agentic.go @@ -1,27 +1,39 @@ package server import ( + "time" + "github.com/Agent-Field/agentfield/control-plane/internal/handlers/agentic" "github.com/Agent-Field/agentfield/control-plane/internal/logger" "github.com/gin-gonic/gin" ) +// agenticLoadCacheTTL is how long the ambient load snapshot is cached so a +// burst of agentic calls doesn't hammer storage. +const agenticLoadCacheTTL = 2 * time.Second + // registerAgenticRoutes installs the /api/v1/agentic/* surface — agent-optimized // endpoints for discovery, query, run inspection, per-agent summaries, batch // invocation, and aggregate status. These inherit the authenticated agentAPI // group's middleware stack. func (s *AgentFieldServer) registerAgenticRoutes(agentAPI *gin.RouterGroup) { + // Ambient machine-load metadata stamped onto every agentic response + // (meta.load). Cached for a short TTL so bursts of calls don't hammer + // storage; degrades to omitting meta.load on any error. + agentic.SetLoadProvider(agentic.NewStorageLoadProvider(s.storage, agenticLoadCacheTTL)) + agenticGroup := agentAPI.Group("/agentic") { agenticGroup.GET("/discover", agentic.DiscoverHandler(s.apiCatalog)) + agenticGroup.GET("/reasoners", agentic.ReasonersHandler(s.storage)) agenticGroup.POST("/query", agentic.QueryHandler(s.storage)) agenticGroup.GET("/run/:run_id", agentic.RunOverviewHandler(s.storage)) agenticGroup.GET("/agent/:agent_id/summary", agentic.AgentSummaryHandler(s.storage)) agenticGroup.POST("/batch", agentic.BatchHandler(s.Router)) agenticGroup.GET("/status", agentic.StatusHandler(s.storage)) } - logger.Logger.Info().Msg("🤖 Agentic API routes registered (discover, query, run, agent, batch, status)") + logger.Logger.Info().Msg("🤖 Agentic API routes registered (discover, reasoners, query, run, agent, batch, status)") } // registerKBRoutes installs the public, unauthenticated Knowledge Base tree diff --git a/control-plane/internal/skillkit/catalog.go b/control-plane/internal/skillkit/catalog.go index 2495e7be7..2d5dedcb9 100644 --- a/control-plane/internal/skillkit/catalog.go +++ b/control-plane/internal/skillkit/catalog.go @@ -41,8 +41,8 @@ read this skill first`, }, { Name: "agentfield-use", - Version: "0.2.0", - Description: "Discover and call agents already running on a local AgentField control plane. Health check, capability discovery, concurrent sync/async execution, in-flight visibility (af ps / executions/active), wedged-run triage (cancel-tree), sessions, and the af CLI ops (run/stop/logs/secrets) that keep installed agents answering.", + Version: "0.3.0", + Description: "Discover and call agents already running on a local AgentField control plane. Health check, capability discovery, ranked reasoner search (af agent search), concurrent sync/async execution, load-aware pacing (meta.load), in-flight visibility (af ps / executions/active), wedged-run triage (cancel-tree), sessions, and the af CLI ops (run/stop/logs/secrets) that keep installed agents answering.", EmbedRoot: "skill_data/agentfield-use", EntryFile: "SKILL.md", Trigger: `When the user asks you to use, call, query, or delegate work to an diff --git a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md index c1dbece74..337839e0c 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -64,6 +64,21 @@ Three gotchas: is the source of truth for what's installed: `af list`, start with `af run ` (it detaches; the agent keeps running after the CLI exits). +### Too many reasoners to scan? Search, don't dump + +When a box has more than ~20 reasoners installed, ranked search beats reading +the whole capabilities payload into context: + +```bash +af agent search "review a pull request" # BM25-ranked; --agent , --limit N (max 50) +# or: curl -s "http://localhost:8080/api/v1/agentic/reasoners?q=review+pull+request" +``` + +Each hit carries `reasoner_id`, `agent_id`, `invocation_target`, `tags`, +`score`, and `agent_health` — everything you need to dispatch with no second +lookup. Build the execute target straight from `invocation_target` (colon → dot) +and only dispatch to hits whose `agent_health` is `"active"`. + ## 3. Call a reasoner Input kwargs are ALWAYS nested under `"input"` — never raw at the top level. @@ -102,6 +117,13 @@ plane is managing many agents at once. What to know: - Save every `execution_id` you dispatch. Group related calls with an `X-Session-ID` header so they're queryable as one batch later. +**Check the load before piling on.** Every `af agent` / agentic response carries +`meta.load`: `{running_agents, total_agents, active_executions, cpu_cores, +recommended_max_concurrent}` (the recommendation is CPU-based). Read it before +launching more heavy runs — if `active_executions >= recommended_max_concurrent`, +finish or await in-flight work first rather than starting more, and tell the +user you're throttling to avoid overloading the machine. + ## 4. Get the result **What's in flight right now** — no IDs needed (also answers "how many agents diff --git a/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index c1dbece74..337839e0c 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/agentfield-use/SKILL.md @@ -64,6 +64,21 @@ Three gotchas: is the source of truth for what's installed: `af list`, start with `af run ` (it detaches; the agent keeps running after the CLI exits). +### Too many reasoners to scan? Search, don't dump + +When a box has more than ~20 reasoners installed, ranked search beats reading +the whole capabilities payload into context: + +```bash +af agent search "review a pull request" # BM25-ranked; --agent , --limit N (max 50) +# or: curl -s "http://localhost:8080/api/v1/agentic/reasoners?q=review+pull+request" +``` + +Each hit carries `reasoner_id`, `agent_id`, `invocation_target`, `tags`, +`score`, and `agent_health` — everything you need to dispatch with no second +lookup. Build the execute target straight from `invocation_target` (colon → dot) +and only dispatch to hits whose `agent_health` is `"active"`. + ## 3. Call a reasoner Input kwargs are ALWAYS nested under `"input"` — never raw at the top level. @@ -102,6 +117,13 @@ plane is managing many agents at once. What to know: - Save every `execution_id` you dispatch. Group related calls with an `X-Session-ID` header so they're queryable as one batch later. +**Check the load before piling on.** Every `af agent` / agentic response carries +`meta.load`: `{running_agents, total_agents, active_executions, cpu_cores, +recommended_max_concurrent}` (the recommendation is CPU-based). Read it before +launching more heavy runs — if `active_executions >= recommended_max_concurrent`, +finish or await in-flight work first rather than starting more, and tell the +user you're throttling to avoid overloading the machine. + ## 4. Get the result **What's in flight right now** — no IDs needed (also answers "how many agents