Skip to main content

locode_provider/
assemble.rs

1//! Streaming tool-call assembly: accumulate partial-JSON args, parse at stop.
2//!
3//! Every studied harness consumes an SSE stream and **accumulates a tool call's
4//! arguments as a raw string** across `input_json_delta` fragments, because a single
5//! delta is not valid JSON — then parses once when the block closes. This helper
6//! encodes exactly that rule (raw string per content-block index, parse at
7//! [`ToolCallAssembler::finish`]). v0 is non-streaming, but the helper is built and
8//! tested standalone so the streaming wire drops straight in later.
9
10use std::collections::BTreeMap;
11
12use locode_protocol::ContentBlock;
13use serde_json::Value;
14use thiserror::Error;
15
16/// Accumulates streamed tool-call fragments by content-block index.
17#[derive(Debug, Default)]
18pub struct ToolCallAssembler {
19    // Keyed by content-block index so `finish` yields calls in stream order.
20    partials: BTreeMap<usize, Partial>,
21}
22
23#[derive(Debug)]
24struct Partial {
25    id: String,
26    name: String,
27    json: String,
28}
29
30/// A failure assembling streamed tool-call arguments.
31#[derive(Debug, Error)]
32pub enum AssembleError {
33    /// A fragment arrived for an index with no preceding [`ToolCallAssembler::begin`].
34    #[error("no tool call started at content-block index {0}")]
35    MissingStart(usize),
36    /// The accumulated argument buffer was not valid JSON.
37    #[error("invalid tool-call arguments JSON at index {index}: {source}")]
38    InvalidJson {
39        /// The content-block index whose buffer failed to parse.
40        index: usize,
41        /// The underlying JSON parse error.
42        source: serde_json::Error,
43    },
44}
45
46impl ToolCallAssembler {
47    /// A fresh, empty assembler.
48    #[must_use]
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Begin a tool call at `index` (from the stream's `content_block_start`).
54    pub fn begin(&mut self, index: usize, id: impl Into<String>, name: impl Into<String>) {
55        self.partials.insert(
56            index,
57            Partial {
58                id: id.into(),
59                name: name.into(),
60                json: String::new(),
61            },
62        );
63    }
64
65    /// Append a raw partial-JSON fragment for `index`. **Never parsed here.**
66    ///
67    /// # Errors
68    /// [`AssembleError::MissingStart`] if no [`ToolCallAssembler::begin`] was seen for
69    /// `index`.
70    pub fn push_json(&mut self, index: usize, fragment: &str) -> Result<(), AssembleError> {
71        match self.partials.get_mut(&index) {
72            Some(partial) => {
73                partial.json.push_str(fragment);
74                Ok(())
75            }
76            None => Err(AssembleError::MissingStart(index)),
77        }
78    }
79
80    /// Finish: parse each accumulated buffer once, in index order, into
81    /// [`ContentBlock::ToolUse`] blocks.
82    ///
83    /// An empty/whitespace buffer becomes `{}` — Anthropic sends empty input for a
84    /// no-argument tool call.
85    ///
86    /// # Errors
87    /// [`AssembleError::InvalidJson`] if any accumulated buffer is not valid JSON.
88    pub fn finish(self) -> Result<Vec<ContentBlock>, AssembleError> {
89        let mut blocks = Vec::with_capacity(self.partials.len());
90        for (index, partial) in self.partials {
91            let input: Value = if partial.json.trim().is_empty() {
92                Value::Object(serde_json::Map::new())
93            } else {
94                serde_json::from_str(&partial.json)
95                    .map_err(|source| AssembleError::InvalidJson { index, source })?
96            };
97            blocks.push(ContentBlock::ToolUse {
98                id: partial.id,
99                name: partial.name,
100                input,
101            });
102        }
103        Ok(blocks)
104    }
105}