use crate::agent::provider::LlmProvider;
use crate::agent::types::{Block, Msg, Role, Stop, ToolSpec, Turn};
use async_trait::async_trait;
use serde_json::{json, Value};
pub struct NeedleProvider {
client: reqwest::Client,
base_url: String,
max_gen_len: u32,
}
impl NeedleProvider {
pub fn new(base_url: String) -> NeedleProvider {
NeedleProvider { client: reqwest::Client::new(), base_url, max_gen_len: 256 }
}
}
fn core_question(t: &str) -> &str {
match t.find("\n\n[Retrieved evidence") {
Some(i) => t[..i].trim_end(),
None => t.trim(),
}
}
pub fn build_query(system: &str, msgs: &[Msg]) -> String {
let mut q = String::new();
if !system.trim().is_empty() {
q.push_str(system.trim());
q.push_str("\n\n");
}
for m in msgs {
match m.role {
Role::User => {
for b in &m.blocks {
match b {
Block::Text(t) => {
q.push_str("User: ");
q.push_str(core_question(t));
q.push('\n');
}
Block::ToolResult { content, .. } => {
q.push_str("Tool result: ");
q.push_str(content);
q.push('\n');
}
_ => {}
}
}
}
Role::Assistant => {
for b in &m.blocks {
match b {
Block::Text(t) if !t.is_empty() => {
q.push_str("Assistant: ");
q.push_str(t);
q.push('\n');
}
Block::ToolUse { name, input, .. } => {
q.push_str(&format!("Assistant called {name}({input})\n"));
}
_ => {}
}
}
}
}
}
q.trim_end().to_string()
}
pub fn to_needle_tools(tools: &[ToolSpec]) -> String {
let arr: Vec<Value> = tools.iter().map(|t| json!({"name": t.name, "description": minimal_desc(&t.name), "parameters": minimal_params(&t.schema)})).collect();
Value::Array(arr).to_string()
}
fn minimal_desc(name: &str) -> String {
match name {
"run_workflow" => "Run one analysis over the rows.".to_string(),
"search" => "Search the documents for a phrase.".to_string(),
other => other.to_string(),
}
}
fn minimal_params(schema: &Value) -> Value {
let mut s = schema.clone();
if let Some(props) = s.get_mut("properties").and_then(|p| p.as_object_mut()) {
for (_k, v) in props.iter_mut() {
if let Some(o) = v.as_object_mut() {
o.remove("description");
}
}
}
s
}
pub fn parse_result(result: &str) -> Turn {
let trimmed = result.trim();
if let Ok(Value::Array(calls)) = serde_json::from_str::<Value>(trimmed) {
let mut tool_uses = Vec::new();
for (i, call) in calls.iter().enumerate() {
let Some(name) = call.get("name").and_then(|x| x.as_str()) else { continue };
let input = match call.get("arguments") {
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(json!({})),
Some(v) => v.clone(),
None => json!({}),
};
tool_uses.push((format!("needle-{i}"), name.to_string(), input));
}
if !tool_uses.is_empty() {
return Turn { text: String::new(), tool_uses, stop: Stop::ToolUse };
}
}
Turn { text: trimmed.to_string(), tool_uses: Vec::new(), stop: Stop::EndTurn }
}
#[async_trait]
impl LlmProvider for NeedleProvider {
fn name(&self) -> &str {
"needle"
}
fn synthesizes(&self) -> bool {
false
}
async fn chat(&self, _system: &str, msgs: &[Msg], tools: &[ToolSpec]) -> Result<Turn, String> {
let url = format!("{}/generate", self.base_url.trim_end_matches('/'));
const NEEDLE_TOOLS: &[&str] = &["run_workflow", "search"];
let tools: Vec<ToolSpec> = tools.iter().filter(|t| NEEDLE_TOOLS.contains(&t.name.as_str())).cloned().collect();
let tools = tools.as_slice();
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\
Examples:\n\
Q: Tell me about electric vehicles with range over 500km.\n\
{\"name\":\"run_workflow\",\"arguments\":{\"constraints\":[\"powertrain/electric\",\"(num range_km gt 500)\"]}}\n\
Q: For each country, which powertrain is most common?\n\
{\"name\":\"run_workflow\",\"arguments\":{\"program\":\"crosstab\",\"facet_a\":\"country\",\"facet_b\":\"powertrain\"}}";
let body = json!({
"query": build_query(LEAN_SYSTEM, msgs),
"tools": to_needle_tools(tools),
"max_gen_len": self.max_gen_len,
"seed": 0,
"constrained": true,
});
let resp = self.client.post(&url).json(&body).send().await.map_err(|e| format!("needle request: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("needle {status}: {text}"));
}
let v: Value = resp.json().await.map_err(|e| format!("needle decode: {e}"))?;
let result = v.get("result").and_then(|r| r.as_str()).ok_or("needle: no result field")?;
Ok(parse_result(result))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn needle_tools_use_bare_schema_not_openai_envelope() {
let tools = vec![ToolSpec {
name: "crosstab".into(),
description: "cross two facets".into(),
schema: json!({"type": "object", "properties": {"a": {"type": "string"}}}),
}];
let s = to_needle_tools(&tools);
let v: Value = serde_json::from_str(&s).unwrap();
assert_eq!(v[0]["name"], "crosstab");
assert!(v[0].get("parameters").is_some());
assert!(v[0].get("function").is_none(), "must NOT use the OpenAI {{type,function}} envelope");
}
#[test]
fn parse_result_reads_tool_calls() {
let t = parse_result(r#"[{"name":"crosstab","arguments":{"a":"country","b":"powertrain"}}]"#);
assert_eq!(t.stop, Stop::ToolUse);
assert_eq!(t.tool_uses.len(), 1);
assert_eq!(t.tool_uses[0].1, "crosstab");
assert_eq!(t.tool_uses[0].2["a"], "country");
}
#[test]
fn parse_result_handles_escaped_arguments_string() {
let t = parse_result(r#"[{"name":"rank","arguments":"{\"facet\":\"powertrain\"}"}]"#);
assert_eq!(t.tool_uses[0].2["facet"], "powertrain");
}
#[test]
fn parse_result_falls_back_to_text() {
let t = parse_result("Japan is mostly electric.");
assert_eq!(t.stop, Stop::EndTurn);
assert!(t.tool_uses.is_empty());
assert_eq!(t.text, "Japan is mostly electric.");
}
#[test]
fn build_query_labels_turns_and_tool_results() {
let msgs = vec![
Msg::user_text("how many EVs per country?"),
Msg { role: Role::Assistant, blocks: vec![Block::ToolUse { id: "1".into(), name: "crosstab".into(), input: json!({"a": "country"}) }] },
Msg { role: Role::User, blocks: vec![Block::ToolResult { id: "1".into(), content: "japan=2".into(), is_error: false }] },
];
let q = build_query("You are SteelDB.", &msgs);
assert!(q.starts_with("You are SteelDB."));
assert!(q.contains("User: how many EVs per country?"));
assert!(q.contains("Assistant called crosstab"));
assert!(q.contains("Tool result: japan=2"));
}
}