Skip to main content

steeldb/agent/
needle.rs

1//! Cactus **Needle** provider — drive the agent with the 26M, tool-use-native Needle model (encoder-
2//! decoder, distilled for function-calling) served by the needle-code torch port. Needle is a single-turn
3//! tool selector: given a `query` string + a tool list, it emits a JSON array of tool calls. We adapt
4//! that to the multi-turn [`LlmProvider`] contract — the agent loop already re-invokes us with tool
5//! results appended, so each call flattens the running conversation into `query`, POSTs to `/generate`,
6//! and parses the returned tool-call JSON into a [`Turn`].
7//!
8//! Wire format (needle-code `POST /generate`):
9//! ```json
10//! { "query": "…task + context…", "tools": "[{\"name\":…,\"description\":…,\"parameters\":…}]",
11//!   "max_gen_len": 256, "seed": 0, "constrained": true }
12//! ```
13//! → `{ "result": "[{\"name\":\"ikl_query\",\"arguments\":{…}}]" }`. Note tools use **Needle's own
14//! schema** (`{name,description,parameters}`), NOT the OpenAI `{type,function}` envelope, and `tools` is
15//! a JSON **string**. Unlike Qwen, Needle emits tool calls as structured JSON directly, so this provider
16//! parses them itself and is NOT wrapped in the Qwen harness.
17
18use crate::agent::provider::LlmProvider;
19use crate::agent::types::{Block, Msg, Role, Stop, ToolSpec, Turn};
20use async_trait::async_trait;
21use serde_json::{json, Value};
22
23pub struct NeedleProvider {
24    client: reqwest::Client,
25    base_url: String,
26    max_gen_len: u32,
27}
28
29impl NeedleProvider {
30    pub fn new(base_url: String) -> NeedleProvider {
31        NeedleProvider { client: reqwest::Client::new(), base_url, max_gen_len: 256 }
32    }
33}
34
35/// Strip the entity-linked recall appendix the agent loop injects into the opening question — useful
36/// context for a chat model, but noise for Needle's compact tool router (which will echo salient phrases
37/// into args).
38fn core_question(t: &str) -> &str {
39    match t.find("\n\n[Retrieved evidence") {
40        Some(i) => t[..i].trim_end(),
41        None => t.trim(),
42    }
43}
44
45/// Flatten the neutral conversation into a single Needle `query` string: system framing first, then a
46/// role-labelled transcript (user asks, prior assistant tool calls, and tool results), ending on a cue
47/// for the next action.
48pub fn build_query(system: &str, msgs: &[Msg]) -> String {
49    let mut q = String::new();
50    if !system.trim().is_empty() {
51        q.push_str(system.trim());
52        q.push_str("\n\n");
53    }
54    for m in msgs {
55        match m.role {
56            Role::User => {
57                for b in &m.blocks {
58                    match b {
59                        Block::Text(t) => {
60                            q.push_str("User: ");
61                            q.push_str(core_question(t));
62                            q.push('\n');
63                        }
64                        Block::ToolResult { content, .. } => {
65                            q.push_str("Tool result: ");
66                            q.push_str(content);
67                            q.push('\n');
68                        }
69                        _ => {}
70                    }
71                }
72            }
73            Role::Assistant => {
74                for b in &m.blocks {
75                    match b {
76                        Block::Text(t) if !t.is_empty() => {
77                            q.push_str("Assistant: ");
78                            q.push_str(t);
79                            q.push('\n');
80                        }
81                        Block::ToolUse { name, input, .. } => {
82                            q.push_str(&format!("Assistant called {name}({input})\n"));
83                        }
84                        _ => {}
85                    }
86                }
87            }
88        }
89    }
90    q.trim_end().to_string()
91}
92
93/// Translate neutral tool specs into Needle's schema and serialise to the JSON **string** the server
94/// expects: `[{"name","description","parameters"}]`. The schema is MINIMISED to match what the finetune
95/// trained on — a terse description and enum-only properties (no prose). A finetuned 26M model keys on
96/// the exact schema shape, and verbose prose both overflows context and diverges from training.
97pub fn to_needle_tools(tools: &[ToolSpec]) -> String {
98    let arr: Vec<Value> = tools.iter().map(|t| json!({"name": t.name, "description": minimal_desc(&t.name), "parameters": minimal_params(&t.schema)})).collect();
99    Value::Array(arr).to_string()
100}
101
102fn minimal_desc(name: &str) -> String {
103    match name {
104        "run_workflow" => "Run one analysis over the rows.".to_string(),
105        "search" => "Search the documents for a phrase.".to_string(),
106        other => other.to_string(),
107    }
108}
109
110/// Strip per-property `description` prose (keep `type`/`enum`/`items`) so the schema matches the
111/// finetune's minimal form.
112fn minimal_params(schema: &Value) -> Value {
113    let mut s = schema.clone();
114    if let Some(props) = s.get_mut("properties").and_then(|p| p.as_object_mut()) {
115        for (_k, v) in props.iter_mut() {
116            if let Some(o) = v.as_object_mut() {
117                o.remove("description");
118            }
119        }
120    }
121    s
122}
123
124/// Parse Needle's `result` into a [`Turn`]. `result` is normally a JSON array of `{name, arguments}`
125/// tool calls; if it isn't valid tool-call JSON, treat it as a final text answer.
126pub fn parse_result(result: &str) -> Turn {
127    let trimmed = result.trim();
128    if let Ok(Value::Array(calls)) = serde_json::from_str::<Value>(trimmed) {
129        let mut tool_uses = Vec::new();
130        for (i, call) in calls.iter().enumerate() {
131            let Some(name) = call.get("name").and_then(|x| x.as_str()) else { continue };
132            // arguments may be an object or an escaped JSON string
133            let input = match call.get("arguments") {
134                Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(json!({})),
135                Some(v) => v.clone(),
136                None => json!({}),
137            };
138            tool_uses.push((format!("needle-{i}"), name.to_string(), input));
139        }
140        if !tool_uses.is_empty() {
141            return Turn { text: String::new(), tool_uses, stop: Stop::ToolUse };
142        }
143    }
144    // Not tool calls → a plain answer.
145    Turn { text: trimmed.to_string(), tool_uses: Vec::new(), stop: Stop::EndTurn }
146}
147
148#[async_trait]
149impl LlmProvider for NeedleProvider {
150    fn name(&self) -> &str {
151        "needle"
152    }
153
154    // Needle is a tool selector, not a chat model — never trust it to phrase numbers; the agent renders
155    // a deterministic template from the program result instead.
156    fn synthesizes(&self) -> bool {
157        false
158    }
159
160    async fn chat(&self, _system: &str, msgs: &[Msg], tools: &[ToolSpec]) -> Result<Turn, String> {
161        let url = format!("{}/generate", self.base_url.trim_end_matches('/'));
162        // A 26M router is reliable with a tiny, non-overlapping tool set. run_workflow already carries the
163        // facet names as enums, so schema-discovery tools (list_facets/facet_tokens/ikl_query) are just
164        // noise for Needle — keep only the DSL entry point and free-text search.
165        const NEEDLE_TOOLS: &[&str] = &["run_workflow", "search"];
166        let tools: Vec<ToolSpec> = tools.iter().filter(|t| NEEDLE_TOOLS.contains(&t.name.as_str())).cloned().collect();
167        let tools = tools.as_slice();
168        // Needle is a compact tool router; the verbose Qwen-oriented system prompt (with its examples and
169        // MECE guidance) confuses it. Feed a lean instruction plus the conversation instead.
170        // Lean framing + a couple of DSL exemplars: a 26M router benefits from seeing the exact shape of
171        // a good run_workflow call. facet_* are always facet NAMES (from list_facets), never a program.
172        const LEAN_SYSTEM: &str = "Call run_workflow. Your main job is `constraints`: the facet/value tokens that narrow the rows to what the question is about. Leave `program` out unless the question clearly needs a specific cross/rank/path. facet names come from the schema.\n\
173Examples:\n\
174Q: Tell me about electric vehicles with range over 500km.\n\
175{\"name\":\"run_workflow\",\"arguments\":{\"constraints\":[\"powertrain/electric\",\"(num range_km gt 500)\"]}}\n\
176Q: For each country, which powertrain is most common?\n\
177{\"name\":\"run_workflow\",\"arguments\":{\"program\":\"crosstab\",\"facet_a\":\"country\",\"facet_b\":\"powertrain\"}}";
178        let body = json!({
179            "query": build_query(LEAN_SYSTEM, msgs),
180            "tools": to_needle_tools(tools),
181            "max_gen_len": self.max_gen_len,
182            "seed": 0,
183            "constrained": true,
184        });
185        let resp = self.client.post(&url).json(&body).send().await.map_err(|e| format!("needle request: {e}"))?;
186        if !resp.status().is_success() {
187            let status = resp.status();
188            let text = resp.text().await.unwrap_or_default();
189            return Err(format!("needle {status}: {text}"));
190        }
191        let v: Value = resp.json().await.map_err(|e| format!("needle decode: {e}"))?;
192        let result = v.get("result").and_then(|r| r.as_str()).ok_or("needle: no result field")?;
193        Ok(parse_result(result))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn needle_tools_use_bare_schema_not_openai_envelope() {
203        let tools = vec![ToolSpec {
204            name: "crosstab".into(),
205            description: "cross two facets".into(),
206            schema: json!({"type": "object", "properties": {"a": {"type": "string"}}}),
207        }];
208        let s = to_needle_tools(&tools);
209        let v: Value = serde_json::from_str(&s).unwrap();
210        assert_eq!(v[0]["name"], "crosstab");
211        assert!(v[0].get("parameters").is_some());
212        assert!(v[0].get("function").is_none(), "must NOT use the OpenAI {{type,function}} envelope");
213    }
214
215    #[test]
216    fn parse_result_reads_tool_calls() {
217        let t = parse_result(r#"[{"name":"crosstab","arguments":{"a":"country","b":"powertrain"}}]"#);
218        assert_eq!(t.stop, Stop::ToolUse);
219        assert_eq!(t.tool_uses.len(), 1);
220        assert_eq!(t.tool_uses[0].1, "crosstab");
221        assert_eq!(t.tool_uses[0].2["a"], "country");
222    }
223
224    #[test]
225    fn parse_result_handles_escaped_arguments_string() {
226        let t = parse_result(r#"[{"name":"rank","arguments":"{\"facet\":\"powertrain\"}"}]"#);
227        assert_eq!(t.tool_uses[0].2["facet"], "powertrain");
228    }
229
230    #[test]
231    fn parse_result_falls_back_to_text() {
232        let t = parse_result("Japan is mostly electric.");
233        assert_eq!(t.stop, Stop::EndTurn);
234        assert!(t.tool_uses.is_empty());
235        assert_eq!(t.text, "Japan is mostly electric.");
236    }
237
238    #[test]
239    fn build_query_labels_turns_and_tool_results() {
240        let msgs = vec![
241            Msg::user_text("how many EVs per country?"),
242            Msg { role: Role::Assistant, blocks: vec![Block::ToolUse { id: "1".into(), name: "crosstab".into(), input: json!({"a": "country"}) }] },
243            Msg { role: Role::User, blocks: vec![Block::ToolResult { id: "1".into(), content: "japan=2".into(), is_error: false }] },
244        ];
245        let q = build_query("You are SteelDB.", &msgs);
246        assert!(q.starts_with("You are SteelDB."));
247        assert!(q.contains("User: how many EVs per country?"));
248        assert!(q.contains("Assistant called crosstab"));
249        assert!(q.contains("Tool result: japan=2"));
250    }
251}