use crate::error::AgentError;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: Option<String>,
pub name: String,
pub arguments: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolOutput {
pub id: Option<String>,
pub name: String,
pub success: bool,
pub output: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelTurn {
pub text: Option<String>,
pub calls: Vec<ToolCall>,
}
fn args_string(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
None => "{}".into(),
}
}
fn push_text(text: &mut Option<String>, piece: &str) {
if piece.is_empty() {
return;
}
match text {
Some(t) => t.push_str(piece),
None => *text = Some(piece.to_string()),
}
}
pub fn parse_model_response(body: &str) -> Result<ModelTurn, AgentError> {
let v: Value = serde_json::from_str(body).map_err(|e| AgentError::ParseError(format!("response is not JSON: {e}")))?;
let mut turn = ModelTurn::default();
let message = v.pointer("/choices/0/message").or_else(|| v.get("message"));
if let Some(m) = message {
if let Some(t) = m.get("content").and_then(Value::as_str) {
push_text(&mut turn.text, t);
}
for c in m.get("tool_calls").and_then(Value::as_array).into_iter().flatten() {
let f = c.get("function").unwrap_or(c);
let name = f.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
if name.is_empty() {
return Err(AgentError::ParseError("tool call without a name".into()));
}
turn.calls.push(ToolCall {
id: c.get("id").and_then(Value::as_str).map(str::to_string),
name,
arguments: args_string(f.get("arguments")),
});
}
return Ok(turn);
}
if let Some(blocks) = v.get("content").and_then(Value::as_array) {
for b in blocks {
match b.get("type").and_then(Value::as_str) {
Some("text") => push_text(&mut turn.text, b.get("text").and_then(Value::as_str).unwrap_or("")),
Some("tool_use") => turn.calls.push(ToolCall {
id: b.get("id").and_then(Value::as_str).map(str::to_string),
name: b.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
arguments: args_string(b.get("input")),
}),
_ => {}
}
}
return Ok(turn);
}
if let Some(items) = v.get("output").and_then(Value::as_array) {
for it in items {
match it.get("type").and_then(Value::as_str) {
Some("function_call") => turn.calls.push(ToolCall {
id: it.get("call_id").or_else(|| it.get("id")).and_then(Value::as_str).map(str::to_string),
name: it.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
arguments: args_string(it.get("arguments")),
}),
Some("message") => {
for c in it.get("content").and_then(Value::as_array).into_iter().flatten() {
push_text(&mut turn.text, c.get("text").and_then(Value::as_str).unwrap_or(""));
}
}
_ => {}
}
}
return Ok(turn);
}
Err(AgentError::ParseError("not an OpenAI, Anthropic or Ollama chat response".into()))
}
fn shown(o: &ToolOutput) -> String {
if o.success { o.output.clone() } else { format!("Error: {}", o.output) }
}
pub fn tool_results_openai(outputs: &[ToolOutput]) -> Vec<Value> {
outputs
.iter()
.map(|o| json!({"role": "tool", "tool_call_id": o.id.clone().unwrap_or_default(), "content": shown(o)}))
.collect()
}
pub fn tool_results_anthropic(outputs: &[ToolOutput]) -> Value {
let blocks: Vec<Value> = outputs
.iter()
.map(|o| {
let mut b = json!({"type": "tool_result", "tool_use_id": o.id.clone().unwrap_or_default(), "content": o.output});
if !o.success {
b["is_error"] = json!(true);
}
b
})
.collect();
json!({"role": "user", "content": blocks})
}
pub fn tool_results_responses(outputs: &[ToolOutput]) -> Vec<Value> {
outputs
.iter()
.map(|o| json!({"type": "function_call_output", "call_id": o.id.clone().unwrap_or_default(), "output": shown(o)}))
.collect()
}
pub(crate) fn api_schema(schema: &str) -> Result<(Value, bool), AgentError> {
let trimmed = schema.trim();
let v: Value = if trimmed.is_empty() || trimmed == "{}" {
json!({"type": "string"})
} else {
serde_json::from_str(trimmed).map_err(|e| AgentError::InvalidToolSignature(format!("input schema is not JSON: {e}")))?
};
if v.get("type").and_then(Value::as_str) == Some("object") {
Ok((v, false))
} else {
Ok((json!({"type": "object", "properties": {"input": v}, "required": ["input"]}), true))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openai_chat() {
let t = parse_model_response(
r#"{"choices":[{"message":{"role":"assistant","content":"Let me check.","tool_calls":[
{"id":"call_a","type":"function","function":{"name":"x","arguments":"{\"q\":1}"}},
{"id":"call_b","type":"function","function":{"name":"y","arguments":"{}"}}]}}]}"#,
)
.unwrap();
assert_eq!(t.text.as_deref(), Some("Let me check."));
assert_eq!(t.calls.len(), 2);
assert_eq!(t.calls[0], ToolCall { id: Some("call_a".into()), name: "x".into(), arguments: r#"{"q":1}"#.into() });
let fin = parse_model_response(r#"{"choices":[{"message":{"role":"assistant","content":"Paris."}}]}"#).unwrap();
assert!(fin.calls.is_empty());
assert_eq!(fin.text.as_deref(), Some("Paris."));
}
#[test]
fn anthropic_messages() {
let t = parse_model_response(
r#"{"id":"msg","type":"message","role":"assistant","stop_reason":"tool_use","content":[
{"type":"text","text":"Checking."},
{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Paris"}}]}"#,
)
.unwrap();
assert_eq!(t.calls[0].id.as_deref(), Some("toolu_1"));
assert_eq!(t.calls[0].arguments, r#"{"city":"Paris"}"#);
assert_eq!(t.text.as_deref(), Some("Checking."));
}
#[test]
fn openai_responses_and_ollama() {
let t = parse_model_response(
r#"{"output":[{"type":"function_call","id":"fc_1","call_id":"call_9","name":"x","arguments":"{\"a\":true}"}]}"#,
)
.unwrap();
assert_eq!(t.calls[0].id.as_deref(), Some("call_9"));
let o = parse_model_response(
r#"{"model":"llama3.2","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"x","arguments":{"a":1}}}]},"done":true}"#,
)
.unwrap();
assert_eq!(o.calls[0], ToolCall { id: None, name: "x".into(), arguments: r#"{"a":1}"#.into() });
assert_eq!(o.text, None);
}
#[test]
fn rejects_unknown_shapes() {
assert!(parse_model_response("not json").is_err());
assert!(parse_model_response(r#"{"hello":1}"#).is_err());
assert!(parse_model_response(r#"{"choices":[{"message":{"tool_calls":[{"function":{"arguments":"{}"}}]}}]}"#).is_err());
}
#[test]
fn result_formats() {
let outs = vec![
ToolOutput { id: Some("c1".into()), name: "x".into(), success: true, output: "ok".into() },
ToolOutput { id: Some("c2".into()), name: "y".into(), success: false, output: "boom".into() },
];
assert_eq!(tool_results_openai(&outs)[1], json!({"role": "tool", "tool_call_id": "c2", "content": "Error: boom"}));
assert_eq!(tool_results_openai(&outs)[0]["content"], "ok");
let a = tool_results_anthropic(&outs);
assert_eq!(a["content"][1]["is_error"], json!(true));
assert!(a["content"][0].get("is_error").is_none());
assert_eq!(tool_results_responses(&outs)[0]["call_id"], "c1");
}
#[test]
fn schema_wrapping() {
let (s, wrapped) = api_schema(r#"{"type":"object","properties":{}}"#).unwrap();
assert!(!wrapped);
assert_eq!(s["type"], "object");
let (s, wrapped) = api_schema(r#"{"type":"string"}"#).unwrap();
assert!(wrapped);
assert_eq!(s["properties"]["input"]["type"], "string");
assert!(api_schema("{nope").is_err());
assert!(api_schema("").unwrap().1);
}
}