1use 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
18pub const ZOOM_MAX_BYTES: usize = 8 * 1024;
22
23const ZOOM_RENDER_CEILING: usize = 64 * ZOOM_MAX_BYTES;
30
31#[derive(Debug, Clone)]
33pub struct Zoom {
34 transcript: Arc<Mutex<Transcript>>,
35}
36
37impl Zoom {
38 pub fn new(transcript: Arc<Mutex<Transcript>>) -> Self {
41 Self { transcript }
42 }
43
44 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 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 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 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 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 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 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
209fn 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 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 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 assert!(
367 out.content.len() < ZOOM_MAX_BYTES + 2048,
368 "{} bytes",
369 out.content.len()
370 );
371 assert!(
373 out.content.contains("zoom again with at="),
374 "{}",
375 out.content
376 );
377 assert!(!out.content.contains(" of "), "{}", out.content);
378 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 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 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 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 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 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}