use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_param, require_str};
pub struct JsonTool;
#[async_trait]
impl Tool for JsonTool {
fn name(&self) -> &str {
"json"
}
fn description(&self) -> &str {
"Parse, query, and transform JSON data. Supports JSONPath-like queries. \
Use `source_tool_call_id` to reference the full output of a previous tool call \
(avoids truncation issues with large responses)."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["parse", "query", "stringify", "validate"],
"description": "The JSON operation to perform"
},
"data": {
"description": "JSON input data. Pass a string for parse, or any JSON value otherwise. Not required when source_tool_call_id is provided."
},
"source_tool_call_id": {
"type": "string",
"description": "Reference a previous tool call's full output by its ID (e.g., 'call_abc123'). Use this instead of data when the previous tool output was large and may have been truncated."
},
"path": {
"type": "string",
"description": "JSONPath-like path for query operation (e.g., 'foo.bar[0].baz')"
}
},
"required": ["operation"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let operation = require_str(¶ms, "operation")?;
let data_value =
if let Some(ref_id) = params.get("source_tool_call_id").and_then(|v| v.as_str()) {
let stash = ctx.tool_output_stash.read().await;
let full_output = stash.get(ref_id).ok_or_else(|| {
ToolError::InvalidParameters(format!(
"no tool output found for call ID '{}'. Available IDs: {:?}",
ref_id,
stash.keys().collect::<Vec<_>>()
))
})?;
serde_json::from_str::<serde_json::Value>(full_output)
.unwrap_or_else(|_| serde_json::Value::String(full_output.clone()))
} else {
require_param(¶ms, "data")?.clone()
};
let data = &data_value;
let result = match operation {
"parse" => {
let json_str = data.as_str().ok_or_else(|| {
ToolError::InvalidParameters(
"'data' must be a string for parse operation".to_string(),
)
})?;
let parsed: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| ToolError::InvalidParameters(format!("invalid JSON: {}", e)))?;
parsed
}
"stringify" => {
let value = if data.is_string() {
parse_json_input(data)?
} else {
data.clone()
};
let json_str = serde_json::to_string_pretty(&value).map_err(|e| {
ToolError::ExecutionFailed(format!("failed to stringify: {}", e))
})?;
serde_json::Value::String(json_str)
}
"query" => {
let path = params.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
ToolError::InvalidParameters("missing 'path' parameter for query".to_string())
})?;
let value = if data.is_string() {
parse_json_input(data)?
} else {
data.clone()
};
query_json(&value, path)?
}
"validate" => {
let is_valid = data
.as_str()
.map(|s| serde_json::from_str::<serde_json::Value>(s).is_ok())
.unwrap_or(false);
serde_json::json!({ "valid": is_valid })
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"unknown operation: {}",
operation
)));
}
};
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false }
}
fn parse_json_input(data: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
let json_str = data
.as_str()
.ok_or_else(|| ToolError::InvalidParameters("'data' must be a JSON string".to_string()))?;
serde_json::from_str(json_str)
.map_err(|e| ToolError::InvalidParameters(format!("invalid JSON input: {}", e)))
}
fn query_json(data: &serde_json::Value, path: &str) -> Result<serde_json::Value, ToolError> {
let mut current = data;
for segment in path.split('.') {
if segment.is_empty() {
continue;
}
if let Some((field, index_str)) = segment.split_once('[') {
if !field.is_empty() {
current = current.get(field).ok_or_else(|| {
ToolError::ExecutionFailed(format!("field not found: {}", field))
})?;
}
let index_str = index_str.trim_end_matches(']');
let index: usize = index_str.parse().map_err(|_| {
ToolError::InvalidParameters(format!("invalid array index: {}", index_str))
})?;
current = current.get(index).ok_or_else(|| {
ToolError::ExecutionFailed(format!("array index out of bounds: {}", index))
})?;
} else {
current = current.get(segment).ok_or_else(|| {
ToolError::ExecutionFailed(format!("field not found: {}", segment))
})?;
}
}
Ok(current.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_query_json() {
let data = serde_json::json!({
"foo": {
"bar": [1, 2, 3],
"baz": "hello"
}
});
assert_eq!(
query_json(&data, "foo.baz").unwrap(),
serde_json::json!("hello")
);
assert_eq!(
query_json(&data, "foo.bar[0]").unwrap(),
serde_json::json!(1)
);
assert_eq!(
query_json(&data, "foo.bar[2]").unwrap(),
serde_json::json!(3)
);
}
#[test]
fn test_parse_json_input_accepts_valid_json_string() {
let input = serde_json::json!("{\"ok\":true}");
let parsed = parse_json_input(&input).unwrap();
assert_eq!(parsed, serde_json::json!({"ok": true}));
}
#[test]
fn test_parse_json_input_rejects_invalid_json_string() {
let input = serde_json::json!("{not valid json}");
let err = parse_json_input(&input).unwrap_err();
assert!(err.to_string().contains("invalid JSON input"));
}
#[tokio::test]
async fn test_query_with_object_data_from_stash() {
use crate::context::JobContext;
let ctx = JobContext::with_user("test", "chat", "test-session");
let stashed = r#"{"status": 200, "body": {"leagues": [{"name": "MLB"}]}}"#;
ctx.tool_output_stash
.write()
.await
.insert("call_http_01".to_string(), stashed.to_string());
let tool = JsonTool;
let params = serde_json::json!({
"operation": "query",
"source_tool_call_id": "call_http_01",
"path": "body.leagues[0].name"
});
let result = tool.execute(params, &ctx).await.unwrap();
assert_eq!(result.result, serde_json::json!("MLB"));
}
#[tokio::test]
async fn test_stringify_with_object_data_from_stash() {
use crate::context::JobContext;
let ctx = JobContext::with_user("test", "chat", "test-session");
let stashed = r#"{"key": "value"}"#;
ctx.tool_output_stash
.write()
.await
.insert("call_01".to_string(), stashed.to_string());
let tool = JsonTool;
let params = serde_json::json!({
"operation": "stringify",
"source_tool_call_id": "call_01"
});
let result = tool.execute(params, &ctx).await.unwrap();
let stringified = result.result.as_str().unwrap();
assert!(stringified.contains("\"key\": \"value\""));
}
#[test]
fn test_json_tool_schema_data_is_freeform() {
let schema = JsonTool.parameters_schema();
let data = schema
.get("properties")
.and_then(|p| p.get("data"))
.expect("data schema missing");
assert!(
data.get("type").is_none(),
"data schema should not have a 'type' to be freeform for OpenAI compatibility"
);
}
}