Skip to main content

rhema_transcript/
zoom.rs

1//! The `zoom` tool: the model's way back to any original message.
2//!
3//! The view summarizes old ranges; zoom opens them. It reads the same
4//! mirror the decorator refreshes, so what it returns is the log as the
5//! last request carried it — which is every message the model has been
6//! shown an id for. Read-only: a `Safe` replay, per the resolver
7//! contract.
8
9use std::sync::{Arc, Mutex, PoisonError};
10
11use async_trait::async_trait;
12use rhema_core::{ReplaySafety, ToolCall, ToolContext, ToolResult, ToolSpec};
13
14use crate::summary;
15use crate::tree::Range;
16use crate::view::Transcript;
17
18/// Bytes one zoom call returns before cutting; the cut names the
19/// total and the `at` to resume at. The same cap and path at every
20/// `n`, so paging a range and paging a message are one call shape.
21pub const ZOOM_MAX_BYTES: usize = 8 * 1024;
22
23/// The most bytes one zoom call renders before it refuses. A range
24/// bigger than this cannot be paged — paging needs the bytes before
25/// `at` rendered too — and a narrower `n` is the way. Sixty-four
26/// pages: no honest range comes near it, and a model asking for the
27/// whole log in one call stops here instead of holding the mirror's
28/// lock while it all renders.
29const ZOOM_RENDER_CEILING: usize = 64 * ZOOM_MAX_BYTES;
30
31/// The `zoom` resolver over a shared mirror.
32#[derive(Debug, Clone)]
33pub struct Zoom {
34    transcript: Arc<Mutex<Transcript>>,
35}
36
37impl Zoom {
38    /// The resolver reading `transcript` — the mirror the view
39    /// decorator refreshes. Build both sides with [`crate::attach`].
40    pub fn new(transcript: Arc<Mutex<Transcript>>) -> Self {
41        Self { transcript }
42    }
43
44    /// The spec advertised to the model. Its description is the view's
45    /// contract: how the lines read and when to zoom.
46    pub fn spec() -> ToolSpec {
47        ToolSpec::new(
48            "zoom",
49            "Read older messages of this conversation whole. The \
50             <transcript> block in the context summarizes old ranges as \
51             `id+n|text` lines; zoom opens them. zoom(id, n) returns the \
52             n messages from id whole, a wide range with each message \
53             headed by its id+1| address. Long output is cut: pass at (a \
54             byte offset) to page on. Before you guess, ask, or re-run a \
55             command: if a summary line omits what you need, the \
56             original is here.",
57            serde_json::json!({
58                "type": "object",
59                "properties": {
60                    "id": {
61                        "type": "integer",
62                        "description": "index of the range's first message"
63                    },
64                    "n": {
65                        "type": "integer",
66                        "description": "how many messages the range covers; the messages come back whole",
67                        "default": 1
68                    },
69                    "at": {
70                        "type": "integer",
71                        "description": "byte offset into the output, to page through a long message",
72                        "default": 0
73                    }
74                },
75                "required": ["id"]
76            }),
77        )
78    }
79
80    /// Resolves one zoom call; the in-band form of
81    /// [`rhema_core::ToolResolver::resolve`], public for tests and
82    /// embedders.
83    ///
84    /// `id`, `n`, and `at` arrive from the model and are trusted for
85    /// nothing: an out-of-range value is an in-band failure, and `at` is
86    /// snapped down to a char boundary before any slice.
87    pub fn zoom(&self, call: &ToolCall) -> ToolResult {
88        let Some(id) = call.args.get("id").and_then(|v| v.as_u64()) else {
89            return ToolResult::failure(&call.id, "zoom needs an integer `id`");
90        };
91        let n = call
92            .args
93            .get("n")
94            .and_then(|v| v.as_u64())
95            .unwrap_or(1)
96            .max(1) as usize;
97        let at = call.args.get("at").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
98
99        let transcript = self
100            .transcript
101            .lock()
102            .unwrap_or_else(PoisonError::into_inner);
103        let len = transcript.len();
104        let range = Range { id: id as usize, n };
105        if range.id >= len {
106            return ToolResult::failure(
107                &call.id,
108                format!("zoom: message {id} is past the log ({len} messages)"),
109            );
110        }
111        // Saturating: `n` is model-supplied and unbounded; the wrapped
112        // sum of a plain add could land inside the log.
113        if range.id.saturating_add(range.n) > len {
114            return ToolResult::failure(
115                &call.id,
116                format!(
117                    "zoom: {id}+{} reaches past the log ({len} messages); \
118                     the last message is {}",
119                    range.n,
120                    len - 1
121                ),
122            );
123        }
124
125        // Render only what the requested page needs: everything up to
126        // one byte past `at + ZOOM_MAX_BYTES`, which is all the offset
127        // math reads. A page-1 call stops one message past its page
128        // instead of rendering the whole range under the lock.
129        let needed = at.saturating_add(ZOOM_MAX_BYTES).saturating_add(1);
130        let range_end = range.id + range.n;
131        let mut text = String::new();
132        let mut covered = range.id;
133        for i in range.id..range_end {
134            if !text.is_empty() {
135                text.push('\n');
136            }
137            if range.n > 1 {
138                // The addresses are the map: a page of content also
139                // names the messages it holds, so the model can zoom a
140                // neighbor or a narrower range without recomputing.
141                text.push_str(&format!("{i}+1|"));
142            }
143            let message = transcript.message(i).expect("the range was checked");
144            text.push_str(&summary::render_message(message));
145            covered = i + 1;
146            if text.len() >= needed && covered < range_end {
147                break;
148            }
149        }
150        let truncated = covered < range_end;
151        if truncated && text.len() > ZOOM_RENDER_CEILING {
152            return ToolResult::failure(
153                &call.id,
154                format!(
155                    "zoom: {id}+{} renders past the byte budget — \
156                     zoom a narrower range (smaller n)",
157                    range.n
158                ),
159            );
160        }
161
162        if at >= text.len() {
163            if truncated {
164                return ToolResult::failure(
165                    &call.id,
166                    format!(
167                        "zoom: `at` {at} lands past what was rendered \
168                         ({} bytes); zoom a narrower range (smaller n)",
169                        text.len()
170                    ),
171                );
172            }
173            let mut msg = format!("zoom: `at` {at} is past the end ({} bytes)", text.len());
174            // Only a render bigger than one page has a tail worth
175            // pointing at.
176            if text.len() > ZOOM_MAX_BYTES {
177                msg.push_str(&format!("; page from {}", text.len() - ZOOM_MAX_BYTES));
178            }
179            return ToolResult::failure(&call.id, msg);
180        }
181        let start = snapped_back(&text, at);
182        if text.len() <= ZOOM_MAX_BYTES {
183            return ToolResult::success(&call.id, &text[start..]);
184        }
185        let mut end = start.saturating_add(ZOOM_MAX_BYTES).min(text.len());
186        while end < text.len() && !text.is_char_boundary(end) {
187            end -= 1;
188        }
189        // The total is only known when the render reached the range's
190        // end; a page cut short of it reports its own size instead.
191        let note = if end < text.len() {
192            if truncated {
193                format!("\n\n[{} bytes; zoom again with at={end}]", end - start)
194            } else {
195                format!(
196                    "\n\n[{} of {} bytes; zoom again with at={}]",
197                    end - start,
198                    text.len(),
199                    end
200                )
201            }
202        } else {
203            String::new()
204        };
205        ToolResult::success(&call.id, format!("{}{}", &text[start..end], note))
206    }
207}
208
209/// The largest char boundary at or below `at` — the model supplies raw
210/// byte offsets, and UTF-8 cannot be cut mid-character.
211fn snapped_back(text: &str, at: usize) -> usize {
212    let mut at = at.min(text.len());
213    while at > 0 && !text.is_char_boundary(at) {
214        at -= 1;
215    }
216    at
217}
218
219#[async_trait]
220impl rhema_core::ToolResolver for Zoom {
221    fn specs(&self) -> Vec<ToolSpec> {
222        vec![Self::spec()]
223    }
224
225    fn replay_safety(&self, name: &str) -> ReplaySafety {
226        if name == "zoom" {
227            ReplaySafety::Safe
228        } else {
229            ReplaySafety::Unsafe
230        }
231    }
232
233    async fn resolve(&self, call: ToolCall, _ctx: &ToolContext) -> ToolResult {
234        self.zoom(&call)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use rhema_core::{Message, Role, ToolResolver};
242
243    fn transcript_with(messages: Vec<Message>) -> Arc<Mutex<Transcript>> {
244        let mut transcript = Transcript::new(crate::view::TranscriptSettings::default());
245        transcript.observe(&messages);
246        Arc::new(Mutex::new(transcript))
247    }
248
249    fn call(args: serde_json::Value) -> ToolCall {
250        ToolCall {
251            id: "z1".into(),
252            name: "zoom".into(),
253            args,
254            provider_data: serde_json::Value::Null,
255        }
256    }
257
258    fn history() -> Vec<Message> {
259        vec![
260            Message::text(Role::User, "fix the login bug"),
261            Message::text(Role::Assistant, "checking auth.rs"),
262            Message::text(Role::User, "the session one, not the api key"),
263        ]
264    }
265
266    #[test]
267    fn zoom_one_returns_the_message_whole() {
268        let zoom = Zoom::new(transcript_with(history()));
269        let out = zoom.zoom(&call(serde_json::json!({ "id": 1 })));
270        assert!(!out.is_error);
271        assert_eq!(out.content, "checking auth.rs");
272    }
273
274    #[test]
275    fn zoom_many_returns_the_messages_whole_and_addressed() {
276        let zoom = Zoom::new(transcript_with(history()));
277        let out = zoom.zoom(&call(serde_json::json!({ "id": 0, "n": 3 })));
278        assert!(!out.is_error);
279        assert!(
280            out.content.contains("0+1|fix the login bug")
281                && out.content.contains("1+1|checking auth.rs")
282                && out.content.contains("2+1|the session one, not the api key"),
283            "{}",
284            out.content
285        );
286    }
287
288    #[test]
289    fn a_wide_range_pages_the_same_way_a_message_does() {
290        // Three messages of half the cap each: the range's content
291        // cannot fit one page, and the second page begins inside the
292        // second message, so the third one's address lands on it.
293        let chunk = "y".repeat(ZOOM_MAX_BYTES / 2);
294        let transcript = transcript_with(vec![
295            Message::text(Role::User, chunk.clone()),
296            Message::text(Role::Assistant, chunk.clone()),
297            Message::text(Role::User, chunk),
298        ]);
299        let zoom = Zoom::new(Arc::clone(&transcript));
300        let first = zoom.zoom(&call(serde_json::json!({ "id": 0, "n": 3 })));
301        assert!(first.content.contains("at="), "{}", first.content);
302        let resume_at: usize = first
303            .content
304            .split("at=")
305            .nth(1)
306            .unwrap()
307            .trim_end_matches(']')
308            .parse()
309            .unwrap();
310        let rest = zoom.zoom(&call(
311            serde_json::json!({ "id": 0, "n": 3, "at": resume_at }),
312        ));
313        assert!(!rest.is_error, "{:?}", rest);
314        // The last page reaches the range's final message and needs
315        // no further cut.
316        assert!(rest.content.contains("2+1|"), "{}", rest.content);
317        assert!(!rest.content.contains("at="), "{}", rest.content);
318    }
319
320    #[test]
321    fn bad_ids_are_in_band_errors() {
322        let zoom = Zoom::new(transcript_with(history()));
323        let past = zoom.zoom(&call(serde_json::json!({ "id": 3 })));
324        assert!(past.is_error);
325        assert!(past.content.contains("past the log (3 messages)"));
326        let over = zoom.zoom(&call(serde_json::json!({ "id": 1, "n": 9 })));
327        assert!(over.is_error);
328        let no_id = zoom.zoom(&call(serde_json::json!({ "n": 1 })));
329        assert!(no_id.is_error);
330    }
331
332    #[test]
333    fn a_long_message_pages_at_the_offset() {
334        let big = "y".repeat(ZOOM_MAX_BYTES + 500);
335        let zoom = Zoom::new(transcript_with(vec![Message::text(Role::User, big)]));
336        let first = zoom.zoom(&call(serde_json::json!({ "id": 0 })));
337        assert!(first.content.len() <= ZOOM_MAX_BYTES + 100);
338        assert!(first.content.contains("at="), "{}", first.content);
339        let resume_at: usize = first
340            .content
341            .split("at=")
342            .nth(1)
343            .unwrap()
344            .trim_end_matches(']')
345            .parse()
346            .unwrap();
347        let rest = zoom.zoom(&call(serde_json::json!({ "id": 0, "at": resume_at })));
348        assert!(!rest.is_error, "{:?}", rest);
349        assert!(
350            rest.content.len() <= 500 + 100,
351            "{} bytes",
352            rest.content.len()
353        );
354    }
355
356    #[test]
357    fn a_wide_range_renders_one_page_and_names_its_own_size() {
358        let line = "x".repeat(600);
359        let messages: Vec<Message> = (0..200)
360            .map(|i| Message::text(Role::User, format!("msg {i} {line}")))
361            .collect();
362        let zoom = Zoom::new(transcript_with(messages));
363        let out = zoom.zoom(&call(serde_json::json!({ "id": 0, "n": 200 })));
364        assert!(!out.is_error, "{:?}", out);
365        // One page, not two hundred messages.
366        assert!(
367            out.content.len() < ZOOM_MAX_BYTES + 2048,
368            "{} bytes",
369            out.content.len()
370        );
371        // The render stopped early: the note carries no grand total.
372        assert!(
373            out.content.contains("zoom again with at="),
374            "{}",
375            out.content
376        );
377        assert!(!out.content.contains(" of "), "{}", out.content);
378        // Paging continues from the named offset.
379        let at: usize = out
380            .content
381            .rsplit("at=")
382            .next()
383            .unwrap()
384            .trim_end_matches(']')
385            .parse()
386            .unwrap();
387        let rest = zoom.zoom(&call(serde_json::json!({ "id": 0, "n": 200, "at": at })));
388        assert!(!rest.is_error, "{:?}", rest);
389    }
390
391    #[test]
392    fn a_range_past_the_ceiling_refuses_with_a_direction() {
393        let line = "x".repeat(2000);
394        let messages: Vec<Message> = (0..400)
395            .map(|i| Message::text(Role::User, format!("msg {i} {line}")))
396            .collect();
397        let zoom = Zoom::new(transcript_with(messages));
398        // Early pages are honest work: the ceiling refuses the deep
399        // ones, where honoring `at` means rendering half a megabyte to
400        // hand back eight kilobytes.
401        let deep = ZOOM_RENDER_CEILING - 2000;
402        let out = zoom.zoom(&call(serde_json::json!({ "id": 0, "n": 400, "at": deep })));
403        assert!(
404            out.is_error,
405            "the deep page does not render: {}",
406            &out.content[..200]
407        );
408        assert!(out.content.contains("narrower range"), "{}", out.content);
409        // A single message still reads: the ceiling is for ranges.
410        let one = zoom.zoom(&call(serde_json::json!({ "id": 399 })));
411        assert!(!one.is_error, "{:?}", one);
412    }
413
414    #[test]
415    fn the_spec_advertises_zoom_and_only_zoom() {
416        let zoom = Zoom::new(transcript_with(history()));
417        let names: Vec<String> = zoom.specs().iter().map(|s| s.name.clone()).collect();
418        assert_eq!(names, vec!["zoom".to_owned()]);
419        let spec = Zoom::spec();
420        assert!(spec.description.contains("id+n|text"));
421        assert_eq!(zoom.replay_safety("zoom"), ReplaySafety::Safe);
422        assert_eq!(zoom.replay_safety("other"), ReplaySafety::Unsafe);
423    }
424
425    #[test]
426    fn a_text_block_only_history_renders_verbatim() {
427        let zoom = Zoom::new(transcript_with(history()));
428        let out = zoom.zoom(&call(serde_json::json!({ "id": 2 })));
429        assert_eq!(out.content, "the session one, not the api key");
430    }
431
432    #[test]
433    fn an_absurd_n_is_an_in_band_error_not_an_overflow() {
434        // id=1 with n=u64::MAX wrapped to `id + n == 0` under a plain
435        // add, walking past the bounds check into the expects.
436        let zoom = Zoom::new(transcript_with(history()));
437        let out = zoom.zoom(&call(serde_json::json!({ "id": 1, "n": u64::MAX })));
438        assert!(out.is_error);
439        assert!(out.content.contains("past the log"), "{}", out.content);
440    }
441
442    #[test]
443    fn an_at_past_the_end_names_the_content_size() {
444        // The render is the content, so the byte count in the error
445        // must equal what a successful call over the same range
446        // returns, not the size of some condensation of it.
447        let zoom = Zoom::new(transcript_with(history()));
448        let whole = zoom.zoom(&call(serde_json::json!({ "id": 0, "n": 3 })));
449        assert!(!whole.is_error);
450        let out = zoom.zoom(&call(serde_json::json!({ "id": 0, "n": 3, "at": 10_000 })));
451        assert!(out.is_error);
452        let size: usize = out
453            .content
454            .split('(')
455            .nth(1)
456            .unwrap()
457            .split(" bytes")
458            .next()
459            .unwrap()
460            .parse()
461            .unwrap();
462        assert_eq!(size, whole.content.len(), "{}", out.content);
463    }
464
465    #[test]
466    fn an_at_inside_a_character_snaps_back_instead_of_panic() {
467        // Byte 1 sits inside the two-byte leading `é`: the raw slice
468        // used to panic here.
469        let zoom = Zoom::new(transcript_with(vec![Message::text(
470            Role::User,
471            "éa long enough message to hold a paging cut",
472        )]));
473        let out = zoom.zoom(&call(serde_json::json!({ "id": 0, "at": 1 })));
474        assert!(!out.is_error, "{out:?}");
475        assert!(out.content.starts_with('é'), "{}", out.content);
476    }
477}