1use std::future::Future;
7use std::pin::Pin;
8
9use futures::Stream;
10use futures::stream::{self, StreamExt};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::error::{ToolError, codes};
15use crate::tool::ToolResult;
16
17pub type ToolStream = Pin<Box<dyn Stream<Item = ToolStreamItem> + Send>>;
19
20#[derive(Debug)]
22pub enum ToolStreamItem {
23 Progress(ToolProgress),
25 Terminal(Result<ToolResult, ToolError>),
27}
28
29impl ToolStreamItem {
30 #[must_use]
32 pub const fn is_terminal(&self) -> bool {
33 matches!(self, Self::Terminal(_))
34 }
35}
36
37pub const MAX_DELTA_BYTES: usize = 16 * 1024;
39pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44#[serde(tag = "kind", rename_all = "snake_case")]
45#[non_exhaustive]
46pub enum ToolProgress {
47 Text {
49 text: String,
51 },
52 Partial {
54 delta: String,
56 total_bytes: u64,
58 truncated: bool,
60 gap: u64,
62 },
63 Custom {
65 subkind: String,
67 payload: Value,
69 },
70}
71
72impl ToolProgress {
73 #[must_use]
75 pub fn text(text: impl Into<String>) -> Self {
76 Self::Text { text: text.into() }
77 }
78
79 #[must_use]
81 pub fn partial(delta: impl Into<String>, total_bytes: u64, truncated: bool, gap: u64) -> Self {
82 Self::Partial {
83 delta: delta.into(),
84 total_bytes,
85 truncated,
86 gap,
87 }
88 }
89}
90
91#[must_use]
96pub fn partial_progress_frames(
97 input: &str,
98 max_delta_bytes: usize,
99 max_frame_bytes: usize,
100) -> Vec<ToolProgress> {
101 let max_delta = max_delta_bytes.max(1);
102 let max_frame = max_frame_bytes.max(max_delta);
103 let mut out = Vec::new();
104 let mut offset = 0usize;
105 let mut total: u64 = 0;
106 let bytes = input.as_bytes();
107 while offset < bytes.len() {
108 if total >= u64::try_from(max_frame).unwrap_or(u64::MAX) {
109 break;
110 }
111 let remaining_frame =
112 max_frame.saturating_sub(usize::try_from(total).unwrap_or(usize::MAX));
113 let want = max_delta
114 .min(remaining_frame)
115 .min(bytes.len().saturating_sub(offset));
116 if want == 0 {
117 break;
118 }
119 let end = utf8_floor_end(input, offset, offset.saturating_add(want));
120 if end <= offset {
121 let next = input[offset..]
123 .chars()
124 .next()
125 .map_or(offset.saturating_add(1), |c| {
126 offset.saturating_add(c.len_utf8())
127 });
128 let gap = u64::try_from(next.saturating_sub(offset)).unwrap_or(0);
129 out.push(ToolProgress::partial(String::new(), total, true, gap));
130 offset = next;
131 continue;
132 }
133 let delta = input.get(offset..end).unwrap_or("").to_owned();
134 let delta_len = u64::try_from(delta.len()).unwrap_or(0);
135 total = total.saturating_add(delta_len);
136 let truncated = end - offset < want && end < bytes.len();
137 out.push(ToolProgress::partial(delta, total, truncated, 0));
138 offset = end;
139 }
140 out
141}
142
143fn utf8_floor_end(s: &str, start: usize, end: usize) -> usize {
145 let end = end.min(s.len());
146 if end <= start {
147 return start;
148 }
149 if s.is_char_boundary(end) {
150 return end;
151 }
152 let mut e = end;
153 while e > start && !s.is_char_boundary(e) {
154 e = e.saturating_sub(1);
155 }
156 e
157}
158
159#[must_use]
161pub fn terminal_only(result: Result<ToolResult, ToolError>) -> ToolStream {
162 Box::pin(stream::once(
163 async move { ToolStreamItem::Terminal(result) },
164 ))
165}
166
167pub fn with_progress<I, F, Fut>(progress: I, terminal: F) -> ToolStream
169where
170 I: IntoIterator<Item = ToolProgress> + Send + 'static,
171 F: FnOnce() -> Fut + Send + 'static,
172 Fut: Future<Output = Result<ToolResult, ToolError>> + Send + 'static,
173{
174 let items: Vec<ToolStreamItem> = progress.into_iter().map(ToolStreamItem::Progress).collect();
175 let prog = stream::iter(items);
176 let term = stream::once(async move { ToolStreamItem::Terminal(terminal().await) });
177 Box::pin(prog.chain(term))
178}
179
180pub async fn drain_terminal(stream: ToolStream) -> Result<ToolResult, ToolError> {
186 let (_progress, result) = drain_with_progress(stream).await;
187 result
188}
189
190pub async fn drain_with_progress(
197 mut stream: ToolStream,
198) -> (Vec<ToolProgress>, Result<ToolResult, ToolError>) {
199 let mut progress = Vec::new();
200 while let Some(item) = stream.next().await {
201 match item {
202 ToolStreamItem::Progress(p) => progress.push(p),
203 ToolStreamItem::Terminal(result) => return (progress, result),
204 }
205 }
206 (
207 progress,
208 Err(codes::stream_protocol(
209 "tool stream ended without a terminal item",
210 )),
211 )
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[tokio::test]
219 async fn terminal_only_drains() {
220 let s = terminal_only(Ok(ToolResult::text("ok")));
221 let r = drain_terminal(s).await.expect("drain");
222 assert_eq!(r.content, "ok");
223 }
224
225 #[tokio::test]
226 async fn progress_then_terminal() {
227 let s = with_progress(vec![ToolProgress::text("working")], || async {
228 Ok(ToolResult::text("done"))
229 });
230 let r = drain_terminal(s).await.expect("drain");
231 assert_eq!(r.content, "done");
232 }
233
234 #[tokio::test]
235 async fn drain_with_progress_captures_chunks() {
236 let s = with_progress(
237 vec![ToolProgress::text("a"), ToolProgress::text("b")],
238 || async { Ok(ToolResult::text("done")) },
239 );
240 let (progress, result) = drain_with_progress(s).await;
241 assert_eq!(progress.len(), 2);
242 assert_eq!(result.expect("ok").content, "done");
243 }
244
245 #[tokio::test]
246 async fn empty_stream_is_protocol_error() {
247 let s: ToolStream = Box::pin(stream::empty());
248 let err = drain_terminal(s).await.expect_err("proto");
249 assert_eq!(err.code(), machi_types::ErrorCode::ToolStreamProtocol);
250 }
251
252 #[test]
253 fn partial_frames_respect_utf8_and_caps() {
254 let s = "hello🎉world";
255 let frames = partial_progress_frames(s, 4, 10_000);
256 assert!(!frames.is_empty());
257 let mut rebuilt = String::new();
258 for f in &frames {
259 if let ToolProgress::Partial { delta, .. } = f {
260 rebuilt.push_str(delta);
261 }
262 }
263 assert!(rebuilt.is_char_boundary(rebuilt.len()));
265 for f in frames {
266 if let ToolProgress::Partial { delta, .. } = f {
267 assert!(delta.len() <= 4 || delta.is_empty());
268 }
269 }
270 }
271
272 #[test]
273 fn partial_frames_honor_frame_budget() {
274 let s = "abcdefghij";
275 let frames = partial_progress_frames(s, 3, 6);
276 let total: usize = frames
277 .iter()
278 .map(|f| match f {
279 ToolProgress::Partial { delta, .. } => delta.len(),
280 _ => 0,
281 })
282 .sum();
283 assert!(total <= 6);
284 }
285}