1use 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
35fn 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
45pub 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
93pub 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
110fn 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
124pub 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 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 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 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 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 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}