1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use crate::error::RuntimeError;
5use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
6use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
7use crate::value::Value;
8
9#[derive(Clone, Default)]
10pub struct OutputStore {
11 session_dir: Option<Arc<PathBuf>>,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct OutputPage {
16 pub content: String,
17 pub mode: &'static str,
18 pub offset: usize,
19 pub next_offset: usize,
20 pub total_lines: usize,
21 pub total_bytes: usize,
22 pub has_more: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct OutputSearchHit {
27 pub line: usize,
28 pub snippet: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct OutputSearchResult {
33 pub query: String,
34 pub total_matches: usize,
35 pub hits: Vec<OutputSearchHit>,
36 pub has_more: bool,
37 pub next_match: usize,
38}
39
40impl OutputStore {
41 pub fn at(session_dir: impl Into<PathBuf>) -> Self {
42 Self {
43 session_dir: Some(Arc::new(session_dir.into())),
44 }
45 }
46
47 pub fn register(&self, _label: &str, content: &str) -> Option<String> {
48 let session_dir = self.session_dir.as_deref()?;
49 let output_id = format!("out_{}", uuid::Uuid::now_v7().simple());
50 write_output_file(session_dir, &output_id, content)?;
51 Some(output_id)
52 }
53
54 pub fn validates_pagination_envelope(&self, content: &str) -> bool {
55 is_live_pagination_envelope(content, Some(self))
56 }
57
58 pub fn read_lines(
59 &self,
60 output_id: &str,
61 offset: usize,
62 limit: usize,
63 budget: ToolOutputBudget,
64 ) -> Result<OutputPage, RuntimeError> {
65 let content = self.read_registered(output_id)?;
66 let lines: Vec<&str> = content.split_inclusive('\n').collect();
67 let start = offset.min(lines.len());
68 let requested_end = start
69 .saturating_add(limit.min(budget.max_lines))
70 .min(lines.len());
71 let mut end = requested_end;
72 while end > start {
73 let candidate = lines[start..end].concat();
74 if bounded_prefix_len(&candidate, budget) == candidate.len() {
75 break;
76 }
77 end -= 1;
78 }
79 if end == start && start < lines.len() {
80 return Err(RuntimeError::ToolFailed(
81 "output.read: the next line exceeds the output budget; use byte_offset + byte_limit".into(),
82 ));
83 }
84 let requested = lines[start..end].concat();
85 Ok(OutputPage {
86 content: requested,
87 mode: "lines",
88 offset: start,
89 next_offset: end,
90 total_lines: lines.len(),
91 total_bytes: content.len(),
92 has_more: end < lines.len(),
93 })
94 }
95
96 pub fn search(
97 &self,
98 output_id: &str,
99 query: &str,
100 match_index: usize,
101 match_limit: usize,
102 ) -> Result<OutputSearchResult, RuntimeError> {
103 if query.is_empty() {
104 return Err(RuntimeError::ToolFailed(
105 "output.read: query must not be empty".into(),
106 ));
107 }
108 let content = self.read_registered(output_id)?;
109 let lines: Vec<&str> = content.lines().collect();
110 let matches: Vec<OutputSearchHit> = lines
111 .iter()
112 .enumerate()
113 .filter(|(_, line)| line.contains(query))
114 .map(|(index, line)| OutputSearchHit {
115 line: index + 1,
116 snippet: line.chars().take(240).collect(),
117 })
118 .collect();
119 let start = match_index.min(matches.len());
120 let end = start.saturating_add(match_limit).min(matches.len());
121 Ok(OutputSearchResult {
122 query: query.to_string(),
123 total_matches: matches.len(),
124 hits: matches[start..end].to_vec(),
125 has_more: end < matches.len(),
126 next_match: end,
127 })
128 }
129
130 pub fn read_bytes(
131 &self,
132 output_id: &str,
133 offset: usize,
134 limit: usize,
135 budget: ToolOutputBudget,
136 ) -> Result<OutputPage, RuntimeError> {
137 let content = self.read_registered(output_id)?;
138 if offset > content.len() || !content.is_char_boundary(offset) {
139 return Err(RuntimeError::ToolFailed(
140 "output.read: byte_offset is not a valid UTF-8 boundary".into(),
141 ));
142 }
143 let mut end = offset
144 .saturating_add(limit.min(budget.max_bytes).min(budget.max_line_bytes))
145 .min(content.len());
146 while end > offset && !content.is_char_boundary(end) {
147 end -= 1;
148 }
149 Ok(OutputPage {
150 content: content[offset..end].to_string(),
151 mode: "bytes",
152 offset,
153 next_offset: end,
154 total_lines: content.split_inclusive('\n').count(),
155 total_bytes: content.len(),
156 has_more: end < content.len(),
157 })
158 }
159
160 pub(crate) fn validates_total_bytes(&self, output_id: &str, total_bytes: usize) -> bool {
161 self.read_registered(output_id)
162 .is_ok_and(|content| content.len() == total_bytes)
163 }
164
165 pub(crate) fn validates_pagination(
166 &self,
167 output_id: &str,
168 mode: &str,
169 offset: usize,
170 total_bytes: usize,
171 total_lines: Option<usize>,
172 has_more: bool,
173 ) -> bool {
174 let Ok(content) = self.read_registered(output_id) else {
175 return false;
176 };
177 if total_bytes != content.len() {
178 return false;
179 }
180 match mode {
181 "bytes" => has_more == (offset < content.len()) && offset <= content.len(),
182 "lines" => {
183 let actual_lines = content.split_inclusive('\n').count();
184 total_lines == Some(actual_lines)
185 && has_more == (offset < actual_lines)
186 && offset <= actual_lines
187 }
188 _ => false,
189 }
190 }
191
192 fn read_registered(&self, output_id: &str) -> Result<String, RuntimeError> {
193 if !output_id.starts_with("out_")
194 || output_id.len() != 36
195 || !output_id[4..].chars().all(|c| c.is_ascii_hexdigit())
196 {
197 return Err(RuntimeError::ToolFailed(
198 "output.read: unknown output_id".into(),
199 ));
200 }
201 let session_dir = self.session_dir.as_deref().ok_or_else(|| {
202 RuntimeError::ToolFailed("output.read: no session output store available".into())
203 })?;
204 let path = output_dir(session_dir).join(format!("{output_id}.txt"));
205 std::fs::read_to_string(path)
206 .map_err(|_| RuntimeError::ToolFailed("output.read: unknown output_id".into()))
207 }
208}
209
210pub struct OutputRead;
211
212impl Tool for OutputRead {
213 fn name(&self) -> &str {
214 "output.read"
215 }
216
217 fn tier(&self) -> Tier {
218 Tier::Zero
219 }
220
221 fn description(&self) -> Option<&str> {
222 Some(
223 "Read a registered oversized tool output from the current session. Use line_offset + line_limit for page-by-line continuation, byte_offset + byte_limit for byte continuation, or query + match_index + match_limit to search matching lines. The returned next_offset/next_match fields are ready for the next call; output_id cannot address arbitrary paths.",
224 )
225 }
226
227 fn input_schema(&self) -> serde_json::Value {
228 serde_json::json!({
229 "type": "object",
230 "properties": {
231 "output_id": {"type": "string", "description": "Opaque ID returned by a truncated tool result."},
232 "query": {"type": "string", "description": "Literal text to search for. Returns matching 1-based line numbers and snippets."},
233 "match_index": {"type": "integer", "minimum": 0, "description": "Zero-based matching-line offset for search pagination."},
234 "match_limit": {"type": "integer", "minimum": 1, "description": "Maximum matching lines to return."},
235 "line_offset": {"type": "integer", "minimum": 0, "description": "Zero-based line offset."},
236 "line_limit": {"type": "integer", "minimum": 1, "description": "Maximum lines to return."},
237 "byte_offset": {"type": "integer", "minimum": 0, "description": "Zero-based UTF-8 byte offset."},
238 "byte_limit": {"type": "integer", "minimum": 1, "description": "Maximum bytes to return."}
239 },
240 "required": ["output_id"]
241 })
242 }
243
244 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
245 Box::pin(async move {
246 let output_id = string_arg(&args, "output_id")?;
247 let store = ctx.output_store.as_ref().ok_or_else(|| {
248 RuntimeError::ToolFailed("output.read: no session output store available".into())
249 })?;
250 let query = optional_string(&args, "query")?;
251 let match_index = optional_usize(&args, "match_index")?.unwrap_or(0);
252 let match_limit = optional_positive_usize(&args, "match_limit")?
253 .unwrap_or(ctx.tool_output_budget.max_lines);
254 let line_offset = optional_usize(&args, "line_offset")?;
255 let line_limit = optional_positive_usize(&args, "line_limit")?;
256 let byte_offset = optional_usize(&args, "byte_offset")?;
257 let byte_limit = optional_positive_usize(&args, "byte_limit")?;
258 let uses_lines = line_offset.is_some() || line_limit.is_some();
259 let uses_bytes = byte_offset.is_some() || byte_limit.is_some();
260 if query.is_some() && (uses_lines || uses_bytes) {
261 return Err(RuntimeError::ToolFailed(
262 "output.read: choose search, line pagination, or byte pagination".into(),
263 ));
264 }
265 if uses_lines && uses_bytes {
266 return Err(RuntimeError::ToolFailed(
267 "output.read: choose line pagination or byte pagination, not both".into(),
268 ));
269 }
270 if let Some(query) = query {
271 return Ok(output_search_value(store.search(
272 &output_id,
273 &query,
274 match_index,
275 match_limit,
276 )?));
277 }
278 let page = if uses_bytes {
279 store.read_bytes(
280 &output_id,
281 byte_offset.unwrap_or(0),
282 byte_limit.unwrap_or(ctx.tool_output_budget.max_bytes),
283 ctx.tool_output_budget,
284 )?
285 } else {
286 store.read_lines(
287 &output_id,
288 line_offset.unwrap_or(0),
289 line_limit.unwrap_or(ctx.tool_output_budget.max_lines),
290 ctx.tool_output_budget,
291 )?
292 };
293 Ok(output_page_value(page))
294 })
295 }
296}
297
298fn optional_string(args: &ToolArgs, name: &str) -> Result<Option<String>, RuntimeError> {
299 match args.named(name) {
300 Some(Value::Str(value)) => Ok(Some(value.clone())),
301 Some(Value::Unit) | None => Ok(None),
302 Some(value) => Err(RuntimeError::TypeMismatch {
303 expected: format!("string {name}"),
304 actual: value.kind_name().into(),
305 }),
306 }
307}
308
309fn string_arg(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
310 match args.named(name) {
311 Some(Value::Str(value)) => Ok(value.clone()),
312 Some(value) => Err(RuntimeError::TypeMismatch {
313 expected: format!("string {name}"),
314 actual: value.kind_name().into(),
315 }),
316 None => Err(RuntimeError::MissingArg(name.into())),
317 }
318}
319
320fn optional_usize(args: &ToolArgs, name: &str) -> Result<Option<usize>, RuntimeError> {
321 match args.named(name) {
322 Some(Value::Int(value)) if *value >= 0 => Ok(Some(*value as usize)),
323 Some(Value::Unit) | None => Ok(None),
324 Some(value) => Err(RuntimeError::TypeMismatch {
325 expected: format!("non-negative integer {name}"),
326 actual: value.kind_name().into(),
327 }),
328 }
329}
330
331fn optional_positive_usize(args: &ToolArgs, name: &str) -> Result<Option<usize>, RuntimeError> {
332 match args.named(name) {
333 Some(Value::Int(value)) if *value > 0 => Ok(Some(*value as usize)),
334 Some(Value::Unit) | None => Ok(None),
335 Some(value) => Err(RuntimeError::TypeMismatch {
336 expected: format!("positive integer {name}"),
337 actual: value.kind_name().into(),
338 }),
339 }
340}
341
342fn output_search_value(result: OutputSearchResult) -> Value {
343 Value::Struct(vec![
344 ("query".into(), Value::Str(result.query)),
345 (
346 "total_matches".into(),
347 Value::Int(result.total_matches as i64),
348 ),
349 (
350 "hits".into(),
351 Value::List(
352 result
353 .hits
354 .into_iter()
355 .map(|hit| {
356 Value::Struct(vec![
357 ("line".into(), Value::Int(hit.line as i64)),
358 ("snippet".into(), Value::Str(hit.snippet)),
359 ])
360 })
361 .collect(),
362 ),
363 ),
364 ("has_more".into(), Value::Bool(result.has_more)),
365 ("next_match".into(), Value::Int(result.next_match as i64)),
366 ])
367}
368
369fn output_page_value(page: OutputPage) -> Value {
370 Value::Struct(vec![
371 ("content".into(), Value::Str(page.content)),
372 ("mode".into(), Value::Str(page.mode.into())),
373 ("offset".into(), Value::Int(page.offset as i64)),
374 ("next_offset".into(), Value::Int(page.next_offset as i64)),
375 ("total_lines".into(), Value::Int(page.total_lines as i64)),
376 ("total_bytes".into(), Value::Int(page.total_bytes as i64)),
377 ("has_more".into(), Value::Bool(page.has_more)),
378 ])
379}
380
381pub const MAX_TOOL_RESULT_CHARS: usize = 25_000;
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub struct ToolOutputBudget {
385 pub max_lines: usize,
386 pub max_bytes: usize,
387 pub max_line_bytes: usize,
388}
389
390impl Default for ToolOutputBudget {
391 fn default() -> Self {
392 Self {
393 max_lines: 256,
394 max_bytes: 10 * 1024,
395 max_line_bytes: 10 * 1024,
396 }
397 }
398}
399
400pub fn truncate_tool_result_content(
401 content: &str,
402 label: &str,
403 output_store: Option<&OutputStore>,
404) -> String {
405 truncate_tool_result_content_with_budget(
406 content,
407 label,
408 output_store,
409 ToolOutputBudget {
410 max_lines: usize::MAX,
411 max_bytes: MAX_TOOL_RESULT_CHARS,
412 max_line_bytes: MAX_TOOL_RESULT_CHARS,
413 },
414 )
415}
416
417pub fn truncate_tool_result_content_with_budget(
418 content: &str,
419 label: &str,
420 output_store: Option<&OutputStore>,
421 budget: ToolOutputBudget,
422) -> String {
423 if is_output_read_result(content)
424 || is_live_pagination_envelope(content, output_store)
425 || is_live_pagination_notice(content, output_store)
426 {
427 return content.to_string();
428 }
429
430 let cut = bounded_prefix_len(content, budget);
431 if cut == content.len() {
432 return content.to_string();
433 }
434
435 let total = content.len();
436 let head = &content[..cut];
437 let output_id = output_store.and_then(|store| store.register(label, content));
438
439 match output_id {
440 Some(output_id) => format!(
441 "{head}\n\n[Output truncated: total_bytes={total}, output_id={output_id}. Continue with exactly: output.read(output_id: {output_id}, line_offset: 0, line_limit: 100). For targeted lookup use: output.read(output_id: {output_id}, query: \"text\", match_limit: 20). For byte paging use: output.read(output_id: {output_id}, byte_offset: 0, byte_limit: {max_bytes}).]",
442 max_bytes = budget.max_bytes,
443 total = total,
444 ),
445 None => format!(
446 "{head}\n\n[Output truncated at configured budget: max_lines={max_lines}, max_bytes={max_bytes}, max_line_bytes={max_line_bytes}, total_bytes={total}. No output_id is available because the session output could not be written.]",
447 max_lines = budget.max_lines,
448 max_bytes = budget.max_bytes,
449 max_line_bytes = budget.max_line_bytes,
450 total = total,
451 ),
452 }
453}
454
455fn is_output_read_result(content: &str) -> bool {
456 let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
457 return false;
458 };
459 let Some(object) = value.as_object() else {
460 return false;
461 };
462 if let Some(mode) = object.get("mode").and_then(serde_json::Value::as_str) {
463 return matches!(mode, "bytes" | "lines")
464 && object
465 .get("content")
466 .is_some_and(serde_json::Value::is_string)
467 && ["offset", "next_offset", "total_lines", "total_bytes"]
468 .iter()
469 .all(|field| object.get(*field).is_some_and(serde_json::Value::is_u64))
470 && object
471 .get("has_more")
472 .is_some_and(serde_json::Value::is_boolean);
473 }
474 object
475 .get("query")
476 .is_some_and(serde_json::Value::is_string)
477 && object
478 .get("total_matches")
479 .is_some_and(serde_json::Value::is_u64)
480 && object.get("hits").is_some_and(serde_json::Value::is_array)
481 && object
482 .get("has_more")
483 .is_some_and(serde_json::Value::is_boolean)
484 && object
485 .get("next_match")
486 .is_some_and(serde_json::Value::is_u64)
487}
488
489fn is_live_pagination_notice(content: &str, output_store: Option<&OutputStore>) -> bool {
490 let Some(store) = output_store else {
491 return false;
492 };
493 let notice = content
494 .rsplit_once("\n\n[Output truncated at configured budget:")
495 .map(|(_, notice)| notice)
496 .or_else(|| {
497 content
498 .rsplit_once("\n\n[Output truncated:")
499 .map(|(_, notice)| notice)
500 });
501 let Some(notice) = notice else {
502 return false;
503 };
504 if !notice.ends_with("]") {
505 return false;
506 }
507 let Some(total_bytes) = notice
508 .split_once("total_bytes=")
509 .and_then(|(_, value)| value.split([',', '.']).next())
510 .and_then(|value| value.trim().parse::<usize>().ok())
511 else {
512 return false;
513 };
514 let Some(output_id) = notice
515 .split_once("output_id=")
516 .and_then(|(_, value)| value.split([',', '.']).next())
517 .map(str::trim)
518 .filter(|value| !value.is_empty())
519 else {
520 return false;
521 };
522 store.validates_total_bytes(output_id, total_bytes)
523}
524
525fn is_live_pagination_envelope(content: &str, output_store: Option<&OutputStore>) -> bool {
526 let Some(store) = output_store else {
527 return false;
528 };
529 let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
530 return false;
531 };
532 let Some(object) = value.as_object() else {
533 return false;
534 };
535 let Some(output_id) = object.get("output_id").and_then(serde_json::Value::as_str) else {
536 return false;
537 };
538 if object
539 .get("content")
540 .and_then(serde_json::Value::as_str)
541 .is_none()
542 {
543 return false;
544 }
545 let Some(total_bytes) = object
546 .get("total_bytes")
547 .and_then(serde_json::Value::as_u64)
548 .and_then(|value| usize::try_from(value).ok())
549 else {
550 return false;
551 };
552 let Some(next) = object.get("next").and_then(serde_json::Value::as_object) else {
553 return false;
554 };
555 let Some(mode) = next.get("mode").and_then(serde_json::Value::as_str) else {
556 return false;
557 };
558 let Some(offset) = next
559 .get("offset")
560 .and_then(serde_json::Value::as_u64)
561 .and_then(|value| usize::try_from(value).ok())
562 else {
563 return false;
564 };
565 let Some(has_more) = next.get("has_more").and_then(serde_json::Value::as_bool) else {
566 return false;
567 };
568 let total_lines = object
569 .get("total_lines")
570 .and_then(serde_json::Value::as_u64)
571 .and_then(|value| usize::try_from(value).ok());
572 store.validates_pagination(output_id, mode, offset, total_bytes, total_lines, has_more)
573}
574
575fn output_dir(session_dir: &Path) -> PathBuf {
576 session_dir.join("tool_outputs")
577}
578
579fn write_output_file(session_dir: &Path, output_id: &str, content: &str) -> Option<()> {
580 let out_dir = output_dir(session_dir);
581 std::fs::create_dir_all(&out_dir).ok()?;
582 let path = out_dir.join(format!("{output_id}.txt"));
583 let mut file = std::fs::OpenOptions::new()
584 .write(true)
585 .create_new(true)
586 .open(&path)
587 .ok()?;
588 if std::io::Write::write_all(&mut file, content.as_bytes()).is_ok() && file.sync_all().is_ok() {
589 return Some(());
590 }
591 drop(file);
592 let _ = std::fs::remove_file(path);
593 None
594}
595
596pub fn bounded_text_prefix(content: &str, budget: ToolOutputBudget) -> usize {
597 bounded_prefix_len(content, budget)
598}
599
600fn bounded_prefix_len(content: &str, budget: ToolOutputBudget) -> usize {
601 let mut lines = 1usize;
602 let mut line_bytes = 0usize;
603 let mut used = 0usize;
604 for (index, ch) in content.char_indices() {
605 let width = ch.len_utf8();
606 if ch == '\n' {
607 if used + width > budget.max_bytes {
608 return index;
609 }
610 used += width;
611 if lines >= budget.max_lines {
612 return index + width;
613 }
614 lines += 1;
615 line_bytes = 0;
616 continue;
617 }
618 if line_bytes + width > budget.max_line_bytes || used + width > budget.max_bytes {
619 return index;
620 }
621 line_bytes += width;
622 used += width;
623 }
624 content.len()
625}
626
627pub fn truncate_tool_results_in_message(
628 msg: &Message,
629 output_store: Option<&OutputStore>,
630) -> Option<Message> {
631 truncate_tool_results_in_message_with_budget(
632 msg,
633 output_store,
634 ToolOutputBudget {
635 max_lines: usize::MAX,
636 max_bytes: MAX_TOOL_RESULT_CHARS,
637 max_line_bytes: MAX_TOOL_RESULT_CHARS,
638 },
639 )
640}
641
642pub fn truncate_tool_results_in_message_with_budget(
643 msg: &Message,
644 output_store: Option<&OutputStore>,
645 budget: ToolOutputBudget,
646) -> Option<Message> {
647 let mut changed = false;
648 let parts: Vec<MessagePart> = msg
649 .parts
650 .iter()
651 .map(|part| match part {
652 MessagePart::ToolResult {
653 tool_use_id,
654 content,
655 is_error,
656 } => {
657 let truncated = truncate_tool_result_content_with_budget(
658 content,
659 tool_use_id,
660 output_store,
661 budget,
662 );
663 if truncated.len() != content.len() || truncated != *content {
664 changed = true;
665 MessagePart::ToolResult {
666 tool_use_id: tool_use_id.clone(),
667 content: truncated,
668 is_error: *is_error,
669 }
670 } else {
671 part.clone()
672 }
673 }
674 _ => part.clone(),
675 })
676 .collect();
677
678 if !changed {
679 return None;
680 }
681 Some(Message {
682 role: msg.role,
683 parts,
684 turn_id: msg.turn_id.clone(),
685 origin: MessageOrigin::User,
686 })
687}
688
689pub fn maybe_truncate_tool_message(msg: &Message, output_store: Option<&OutputStore>) -> Message {
690 maybe_truncate_tool_message_with_budget(
691 msg,
692 output_store,
693 ToolOutputBudget {
694 max_lines: usize::MAX,
695 max_bytes: MAX_TOOL_RESULT_CHARS,
696 max_line_bytes: MAX_TOOL_RESULT_CHARS,
697 },
698 )
699}
700
701pub fn maybe_truncate_tool_message_with_budget(
702 msg: &Message,
703 output_store: Option<&OutputStore>,
704 budget: ToolOutputBudget,
705) -> Message {
706 if !matches!(msg.role, MessageRole::Tool) {
707 return msg.clone();
708 }
709 truncate_tool_results_in_message_with_budget(msg, output_store, budget)
710 .unwrap_or_else(|| msg.clone())
711}
712
713pub fn spill_dir(session_dir: &Path) -> PathBuf {
714 session_dir.join("tool_outputs")
715}
716
717#[cfg(test)]
718mod output_store_tests {
719 use super::*;
720 use tempfile::TempDir;
721
722 fn budget() -> ToolOutputBudget {
723 ToolOutputBudget {
724 max_lines: 2,
725 max_bytes: 32,
726 max_line_bytes: 16,
727 }
728 }
729
730 fn id(notice: &str) -> String {
731 notice
732 .split_once("output_id=")
733 .unwrap()
734 .1
735 .split([',', '.'])
736 .next()
737 .unwrap()
738 .to_string()
739 }
740
741 #[test]
742 fn output_page_keeps_structure_without_nested_spill() {
743 let content = "x".repeat(budget().max_bytes);
744 let page = serde_json::json!({
745 "content": content,
746 "mode": "bytes",
747 "offset": 0,
748 "next_offset": 64,
749 "total_lines": 1,
750 "total_bytes": 64,
751 "has_more": false,
752 })
753 .to_string();
754 let result = truncate_tool_result_content_with_budget(&page, "output.read", None, budget());
755 assert_eq!(result, page);
756 assert!(!result.contains("Output truncated"));
757 assert!(is_output_read_result(&result));
758 }
759
760 #[test]
761 fn output_search_keeps_structure_without_nested_spill() {
762 let result = serde_json::json!({
763 "query": "needle",
764 "total_matches": 1,
765 "hits": [{"line": 1, "snippet": "x".repeat(budget().max_bytes)}],
766 "has_more": false,
767 "next_match": 1,
768 })
769 .to_string();
770 let unchanged =
771 truncate_tool_result_content_with_budget(&result, "output.read", None, budget());
772 assert_eq!(unchanged, result);
773 assert!(!unchanged.contains("Output truncated"));
774 assert!(is_output_read_result(&unchanged));
775 }
776
777 #[test]
778 fn opaque_id_hides_path_and_survives_reopen() {
779 let dir = TempDir::new().unwrap();
780 let store = OutputStore::at(dir.path());
781 let notice = truncate_tool_result_content_with_budget(
782 "one\ntwo\nthree\n",
783 "x",
784 Some(&store),
785 budget(),
786 );
787 let output_id = id(¬ice);
788 assert!(!notice.contains(dir.path().to_string_lossy().as_ref()));
789 assert!(!notice.contains("fs.read"));
790 let page = OutputStore::at(dir.path())
791 .read_lines(&output_id, 1, 1, ToolOutputBudget::default())
792 .unwrap();
793 assert_eq!(page.content, "two\n");
794 assert_eq!(page.next_offset, 2);
795 }
796
797 #[test]
798 fn continuous_line_and_byte_reads_reconstruct_full_output() {
799 let dir = TempDir::new().unwrap();
800 let long_line = "前缀🚀".repeat(4096);
801 let full = format!("第一行\n{long_line}\n最后一行\n");
802 let store = OutputStore::at(dir.path());
803 let output_id = store.register("continuous", &full).unwrap();
804
805 let first = store
806 .read_lines(&output_id, 0, 1, ToolOutputBudget::default())
807 .unwrap();
808 assert_eq!(first.content, "第一行\n");
809 let long_line_error = store.read_lines(
810 &output_id,
811 first.next_offset,
812 1,
813 ToolOutputBudget {
814 max_lines: 1,
815 max_bytes: 8192,
816 max_line_bytes: 8192,
817 },
818 );
819 assert!(long_line_error.is_err());
820
821 let mut byte_offset = 0;
822 let mut bytes = String::new();
823 loop {
824 let page = store
825 .read_bytes(
826 &output_id,
827 byte_offset,
828 257,
829 ToolOutputBudget {
830 max_lines: 100,
831 max_bytes: 257,
832 max_line_bytes: 257,
833 },
834 )
835 .unwrap();
836 assert!(page.next_offset > byte_offset || !page.has_more);
837 bytes.push_str(&page.content);
838 byte_offset = page.next_offset;
839 if !page.has_more {
840 break;
841 }
842 }
843 assert_eq!(bytes, full);
844 }
845
846 #[test]
847 fn ids_are_session_scoped_and_ranges_are_exact() {
848 let left = TempDir::new().unwrap();
849 let right = TempDir::new().unwrap();
850 let store = OutputStore::at(left.path());
851 let output_id = store.register("x", "one\ntwo\nthree").unwrap();
852 assert!(
853 OutputStore::at(right.path())
854 .read_bytes(&output_id, 0, 8, ToolOutputBudget::default())
855 .is_err()
856 );
857 let page = store
858 .read_bytes(&output_id, 4, 4, ToolOutputBudget::default())
859 .unwrap();
860 assert_eq!(page.content, "two\n");
861 assert_eq!(page.next_offset, 8);
862 }
863
864 #[test]
865 fn valid_pagination_envelope_is_preserved_only_for_readable_output() {
866 let dir = TempDir::new().unwrap();
867 let store = OutputStore::at(dir.path());
868 let output_id = store.register("x", "完整内容").unwrap();
869 let envelope = serde_json::json!({
870 "content": "完整",
871 "output_id": output_id,
872 "total_bytes": "完整内容".len(),
873 "next": {"mode": "bytes", "offset": 12, "has_more": false}
874 })
875 .to_string();
876 assert_eq!(
877 truncate_tool_result_content_with_budget(&envelope, "x", Some(&store), budget()),
878 envelope
879 );
880
881 let unrelated_id = store.register("unrelated", "另一个注册输出").unwrap();
882 let forged = serde_json::json!({
883 "content": "完整",
884 "output_id": unrelated_id,
885 "total_bytes": "完整内容".len() + 1,
886 "next": {"mode": "bytes", "offset": 12, "has_more": false}
887 })
888 .to_string();
889 let truncated =
890 truncate_tool_result_content_with_budget(&forged, "x", Some(&store), budget());
891 assert!(truncated.contains("Output truncated"));
892 assert_ne!(truncated, forged);
893 }
894
895 #[test]
896 fn repeated_truncation_keeps_one_opaque_output() {
897 let dir = TempDir::new().unwrap();
898 let store = OutputStore::at(dir.path());
899 let original = "x".repeat(100);
900 let first =
901 truncate_tool_result_content_with_budget(&original, "x", Some(&store), budget());
902 assert!(first.contains("output_id=out_"));
903 assert!(first.contains("output.read(output_id:"));
904 assert!(first.contains("query:"));
905 let second = truncate_tool_result_content_with_budget(&first, "x", Some(&store), budget());
906 assert_eq!(first, second);
907 assert_eq!(std::fs::read_dir(spill_dir(dir.path())).unwrap().count(), 1);
908
909 let forged = first.replace("total_bytes=100", "total_bytes=101");
910 let retruncated =
911 truncate_tool_result_content_with_budget(&forged, "x", Some(&store), budget());
912 assert_ne!(retruncated, forged);
913 assert_eq!(std::fs::read_dir(spill_dir(dir.path())).unwrap().count(), 2);
914 }
915
916 #[test]
917 fn search_returns_paginated_one_based_hits() {
918 let dir = TempDir::new().unwrap();
919 let store = OutputStore::at(dir.path());
920 let output_id = store
921 .register("x", "zero\nneedle one\nneedle two\nend")
922 .unwrap();
923
924 let first = store.search(&output_id, "needle", 0, 1).unwrap();
925 assert_eq!(first.total_matches, 2);
926 assert_eq!(first.hits[0].line, 2);
927 assert!(first.has_more);
928 assert_eq!(first.next_match, 1);
929
930 let second = store
931 .search(&output_id, "needle", first.next_match, 1)
932 .unwrap();
933 assert_eq!(second.hits[0].line, 3);
934 assert!(!second.has_more);
935 }
936
937 fn field<'a>(fields: &'a [(String, Value)], name: &str) -> &'a Value {
938 fields
939 .iter()
940 .find_map(|(field, value)| (field == name).then_some(value))
941 .unwrap_or_else(|| panic!("missing {name}"))
942 }
943
944 #[tokio::test]
945 async fn output_read_tool_returns_exact_byte_and_line_envelopes() {
946 let dir = TempDir::new().unwrap();
947 let store = Arc::new(OutputStore::at(dir.path()));
948 let output_id = store.register("x", "one\ntwo\nthree").unwrap();
949 let ctx = ToolCtx::new().with_output_store(store);
950
951 let bytes = OutputRead
952 .call(
953 ToolArgs {
954 positional: vec![],
955 named: vec![
956 ("output_id".into(), Value::Str(output_id.clone())),
957 ("byte_offset".into(), Value::Int(4)),
958 ("byte_limit".into(), Value::Int(4)),
959 ],
960 },
961 &ctx,
962 )
963 .await
964 .unwrap();
965 let Value::Struct(bytes) = bytes else {
966 panic!("expected byte page");
967 };
968 assert!(matches!(field(&bytes, "content"), Value::Str(value) if value == "two\n"));
969 assert!(matches!(field(&bytes, "mode"), Value::Str(value) if value == "bytes"));
970 assert!(matches!(field(&bytes, "offset"), Value::Int(4)));
971 assert!(matches!(field(&bytes, "next_offset"), Value::Int(8)));
972 assert!(matches!(field(&bytes, "has_more"), Value::Bool(true)));
973
974 let lines = OutputRead
975 .call(
976 ToolArgs {
977 positional: vec![],
978 named: vec![
979 ("output_id".into(), Value::Str(output_id)),
980 ("line_offset".into(), Value::Int(1)),
981 ("line_limit".into(), Value::Int(1)),
982 ],
983 },
984 &ctx,
985 )
986 .await
987 .unwrap();
988 let Value::Struct(lines) = lines else {
989 panic!("expected line page");
990 };
991 assert!(matches!(field(&lines, "content"), Value::Str(value) if value == "two\n"));
992 assert!(matches!(field(&lines, "mode"), Value::Str(value) if value == "lines"));
993 assert!(matches!(field(&lines, "offset"), Value::Int(1)));
994 assert!(matches!(field(&lines, "next_offset"), Value::Int(2)));
995 assert!(matches!(field(&lines, "has_more"), Value::Bool(true)));
996 }
997
998 #[tokio::test]
999 async fn output_read_tool_rejects_non_progressing_and_mixed_pagination() {
1000 let dir = TempDir::new().unwrap();
1001 let store = Arc::new(OutputStore::at(dir.path()));
1002 let output_id = store.register("x", "content").unwrap();
1003 let ctx = ToolCtx::new().with_output_store(store);
1004
1005 for limit in ["line_limit", "byte_limit"] {
1006 let error = OutputRead
1007 .call(
1008 ToolArgs {
1009 positional: vec![],
1010 named: vec![
1011 ("output_id".into(), Value::Str(output_id.clone())),
1012 (limit.into(), Value::Int(0)),
1013 ],
1014 },
1015 &ctx,
1016 )
1017 .await
1018 .unwrap_err();
1019 assert!(matches!(
1020 error,
1021 RuntimeError::TypeMismatch { expected, .. }
1022 if expected == format!("positive integer {limit}")
1023 ));
1024 }
1025
1026 let error = OutputRead
1027 .call(
1028 ToolArgs {
1029 positional: vec![],
1030 named: vec![
1031 ("output_id".into(), Value::Str(output_id)),
1032 ("line_offset".into(), Value::Int(0)),
1033 ("byte_offset".into(), Value::Int(0)),
1034 ],
1035 },
1036 &ctx,
1037 )
1038 .await
1039 .unwrap_err();
1040 assert!(matches!(
1041 error,
1042 RuntimeError::ToolFailed(message) if message.contains("not both")
1043 ));
1044 }
1045
1046 #[tokio::test]
1047 async fn output_read_tool_search_returns_line_hits() {
1048 let dir = TempDir::new().unwrap();
1049 let store = Arc::new(OutputStore::at(dir.path()));
1050 let output_id = store.register("x", "zero\nneedle\nend").unwrap();
1051 let ctx = ToolCtx::new().with_output_store(store);
1052
1053 let result = OutputRead
1054 .call(
1055 ToolArgs {
1056 positional: vec![],
1057 named: vec![
1058 ("output_id".into(), Value::Str(output_id)),
1059 ("query".into(), Value::Str("needle".into())),
1060 ],
1061 },
1062 &ctx,
1063 )
1064 .await
1065 .unwrap();
1066 let Value::Struct(fields) = result else {
1067 panic!("expected search result");
1068 };
1069 assert!(matches!(field(&fields, "total_matches"), Value::Int(1)));
1070 assert!(matches!(field(&fields, "next_match"), Value::Int(1)));
1071 let Value::List(hits) = field(&fields, "hits") else {
1072 panic!("expected hits");
1073 };
1074 let Value::Struct(hit) = &hits[0] else {
1075 panic!("expected hit");
1076 };
1077 assert!(matches!(field(hit, "line"), Value::Int(2)));
1078 }
1079
1080 #[test]
1081 fn output_read_is_registered_in_tier_zero() {
1082 let registry = crate::tool::ToolRegistry::new();
1083 crate::tools::register_tier_zero(®istry);
1084 let tool = registry.get("output.read").expect("registered output.read");
1085 assert_eq!(tool.tier(), Tier::Zero);
1086 }
1087}