tool-call-batcher 0.1.0

Queue multiple pending tool calls and flush them as a batch
Documentation
/*!
tool-call-batcher: queue pending tool calls and flush them as a batch.

When an agent produces multiple tool calls in one turn, this crate lets
you collect them, inspect them, and flush them in a controlled way.

```rust
use tool_call_batcher::CallBatcher;
use serde_json::json;

let mut b = CallBatcher::new();
b.enqueue("search", json!({"q": "rust"}));
b.enqueue("fetch",  json!({"url": "http://example.com"}));
let batch = b.flush();
assert_eq!(batch.len(), 2);
assert!(b.is_empty());
```
*/

use serde_json::Value;

#[derive(Debug, Clone)]
pub struct PendingCall {
    pub id: usize,
    pub tool: String,
    pub args: Value,
    pub priority: i32,
}

#[derive(Debug, Default)]
pub struct CallBatcher {
    queue: Vec<PendingCall>,
    next_id: usize,
}

impl CallBatcher {
    pub fn new() -> Self { Self::default() }

    /// Add a call to the queue. Returns its id.
    pub fn enqueue(&mut self, tool: impl Into<String>, args: Value) -> usize {
        self.enqueue_with_priority(tool, args, 0)
    }

    pub fn enqueue_with_priority(&mut self, tool: impl Into<String>, args: Value, priority: i32) -> usize {
        let id = self.next_id;
        self.next_id += 1;
        self.queue.push(PendingCall { id, tool: tool.into(), args, priority });
        id
    }

    /// Remove and return all queued calls (in enqueue order).
    pub fn flush(&mut self) -> Vec<PendingCall> {
        std::mem::take(&mut self.queue)
    }

    /// Remove and return calls sorted by priority descending.
    pub fn flush_by_priority(&mut self) -> Vec<PendingCall> {
        let mut calls = std::mem::take(&mut self.queue);
        calls.sort_by(|a, b| b.priority.cmp(&a.priority));
        calls
    }

    /// Peek at calls without removing them.
    pub fn peek(&self) -> &[PendingCall] { &self.queue }

    /// Remove a specific call by id.
    pub fn cancel(&mut self, id: usize) -> bool {
        let before = self.queue.len();
        self.queue.retain(|c| c.id != id);
        self.queue.len() < before
    }

    /// Calls for a specific tool (peeked, not removed).
    pub fn calls_for(&self, tool: &str) -> Vec<&PendingCall> {
        self.queue.iter().filter(|c| c.tool == tool).collect()
    }

    pub fn len(&self) -> usize { self.queue.len() }
    pub fn is_empty(&self) -> bool { self.queue.is_empty() }
    pub fn clear(&mut self) { self.queue.clear(); }

    /// Build Anthropic-style tool_use content blocks.
    pub fn to_tool_use_blocks(&self) -> Vec<Value> {
        self.queue.iter().map(|c| {
            serde_json::json!({
                "type": "tool_use",
                "id": format!("call_{}", c.id),
                "name": c.tool,
                "input": c.args,
            })
        }).collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn enqueue_and_flush() {
        let mut b = CallBatcher::new();
        b.enqueue("search", json!({}));
        b.enqueue("fetch", json!({}));
        let calls = b.flush();
        assert_eq!(calls.len(), 2);
        assert!(b.is_empty());
    }

    #[test]
    fn flush_clears_queue() {
        let mut b = CallBatcher::new();
        b.enqueue("t", json!(null));
        b.flush();
        assert!(b.is_empty());
    }

    #[test]
    fn flush_by_priority() {
        let mut b = CallBatcher::new();
        b.enqueue_with_priority("low", json!({}), 1);
        b.enqueue_with_priority("high", json!({}), 10);
        let calls = b.flush_by_priority();
        assert_eq!(calls[0].tool, "high");
    }

    #[test]
    fn cancel_removes_call() {
        let mut b = CallBatcher::new();
        let id = b.enqueue("x", json!({}));
        assert!(b.cancel(id));
        assert!(b.is_empty());
    }

    #[test]
    fn cancel_nonexistent_returns_false() {
        let mut b = CallBatcher::new();
        assert!(!b.cancel(999));
    }

    #[test]
    fn calls_for_filter() {
        let mut b = CallBatcher::new();
        b.enqueue("search", json!({}));
        b.enqueue("fetch", json!({}));
        b.enqueue("search", json!({}));
        assert_eq!(b.calls_for("search").len(), 2);
    }

    #[test]
    fn ids_are_unique() {
        let mut b = CallBatcher::new();
        let a = b.enqueue("t", json!({}));
        let c = b.enqueue("t", json!({}));
        assert_ne!(a, c);
    }

    #[test]
    fn peek_does_not_remove() {
        let mut b = CallBatcher::new();
        b.enqueue("t", json!({}));
        let _ = b.peek();
        assert_eq!(b.len(), 1);
    }

    #[test]
    fn to_tool_use_blocks() {
        let mut b = CallBatcher::new();
        b.enqueue("search", json!({"q": "rust"}));
        let blocks = b.to_tool_use_blocks();
        assert_eq!(blocks.len(), 1);
        assert_eq!(blocks[0]["type"], "tool_use");
        assert_eq!(blocks[0]["name"], "search");
    }

    #[test]
    fn clear_empties_queue() {
        let mut b = CallBatcher::new();
        b.enqueue("t", json!({}));
        b.clear();
        assert!(b.is_empty());
    }

    #[test]
    fn flush_preserves_order() {
        let mut b = CallBatcher::new();
        b.enqueue("a", json!({}));
        b.enqueue("b", json!({}));
        b.enqueue("c", json!({}));
        let calls = b.flush();
        let tools: Vec<&str> = calls.iter().map(|c| c.tool.as_str()).collect();
        assert_eq!(tools, vec!["a", "b", "c"]);
    }
}