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