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