Skip to main content

steeldb/agent/
tools.rs

1//! The engine tools the agent may call — pure read primitives over a loaded `Corpus`. The agent
2//! plans a question into IKL probes; retrieval is the reasoning (roaring set-algebra), so these three
3//! tools (discover schema, discover a facet's vocabulary, run an IKL query) are enough to answer.
4
5use crate::agent::types::ToolSpec;
6use crate::db::Corpus;
7use serde_json::{json, Value};
8
9/// A facet-name string property. When the corpus's real facet names are known, they're injected as an
10/// `enum` so a constrained decoder (Needle) can ONLY emit a facet that actually exists — validity by
11/// construction, not just post-hoc repair.
12fn facet_prop(desc: &str, facets: &[String]) -> serde_json::Value {
13    let mut o = json!({ "type": "string", "description": desc });
14    if !facets.is_empty() {
15        o["enum"] = json!(facets);
16    }
17    o
18}
19
20/// Build the engine tool specs. `facets` = the corpus's real facet names (empty = none known yet); when
21/// present they bound the `run_workflow` facet slots to real values.
22pub fn engine_tools(facets: &[String]) -> Vec<ToolSpec> {
23    vec![
24        ToolSpec {
25            name: "list_facets".into(),
26            description: "List the corpus facets (token prefixes) and how many distinct tokens each has. Call this first to learn the schema.".into(),
27            schema: json!({ "type": "object", "properties": {}, "additionalProperties": false }),
28        },
29        ToolSpec {
30            name: "run_workflow".into(),
31            description: "Answer in ONE call. Your MAIN job is `constraints`: the facet/value tokens or predicates that narrow the rows to what the question is about, AND-ed together (e.g. [\"powertrain/electric\", \"(num range_km gt 500)\"]). Leave `program` OUT to auto-profile the narrowed rows (the engine picks the informative breakdowns/crosstab/clusters for you). Only set `program` when the question clearly needs a specific one: `crosstab` (facet_a × facet_b, 'for each A which B'), `breakdown` (partition by `facet`), `rank` (salient values of `facet`), `cooccurs` (neighbours of `token`), `s_path` (how tokens `a`,`b` connect), `s_clusters` (concept groupings). Use exact facet NAMES from list_facets.".into(),
32            schema: json!({
33                "type": "object",
34                "properties": {
35                    "constraints": { "type": "array", "items": { "type": "string" }, "description": "PRIMARY: filters to AND, as facet/value tokens or predicates, e.g. [\"powertrain/electric\", \"(num range_km gt 500)\"]" },
36                    "program": { "type": "string", "enum": ["crosstab", "breakdown", "rank", "cooccurs", "s_path", "s_clusters", "narrow"], "description": "OPTIONAL specific analysis; omit to auto-profile" },
37                    "facet_a": facet_prop("crosstab only: grouping facet NAME (e.g. country)", facets),
38                    "facet_b": facet_prop("crosstab only: facet NAME counted within each group (e.g. powertrain)", facets),
39                    "facet": facet_prop("breakdown/rank only: the facet NAME (e.g. country)", facets),
40                    "token": { "type": "string", "description": "cooccurs only: the focus facet/value token (e.g. aws-service/lambda)" },
41                    "a": { "type": "string", "description": "s_path only: first facet/value token" },
42                    "b": { "type": "string", "description": "s_path only: second facet/value token" }
43                },
44                "additionalProperties": false
45            }),
46        },
47        ToolSpec {
48            name: "facet_tokens".into(),
49            description: "List the most common tokens under a facet (with support counts), so you know the exact tokens you can query. Tokens look like `facet/value`.".into(),
50            schema: json!({
51                "type": "object",
52                "properties": {
53                    "facet": { "type": "string", "description": "the facet prefix, e.g. `geo` or `country`" },
54                    "limit": { "type": "integer", "description": "max tokens to return (default 40)" }
55                },
56                "required": ["facet"],
57                "additionalProperties": false
58            }),
59        },
60        ToolSpec {
61            name: "ikl_query".into(),
62            description: "Run an IKL s-expression over the corpus and return matching situations. IKL is set-algebra over tokens: `(and A B)`, `(or A B)`, `(not A)`, a bare token, a glob like `geo/*`, or a numeric range `(num <field> <op> <value>)` with op ge|gt|le|lt|eq|ne over a numeric_field from list_facets. Example: `(and powertrain/electric (num range_km ge 500))`.".into(),
63            schema: json!({
64                "type": "object",
65                "properties": {
66                    "ikl": { "type": "string", "description": "the IKL s-expression" },
67                    "limit": { "type": "integer", "description": "max situations to return (default 20)" }
68                },
69                "required": ["ikl"],
70                "additionalProperties": false
71            }),
72        },
73        ToolSpec {
74            name: "search".into(),
75            description: "Free-text search across the corpus documents/rows: give any keywords or a natural-language phrase, get back matching situations (with their text). Use this when the user asks WHAT the documents say about something, WHICH files talk about X, or wants to READ evidence — as opposed to counting/comparing (use breakdown/crosstab for those).".into(),
76            schema: json!({
77                "type": "object",
78                "properties": {
79                    "query": { "type": "string", "description": "keywords or natural-language phrase" },
80                    "limit": { "type": "integer", "description": "max hits (default 8)" }
81                },
82                "required": ["query"],
83                "additionalProperties": false
84            }),
85        },
86        ToolSpec {
87            name: "entity_link".into(),
88            description: "Resolve a question's mentions (acronyms, entities, phrases) to the corpus's PRECISE existing tokens and retrieve on them. Use for entity-specific questions: the exact tokens docs are indexed under are often more specific than what a plain query guesses.".into(),
89            schema: json!({
90                "type": "object",
91                "properties": { "question": { "type": "string", "description": "the question or sub-question to link" } },
92                "required": ["question"],
93                "additionalProperties": false
94            }),
95        },
96    ]
97}
98
99fn str_arg<'a>(input: &'a Value, key: &str) -> &'a str {
100    input.get(key).and_then(|v| v.as_str()).unwrap_or("")
101}
102fn usize_arg(input: &Value, key: &str, default: usize) -> usize {
103    input.get(key).and_then(|v| v.as_u64()).map(|n| n as usize).unwrap_or(default)
104}
105fn str_list(input: &Value, key: &str) -> Vec<String> {
106    input
107        .get(key)
108        .and_then(|v| v.as_array())
109        .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
110        .unwrap_or_default()
111}
112
113/// Execute a tool call against the corpus. Returns (json_string, is_error).
114pub fn exec_tool(corpus: &Corpus, name: &str, input: &Value) -> (String, bool) {
115    match name {
116        "list_facets" => {
117            let s = corpus.stats();
118            // Ambient facets that carry structural noise (open-ended text-derived relations, per-file
119            // src tags). Kept on the corpus but hidden from list_facets so the model doesn't fixate on
120            // them; still queryable if the model explicitly asks facet_tokens on them.
121            let ambient = ["rel", "src"];
122            let facets: Vec<Value> = s
123                .facets
124                .iter()
125                .filter(|(f, _)| !ambient.contains(&f.as_str()))
126                .map(|(f, n)| json!({ "facet": f, "distinct_tokens": n }))
127                .collect();
128            (json!({ "situations": s.situations, "facets": facets, "numeric_fields": s.numeric_fields }).to_string(), false)
129        }
130        "facet_tokens" => {
131            let facet = input.get("facet").and_then(|v| v.as_str()).unwrap_or("");
132            if facet.is_empty() {
133                return (json!({ "error": "missing `facet`" }).to_string(), true);
134            }
135            // Cap responses so a facet with thousands of long-tail values (e.g. CJK `rel/*`) can't
136            // overwhelm the model's context. min_support filters singleton noise unless overridden.
137            let limit = (input.get("limit").and_then(|v| v.as_u64()).unwrap_or(40) as usize).min(60);
138            let min_support = input.get("min_support").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
139            let all = corpus.facet_tokens(facet, 1000);
140            let total_distinct = all.len();
141            let filtered: Vec<(String, usize)> = all.into_iter().filter(|(_, n)| *n >= min_support).collect();
142            let shown = filtered.iter().take(limit).cloned().collect::<Vec<_>>();
143            let truncated = filtered.len() > shown.len();
144            let toks: Vec<Value> = shown.iter().map(|(t, n)| json!({ "token": t, "support": n })).collect();
145            let mut out = json!({ "facet": facet, "tokens": toks, "distinct_tokens": total_distinct });
146            if truncated {
147                out["truncated"] = Value::Bool(true);
148                out["note"] = Value::String(format!("showing top {} by support; {} more not shown — raise min_support or query specific tokens with ikl_query", shown.len(), filtered.len() - shown.len()));
149            }
150            (out.to_string(), false)
151        }
152        "ikl_query" => {
153            let ikl = input.get("ikl").and_then(|v| v.as_str()).unwrap_or("");
154            if ikl.is_empty() {
155                return (json!({ "error": "missing `ikl`" }).to_string(), true);
156            }
157            let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
158            let out = corpus.query(ikl, limit);
159            let hits: Vec<Value> = out
160                .hits
161                .iter()
162                .map(|h| {
163                    // truncate long cells so the context stays lean
164                    let cells: Vec<String> = h
165                        .cells
166                        .iter()
167                        .map(|c| if c.len() > 300 { format!("{}…", &c[..300]) } else { c.clone() })
168                        .collect();
169                    json!({ "sid": h.sid, "cells": cells })
170                })
171                .collect();
172            (
173                json!({ "ikl": ikl, "count": out.count, "micros": out.micros, "returned": hits.len(), "hits": hits }).to_string(),
174                false,
175            )
176        }
177        "search" => {
178            let q = str_arg(input, "query");
179            if q.is_empty() {
180                return (json!({ "error": "missing `query`" }).to_string(), true);
181            }
182            let limit = usize_arg(input, "limit", 8);
183            let linked = corpus.entity_link(q);
184            if linked.is_empty() {
185                return (json!({ "query": q, "count": 0, "note": "no matching tokens found in the corpus vocabulary" }).to_string(), false);
186            }
187            // relevance-ranked: most query-token coverage first (not document order)
188            let ranked = corpus.search_ranked(&linked, limit);
189            let hits: Vec<Value> = ranked.iter().map(|(sid, cov, cells)| {
190                let cells: Vec<String> = cells.iter().map(|c| if c.chars().count() > 400 { format!("{}…", c.chars().take(400).collect::<String>()) } else { c.clone() }).collect();
191                json!({ "sid": sid, "match_score": cov, "cells": cells })
192            }).collect();
193            (json!({ "query": q, "matched_tokens": linked, "returned": hits.len(), "hits": hits }).to_string(), false)
194        }
195        "entity_link" => {
196            let q = str_arg(input, "question");
197            if q.is_empty() {
198                return (json!({ "error": "missing `question`" }).to_string(), true);
199            }
200            let linked = corpus.entity_link(q);
201            if linked.is_empty() {
202                return (json!({ "linked": [], "note": "no precise tokens linked from the question" }).to_string(), false);
203            }
204            let ikl = if linked.len() > 1 { format!("(or {})", linked.join(" ")) } else { linked[0].clone() };
205            let out = corpus.query(&ikl, 12);
206            let hits: Vec<Value> = out
207                .hits
208                .iter()
209                .map(|h| {
210                    let cells: Vec<String> = h.cells.iter().map(|c| if c.len() > 300 { format!("{}…", &c[..300]) } else { c.clone() }).collect();
211                    json!({ "sid": h.sid, "cells": cells })
212                })
213                .collect();
214            (json!({ "linked": linked, "ikl": ikl, "count": out.count, "returned": hits.len(), "hits": hits }).to_string(), false)
215        }
216        "breakdown" => {
217            let anchor = str_arg(input, "anchor");
218            let anchor = if anchor.is_empty() || anchor == "all" { "*" } else { anchor }; // default = whole corpus
219            let facet = str_arg(input, "facet");
220            if facet.is_empty() {
221                return (json!({ "error": "need `facet`" }).to_string(), true);
222            }
223            (corpus.breakdown(anchor, facet, usize_arg(input, "k", 15)).to_string(), false)
224        }
225        "crosstab" => {
226            let anchor = str_arg(input, "anchor");
227            let anchor = if anchor.is_empty() || anchor == "all" { "*" } else { anchor }; // default = whole corpus
228            let (fa, fb) = (str_arg(input, "facet_a"), str_arg(input, "facet_b"));
229            if fa.is_empty() || fb.is_empty() {
230                return (json!({ "error": "need `facet_a`, `facet_b`" }).to_string(), true);
231            }
232            (corpus.crosstab(anchor, fa, fb, usize_arg(input, "k", 6)).to_string(), false)
233        }
234        "rank" => {
235            let facet = str_arg(input, "facet");
236            if facet.is_empty() {
237                return (json!({ "error": "missing `facet`" }).to_string(), true);
238            }
239            (corpus.rank(facet, usize_arg(input, "k", 10)).to_string(), false)
240        }
241        "cooccurs" => {
242            let token = str_arg(input, "token");
243            if token.is_empty() {
244                return (json!({ "error": "missing `token`" }).to_string(), true);
245            }
246            (corpus.cooccurs(token, usize_arg(input, "k", 12)).to_string(), false)
247        }
248        "s_path" => {
249            let (a, b) = (str_arg(input, "a"), str_arg(input, "b"));
250            if a.is_empty() || b.is_empty() {
251                return (json!({ "error": "need `a` and `b`" }).to_string(), true);
252            }
253            (corpus.s_path(a, b, usize_arg(input, "s", 1)).to_string(), false)
254        }
255        "s_clusters" => (corpus.s_clusters(usize_arg(input, "s", 2), usize_arg(input, "k", 8)).to_string(), false),
256        "narrow" => (corpus.narrow(&str_list(input, "scope"), &str_list(input, "filters")).to_string(), false),
257        other => (json!({ "error": format!("unknown tool {other}") }).to_string(), true),
258    }
259}
260
261pub const SYSTEM_PROMPT: &str = "\
262You are SteelDB, a retrieval-as-reasoning agent over a corpus of documents and structured rows. Answers \
263come ONLY from the corpus, reached through the tools. Pick the right tool for the question:
264
265DOCUMENT / EVIDENCE questions (what do the docs say about X, which files mention Y, quote the passage \
266about Z, summarise the sections on W): call `search` with plain-language keywords — it returns matching \
267situations with their text. Quote from the hit `cells` in your answer.
268
269ANALYTIC questions on structured data (compare, enumerate, rank, relate, break down, distribute): first \
270`list_facets` to see the schema, `facet_tokens` for exact token vocabulary, then use one of the bitmap \
271programs — `breakdown` (partition), `crosstab` (relate two facets), `rank` (most salient values), \
272`cooccurs` / `s_path` / `s_clusters` (structural graph), or `narrow` (find which constraint the corpus \
273cannot satisfy). Do NOT dump facet_tokens on a facet with hundreds of long-tail values — use `search` \
274or `ikl_query` instead.
275
276IKL for precise set queries: `ikl_query` runs `(and A B)`, `(or A B)`, `(not A)`, globs like `geo/*`, or \
277NUMERIC ranges `(num <field> <op> <value>)` (op ge|gt|le|lt|eq|ne) over the numeric_fields shown by \
278list_facets — e.g. `(and powertrain/electric (num range_km ge 500))` for 'electric vehicles with range \
279over 500'. `entity_link` has already been run for you up front — reuse those linked tokens as scope. \
280Answer concisely, grounded in what the tools returned, and say plainly if the corpus does not support \
281an answer. Never invent tokens; only query tokens you have seen.";