use lellm_core::{LlmError, ToolCall};
#[derive(Debug)]
pub struct ToolCallDelta {
pub index: usize,
pub id: Option<String>,
pub name: Option<String>,
pub arguments_delta: Option<String>,
}
pub struct ToolCallAccumulator {
current: std::collections::HashMap<usize, PendingToolCall>,
}
struct PendingToolCall {
id: Option<String>,
name: Option<String>,
arguments: String,
}
impl ToolCallAccumulator {
pub fn new() -> Self {
Self {
current: std::collections::HashMap::new(),
}
}
pub fn push(&mut self, delta: &ToolCallDelta) {
let entry = self
.current
.entry(delta.index)
.or_insert_with(|| PendingToolCall {
id: None,
name: None,
arguments: String::new(),
});
if let Some(ref id) = delta.id {
entry.id = Some(id.clone());
}
if let Some(ref name) = delta.name {
entry.name = Some(name.clone());
}
if let Some(ref d) = delta.arguments_delta {
entry.arguments.push_str(d);
}
}
pub fn finalize(self) -> Result<Vec<ToolCall>, LlmError> {
let mut entries: Vec<_> = self.current.into_iter().collect();
entries.sort_by_key(|&(idx, _)| idx);
let mut result = Vec::new();
for (_index, pending) in entries {
let id = pending.id.unwrap_or_else(|| "unknown".to_string());
let name = pending.name.unwrap_or_else(|| "unknown".to_string());
let arguments: serde_json::Value = if pending.arguments.is_empty() {
serde_json::Value::Object(Default::default())
} else {
robust_parse(&pending.arguments)
};
result.push(ToolCall {
id,
name,
arguments,
});
}
Ok(result)
}
}
fn robust_parse(text: &str) -> serde_json::Value {
let trimmed = text.trim();
if trimmed.is_empty() {
return serde_json::Value::Object(Default::default());
}
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&trimmed) {
return result;
}
let stripped = strip_codeblocks(&trimmed);
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&stripped) {
tracing::debug!(
original_preview = %trimmed.chars().take(80).collect::<String>(),
"structured output: stripped markdown codeblocks"
);
return result;
}
if let Some(json_str) = extract_braces(&stripped) {
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&json_str) {
tracing::debug!(
original_preview = %trimmed.chars().take(80).collect::<String>(),
"structured output: extracted braces"
);
return result;
}
}
let fixed = fix_common_errors(&stripped);
if let Some(json_str) = extract_braces(&fixed) {
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&json_str) {
tracing::debug!(
original_preview = %trimmed.chars().take(80).collect::<String>(),
"structured output: fixed common json errors"
);
return result;
}
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&fixed) {
return v;
}
tracing::warn!(
raw_preview = %trimmed.chars().take(120).collect::<String>(),
"structured output: all parse layers failed, returning raw string"
);
serde_json::Value::String(trimmed.to_string())
}
fn strip_codeblocks(text: &str) -> String {
let mut result = text.to_string();
result = result.replace("```json\n", "").replace("```json", "");
result = result.replace("```\n", "").replace("```", "");
result.trim().to_string()
}
fn fix_common_errors(text: &str) -> String {
let mut s = text.to_string();
s = s
.replace(", }", "}")
.replace(",\t}", "}")
.replace(",}", "}");
s = s
.replace(", ]", "]")
.replace(",\t]", "]")
.replace(",]", "]");
s = normalize_single_quote_json(&s);
s
}
fn normalize_single_quote_json(s: &str) -> String {
let mut out = String::new();
let mut in_double = false;
let mut in_single = false;
let mut escaped = false;
for ch in s.chars() {
match ch {
'"' if !escaped && !in_single => {
in_double = !in_double;
out.push(ch);
}
'\'' if !escaped && !in_double => {
in_single = !in_single;
out.push('"');
}
_ => out.push(ch),
}
escaped = ch == '\\' && !escaped;
}
out
}
fn extract_braces(content: &str) -> Option<String> {
let start = content.find('{')?;
let mut depth = 0i32;
let mut end = None;
let mut in_string = false;
let mut escaped = false;
for (i, c) in content[start..].char_indices() {
if in_string {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_string = false;
}
} else {
match c {
'"' => in_string = true,
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(start + i + 1);
break;
}
}
_ => {}
}
}
}
end.map(|e| content[start..e].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accumulates_delta_into_tool_call() {
let mut acc = ToolCallAccumulator::new();
acc.push(&ToolCallDelta {
index: 0,
id: None,
name: Some("read_file".into()),
arguments_delta: None,
});
acc.push(&ToolCallDelta {
index: 0,
id: Some("tc_123".into()),
name: None,
arguments_delta: Some(r#""{"path""#.into()),
});
acc.push(&ToolCallDelta {
index: 0,
id: None,
name: None,
arguments_delta: Some(r#": "/test"}}"#.into()),
});
let calls = acc.finalize().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "tc_123");
assert_eq!(calls[0].name, "read_file");
}
#[test]
fn empty_accumulator_returns_empty() {
let acc = ToolCallAccumulator::new();
let calls = acc.finalize().unwrap();
assert!(calls.is_empty());
}
#[test]
fn robust_parse_direct() {
let result = robust_parse(r#"{"action":"deploy","job_name":"ds-pkg"}"#);
assert!(result.is_object());
assert_eq!(result["action"], "deploy");
}
#[test]
fn robust_parse_codeblock() {
let result = robust_parse("```json\n{\"action\":\"build\"}\n```");
assert_eq!(result["action"], "build");
}
#[test]
fn robust_parse_braces() {
let result = robust_parse("Here is the result: {\"action\":\"query\"} done.");
assert_eq!(result["action"], "query");
}
#[test]
fn robust_parse_trailing_comma() {
let result = robust_parse(r#"{"action":"deploy","job_name":"ds-pkg","branch":"main",}"#);
assert_eq!(result["action"], "deploy");
}
#[test]
fn robust_parse_single_quotes() {
let result = robust_parse(r#"{'action':'deploy','job_name':'ds-pkg'}"#);
assert_eq!(result["action"], "deploy");
}
#[test]
fn robust_parse_nested_braces() {
let result = robust_parse(r#"{"config":{"nested":true}}"#);
assert_eq!(result["config"]["nested"], true);
}
#[test]
fn robust_parse_braces_in_string() {
let result = robust_parse(r#"{"text":"hello {world}"}"#);
assert_eq!(result["text"], "hello {world}");
}
#[test]
fn robust_parse_empty_returns_object() {
let result = robust_parse("");
assert!(result.is_object());
}
#[test]
fn strip_codeblocks_removes_markers() {
assert_eq!(strip_codeblocks("```json\n{\"a\":1}\n```"), r#"{"a":1}"#);
assert_eq!(strip_codeblocks("```\n{\"a\":1}\n```"), r#"{"a":1}"#);
}
#[test]
fn fix_common_errors_removes_trailing_commas() {
assert_eq!(fix_common_errors(r#"{"a":1,}"#), r#"{"a":1}"#);
assert_eq!(fix_common_errors(r#"{"a":[1,]}"#), r#"{"a":[1]}"#);
}
#[test]
fn normalize_single_quote_json_pure_single_quotes() {
assert_eq!(
normalize_single_quote_json(r#"{'action':'deploy'}"#),
r#"{"action":"deploy"}"#
);
}
#[test]
fn normalize_single_quote_json_preserves_apostrophe_in_double_string() {
assert_eq!(
normalize_single_quote_json(r#"{'text': "It's"}"#),
r#"{"text": "It's"}"#
);
}
#[test]
fn normalize_single_quote_json_no_change_if_double() {
assert_eq!(
normalize_single_quote_json(r#"{"a": "b"}"#),
r#"{"a": "b"}"#
);
}
#[test]
fn normalize_single_quote_json_respects_escape() {
assert_eq!(
normalize_single_quote_json(r#"{"a": "test\"ed"}"#),
r#"{"a": "test\"ed"}"#
);
}
#[test]
fn normalize_single_quote_json_double_backslash() {
assert_eq!(
normalize_single_quote_json(r#"{"a": "C:\\\"test"}"#),
r#"{"a": "C:\\\"test"}"#
);
}
#[test]
fn robust_parse_mixed_quotes_with_trailing_comma() {
let result = robust_parse(r#"{'text': "It's a test", 'count': 5,}"#);
assert_eq!(result["text"], "It's a test");
assert_eq!(result["count"], 5);
}
#[test]
fn extract_braces_finds_outermost() {
assert_eq!(
extract_braces("hello {\"a\":1} world"),
Some(r#"{"a":1}"#.into())
);
assert_eq!(
extract_braces("{\"a\":{\"b\":1}}"),
Some(r#"{"a":{"b":1}}"#.into())
);
assert_eq!(
extract_braces(r#"{"text":"hello {world}"}"#),
Some(r#"{"text":"hello {world}"}"#.into())
);
}
}