Skip to main content

atman_runtime/tools/
tool_output.rs

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(&notice);
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(&registry);
1080        let tool = registry.get("output.read").expect("registered output.read");
1081        assert_eq!(tool.tier(), Tier::Zero);
1082    }
1083}