use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct ToolSpec {
pub name: &'static str,
pub description: &'static str,
pub parameters_schema: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments_json: String,
}
#[derive(Debug, Clone)]
pub enum StreamOutcome {
Text,
ToolCalls(Vec<ToolCall>),
}
#[derive(Debug, Default)]
pub struct ToolCallAccumulator {
calls: BTreeMap<u32, (String, String, String)>, }
impl ToolCallAccumulator {
pub fn new() -> Self {
Self::default()
}
pub fn start(&mut self, index: u32, id: String, name: String) {
let entry = self.calls.entry(index).or_default();
if !id.is_empty() {
entry.0 = id;
}
if !name.is_empty() {
entry.1 = name;
}
}
pub fn append_args(&mut self, index: u32, fragment: &str) {
self.calls.entry(index).or_default().2.push_str(fragment);
}
pub fn finish(self) -> Vec<ToolCall> {
self.calls
.into_iter()
.filter_map(|(_, (id, name, args))| {
(!name.is_empty()).then_some(ToolCall {
id,
name,
arguments_json: args,
})
})
.collect()
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolCallRecord {
pub id: String,
pub name: String,
pub arguments_json: String,
}
impl From<ToolCall> for ToolCallRecord {
fn from(c: ToolCall) -> Self {
Self {
id: c.id,
name: c.name,
arguments_json: c.arguments_json,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolResultRecord {
pub call_id: String,
pub output: String,
pub is_error: bool,
pub denied: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_call_in_order() {
let mut acc = ToolCallAccumulator::new();
acc.start(0, "call_1".to_string(), "read_file".to_string());
acc.append_args(0, "{\"path\":");
acc.append_args(0, "\"src/main.rs\"}");
let calls = acc.finish();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "read_file");
assert_eq!(calls[0].arguments_json, "{\"path\":\"src/main.rs\"}");
}
#[test]
fn args_before_start_still_accumulate() {
let mut acc = ToolCallAccumulator::new();
acc.append_args(0, "{\"path\":\"x\"}");
acc.start(0, "call_1".to_string(), "read_file".to_string());
let calls = acc.finish();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].arguments_json, "{\"path\":\"x\"}");
}
#[test]
fn multiple_calls_interleaved_by_index() {
let mut acc = ToolCallAccumulator::new();
acc.start(0, "call_1".to_string(), "read_file".to_string());
acc.start(1, "call_2".to_string(), "run_command".to_string());
acc.append_args(0, "{\"path\":");
acc.append_args(1, "{\"command\":");
acc.append_args(0, "\"a\"}");
acc.append_args(1, "\"ls\"}");
let calls = acc.finish();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "read_file");
assert_eq!(calls[0].arguments_json, "{\"path\":\"a\"}");
assert_eq!(calls[1].name, "run_command");
assert_eq!(calls[1].arguments_json, "{\"command\":\"ls\"}");
}
#[test]
fn nameless_call_is_dropped() {
let mut acc = ToolCallAccumulator::new();
acc.append_args(0, "{}"); let calls = acc.finish();
assert!(calls.is_empty());
}
#[test]
fn start_called_twice_keeps_latest_nonempty_value() {
let mut acc = ToolCallAccumulator::new();
acc.start(0, "call_1".to_string(), String::new());
acc.start(0, String::new(), "read_file".to_string());
let calls = acc.finish();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "read_file");
}
}