mod args;
pub use args::{ArgumentStream, FieldHandle};
use crate::error::{MiniLLMError, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
impl ToolDefinition {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
) -> Self {
Self {
name: name.into(),
description: Some(description.into()),
parameters,
strict: None,
}
}
pub fn with_strict(mut self, strict: bool) -> Self {
self.strict = Some(strict);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolChoice {
Auto,
None,
Required,
Tool(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
impl ToolCall {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
arguments: impl Into<String>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
arguments: arguments.into(),
}
}
pub fn arguments_json(&self) -> Result<serde_json::Value> {
serde_json::from_str(&self.arguments).map_err(|e| {
MiniLLMError::InvalidParameter(format!(
"tool call '{}' ({}) carries invalid JSON arguments: {} (raw: {})",
self.name, self.id, e, self.arguments
))
})
}
pub fn arguments_json_repaired(&self) -> Result<serde_json::Value> {
crate::utils::extract_json_value(&self.arguments).map_err(|e| {
MiniLLMError::InvalidParameter(format!(
"tool call '{}' ({}) carries unrepairable JSON arguments: {} (raw: {})",
self.name, self.id, e, self.arguments
))
})
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ToolCallDelta {
pub index: u64,
pub id: Option<String>,
pub name: Option<String>,
pub arguments_fragment: Option<String>,
}
#[derive(Debug, Default)]
pub struct ToolCallAccumulator {
slots: BTreeMap<u64, PartialToolCall>,
}
#[derive(Debug, Default)]
struct PartialToolCall {
id: Option<String>,
name: Option<String>,
arguments: String,
}
impl ToolCallAccumulator {
pub fn ingest(&mut self, deltas: &[ToolCallDelta]) {
for delta in deltas {
let slot = self.slots.entry(delta.index).or_default();
if let Some(id) = &delta.id {
slot.id = Some(id.clone());
}
if let Some(name) = &delta.name {
slot.name = Some(name.clone());
}
if let Some(frag) = &delta.arguments_fragment {
slot.arguments.push_str(frag);
}
}
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
pub fn finish(&self) -> Vec<ToolCall> {
self.slots
.iter()
.filter_map(|(index, slot)| match (&slot.id, &slot.name) {
(Some(id), Some(name)) => {
Some(ToolCall::new(id.clone(), name.clone(), slot.arguments.clone()))
}
_ => {
tracing::warn!(
index,
has_id = slot.id.is_some(),
has_name = slot.name.is_some(),
"incomplete tool call fragment dropped (stream cancelled mid-call or malformed wire)"
);
None
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn call_arguments_json_parses_or_fails_loudly() {
let ok = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#);
assert_eq!(ok.arguments_json().unwrap()["city"], "Paris");
let bad = ToolCall::new("c2", "get_weather", "{not json");
assert!(bad.arguments_json().is_err());
}
#[test]
fn accumulator_assembles_fragments_by_index() {
let mut acc = ToolCallAccumulator::default();
acc.ingest(&[ToolCallDelta {
index: 0,
id: Some("c0".into()),
name: Some("search".into()),
arguments_fragment: Some("{\"q\":".into()),
}]);
acc.ingest(&[ToolCallDelta {
index: 0,
arguments_fragment: Some("\"rust\"}".into()),
..Default::default()
}]);
let calls = acc.finish();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "c0");
assert_eq!(calls[0].name, "search");
assert_eq!(calls[0].arguments, r#"{"q":"rust"}"#);
}
#[test]
fn accumulator_handles_sparse_and_interleaved_indices() {
let mut acc = ToolCallAccumulator::default();
acc.ingest(&[
ToolCallDelta {
index: 3,
id: Some("c3".into()),
name: Some("b".into()),
..Default::default()
},
ToolCallDelta {
index: 1,
id: Some("c1".into()),
name: Some("a".into()),
..Default::default()
},
]);
acc.ingest(&[
ToolCallDelta {
index: 1,
arguments_fragment: Some("{}".into()),
..Default::default()
},
ToolCallDelta {
index: 3,
arguments_fragment: Some("{}".into()),
..Default::default()
},
]);
let calls = acc.finish();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].id, "c1", "index order preserved");
assert_eq!(calls[1].id, "c3");
}
#[test]
fn accumulator_drops_incomplete_slots_and_never_allocates_by_index() {
let mut acc = ToolCallAccumulator::default();
acc.ingest(&[ToolCallDelta {
index: 4_000_000_000,
arguments_fragment: Some("junk".into()),
..Default::default()
}]);
assert!(acc.finish().is_empty());
}
#[test]
fn tool_call_round_trips_serde() {
let call = ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#);
let json = serde_json::to_string(&call).unwrap();
let back: ToolCall = serde_json::from_str(&json).unwrap();
assert_eq!(back, call);
}
}