Skip to main content

lash_core/plugin/
tool_result_projection_builtin.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use sha2::{Digest, Sha256};
6
7use crate::plugin::{
8    PluginError, PluginFactory, PluginRegistrar, PluginSessionContext, SessionPlugin,
9    ToolResultProjectionContext,
10};
11use crate::{ModelToolReturn, ModelToolReturnPart, ToolCallOutcome, ToolValue};
12
13const APPROX_BYTES_PER_TOKEN: usize = 4;
14pub const DEFAULT_TOOL_OUTPUT_BUDGET_LIMIT_BYTES: usize = 16 * 1024;
15pub const DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES: usize = 400;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ToolOutputBudgetMode {
20    Bytes,
21    Tokens,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(default)]
26pub struct ToolOutputBudgetConfig {
27    pub mode: ToolOutputBudgetMode,
28    pub limit: usize,
29    pub max_lines: usize,
30}
31
32impl Default for ToolOutputBudgetConfig {
33    fn default() -> Self {
34        Self {
35            mode: ToolOutputBudgetMode::Bytes,
36            limit: DEFAULT_TOOL_OUTPUT_BUDGET_LIMIT_BYTES,
37            max_lines: DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES,
38        }
39    }
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43enum ProjectionDirection {
44    Head,
45    Tail,
46}
47
48pub struct ToolOutputBudgetPluginFactory {
49    config: ToolOutputBudgetConfig,
50}
51
52impl ToolOutputBudgetPluginFactory {
53    pub fn new(config: ToolOutputBudgetConfig) -> Self {
54        Self { config }
55    }
56}
57
58impl Default for ToolOutputBudgetPluginFactory {
59    fn default() -> Self {
60        Self::new(ToolOutputBudgetConfig::default())
61    }
62}
63
64impl PluginFactory for ToolOutputBudgetPluginFactory {
65    fn id(&self) -> &'static str {
66        "tool_output_budget"
67    }
68
69    fn build(&self, _ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
70        Ok(Arc::new(ToolOutputBudgetPlugin {
71            config: self.config.clone(),
72        }))
73    }
74}
75
76struct ToolOutputBudgetPlugin {
77    config: ToolOutputBudgetConfig,
78}
79
80impl SessionPlugin for ToolOutputBudgetPlugin {
81    fn id(&self) -> &'static str {
82        "tool_output_budget"
83    }
84
85    fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
86        register_projector(reg, &self.config)
87    }
88}
89
90fn register_projector(
91    reg: &mut PluginRegistrar,
92    config: &ToolOutputBudgetConfig,
93) -> Result<(), PluginError> {
94    let config = config.clone();
95    reg.tool_results().projector(Arc::new(move |ctx| {
96        let config = config.clone();
97        Box::pin(async move { Ok(project_tool_result(&config, ctx)) })
98    }))
99}
100
101fn project_tool_result(
102    config: &ToolOutputBudgetConfig,
103    ctx: ToolResultProjectionContext,
104) -> ModelToolReturn {
105    let parts = project_model_parts(config, &ctx);
106    ModelToolReturn {
107        call_id: ctx.call_id.clone(),
108        tool_name: ctx.tool_name.clone(),
109        parts,
110    }
111}
112
113fn project_model_parts(
114    config: &ToolOutputBudgetConfig,
115    ctx: &ToolResultProjectionContext,
116) -> Vec<ModelToolReturnPart> {
117    if ctx.tool_name == "batch" {
118        let value = project_batch_value(config, ctx);
119        return vec![ModelToolReturnPart::Text(render_projected_model_value(
120            &value,
121        ))];
122    }
123
124    match &ctx.output.outcome {
125        ToolCallOutcome::Success(value) => project_tool_value_parts(config, ctx, value),
126        ToolCallOutcome::Failure(failure) => {
127            let mut parts = vec![ModelToolReturnPart::Text(
128                crate::session_model::format_tool_output_content(&ctx.output),
129            )];
130            if let Some(raw) = &failure.raw {
131                parts.extend(
132                    raw.attachments()
133                        .into_iter()
134                        .map(ModelToolReturnPart::Attachment),
135                );
136            }
137            parts
138        }
139        ToolCallOutcome::Cancelled(cancellation) => {
140            let mut parts = vec![ModelToolReturnPart::Text(
141                crate::session_model::format_tool_output_content(&ctx.output),
142            )];
143            if let Some(raw) = &cancellation.raw {
144                parts.extend(
145                    raw.attachments()
146                        .into_iter()
147                        .map(ModelToolReturnPart::Attachment),
148                );
149            }
150            parts
151        }
152    }
153}
154
155fn render_projected_model_value(value: &serde_json::Value) -> String {
156    match value {
157        serde_json::Value::String(text) => text.clone(),
158        other => serde_json::to_string(other).unwrap_or_else(|_| "null".to_string()),
159    }
160}
161
162fn project_tool_value_parts(
163    config: &ToolOutputBudgetConfig,
164    ctx: &ToolResultProjectionContext,
165    value: &ToolValue,
166) -> Vec<ModelToolReturnPart> {
167    let mut parts = Vec::new();
168    match value {
169        ToolValue::String(text) => {
170            parts.push(ModelToolReturnPart::Text(project_text(text, config, ctx)))
171        }
172        ToolValue::Attachment(reference) => {
173            parts.push(ModelToolReturnPart::Attachment(reference.clone()));
174        }
175        ToolValue::Null
176        | ToolValue::Bool(_)
177        | ToolValue::Number(_)
178        | ToolValue::Array(_)
179        | ToolValue::Object(_) => {
180            push_projected_tool_value_parts(value, &mut parts, config, ctx);
181        }
182    }
183    parts
184}
185
186fn push_projected_tool_value_parts(
187    value: &ToolValue,
188    parts: &mut Vec<ModelToolReturnPart>,
189    config: &ToolOutputBudgetConfig,
190    ctx: &ToolResultProjectionContext,
191) {
192    match value {
193        ToolValue::Null => push_text_part(parts, "null"),
194        ToolValue::Bool(value) => push_text_part(parts, value.to_string()),
195        ToolValue::Number(value) => push_text_part(parts, value.to_string()),
196        ToolValue::String(text) => push_text_part(
197            parts,
198            serde_json::to_string(&project_text(text, config, ctx))
199                .unwrap_or_else(|_| "\"\"".to_string()),
200        ),
201        ToolValue::Attachment(reference) => {
202            parts.push(ModelToolReturnPart::Attachment(reference.clone()));
203        }
204        ToolValue::Array(items) => {
205            push_text_part(parts, "[");
206            for (index, item) in items.iter().enumerate() {
207                if index > 0 {
208                    push_text_part(parts, ",");
209                }
210                push_projected_tool_value_parts(item, parts, config, ctx);
211            }
212            push_text_part(parts, "]");
213        }
214        ToolValue::Object(map) => {
215            push_text_part(parts, "{");
216            for (index, (key, value)) in map.iter().enumerate() {
217                if index > 0 {
218                    push_text_part(parts, ",");
219                }
220                push_text_part(
221                    parts,
222                    serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string()),
223                );
224                push_text_part(parts, ":");
225                push_projected_tool_value_parts(value, parts, config, ctx);
226            }
227            push_text_part(parts, "}");
228        }
229    }
230}
231
232fn push_text_part(parts: &mut Vec<ModelToolReturnPart>, text: impl Into<String>) {
233    let text = text.into();
234    if text.is_empty() {
235        return;
236    }
237    if let Some(ModelToolReturnPart::Text(existing)) = parts.last_mut() {
238        existing.push_str(&text);
239    } else {
240        parts.push(ModelToolReturnPart::Text(text));
241    }
242}
243
244fn project_text(
245    text: &str,
246    config: &ToolOutputBudgetConfig,
247    ctx: &ToolResultProjectionContext,
248) -> String {
249    if !needs_truncation(text, config) {
250        return text.to_string();
251    }
252    truncate_text(
253        text,
254        config,
255        tool_projection_direction(&ctx.tool_name),
256        Some(ctx),
257    )
258}
259
260fn needs_truncation(text: &str, config: &ToolOutputBudgetConfig) -> bool {
261    if text.lines().count() > config.max_lines {
262        return true;
263    }
264    match config.mode {
265        ToolOutputBudgetMode::Bytes => text.len() > config.limit,
266        ToolOutputBudgetMode::Tokens => approx_token_count(text) > config.limit,
267    }
268}
269
270pub fn truncate_observation_text(text: &str, config: &ToolOutputBudgetConfig) -> String {
271    if !needs_truncation(text, config) {
272        return text.to_string();
273    }
274    truncate_text_with_hint(
275        text,
276        config,
277        ProjectionDirection::Head,
278        observation_truncation_hint(text, config),
279    )
280}
281
282pub fn project_observation_text(
283    text: &str,
284    config: &ToolOutputBudgetConfig,
285) -> (String, crate::TextProjectionMetadata) {
286    let projected = truncate_observation_text(text, config);
287    let metadata = observation_projection_metadata(text, &projected, config);
288    (projected, metadata)
289}
290
291pub fn observation_projection_metadata(
292    original: &str,
293    projected: &str,
294    config: &ToolOutputBudgetConfig,
295) -> crate::TextProjectionMetadata {
296    let limit_mode = match config.mode {
297        ToolOutputBudgetMode::Bytes => "bytes",
298        ToolOutputBudgetMode::Tokens => "tokens",
299    };
300    crate::TextProjectionMetadata {
301        truncated: original != projected,
302        original_chars: original.chars().count(),
303        projected_chars: projected.chars().count(),
304        original_lines: original.lines().count(),
305        projected_lines: projected.lines().count(),
306        limit: config.limit,
307        limit_mode: limit_mode.to_string(),
308        max_lines: config.max_lines,
309    }
310}
311
312fn truncate_text(
313    text: &str,
314    config: &ToolOutputBudgetConfig,
315    direction: ProjectionDirection,
316    ctx: Option<&ToolResultProjectionContext>,
317) -> String {
318    truncate_text_with_hint(text, config, direction, truncation_hint(ctx, text))
319}
320
321fn truncate_text_with_hint(
322    text: &str,
323    config: &ToolOutputBudgetConfig,
324    direction: ProjectionDirection,
325    hint: String,
326) -> String {
327    if text.is_empty() {
328        return String::new();
329    }
330    let max_bytes = match config.mode {
331        ToolOutputBudgetMode::Bytes => config.limit,
332        ToolOutputBudgetMode::Tokens => approx_bytes_for_tokens(config.limit),
333    };
334    if max_bytes == 0 {
335        return format_truncation_marker(
336            config.mode,
337            removed_units(config.mode, text.len(), text.chars().count()),
338        );
339    }
340    if !needs_truncation(text, config) {
341        return text.to_string();
342    }
343    let lines: Vec<&str> = text.lines().collect();
344    let mut preview_lines = Vec::new();
345    let mut bytes = 0usize;
346    let mut hit_budget = false;
347
348    match direction {
349        ProjectionDirection::Head => {
350            for (idx, line) in lines.iter().enumerate().take(config.max_lines) {
351                let size = line.len() + usize::from(idx > 0);
352                if bytes + size > max_bytes {
353                    hit_budget = true;
354                    break;
355                }
356                preview_lines.push(*line);
357                bytes += size;
358            }
359        }
360        ProjectionDirection::Tail => {
361            for (idx, line) in lines.iter().rev().take(config.max_lines).enumerate() {
362                let size = line.len() + usize::from(idx > 0);
363                if bytes + size > max_bytes {
364                    hit_budget = true;
365                    break;
366                }
367                preview_lines.push(*line);
368                bytes += size;
369            }
370            preview_lines.reverse();
371        }
372    }
373
374    let preview = preview_lines.join("\n");
375    let removed = if hit_budget {
376        removed_units(
377            config.mode,
378            text.len().saturating_sub(bytes),
379            text.chars().count().saturating_sub(preview.chars().count()),
380        )
381    } else {
382        u64::try_from(lines.len().saturating_sub(preview_lines.len())).unwrap_or(u64::MAX)
383    };
384    let unit = if hit_budget {
385        match config.mode {
386            ToolOutputBudgetMode::Bytes => "bytes",
387            ToolOutputBudgetMode::Tokens => "tokens",
388        }
389    } else {
390        "lines"
391    };
392    match direction {
393        ProjectionDirection::Head => {
394            format!("{preview}\n\n...{removed} {unit} truncated...\n\n{hint}")
395        }
396        ProjectionDirection::Tail => {
397            format!("...{removed} {unit} truncated...\n\n{hint}\n\n{preview}")
398        }
399    }
400}
401
402fn format_truncation_marker(mode: ToolOutputBudgetMode, removed: u64) -> String {
403    match mode {
404        ToolOutputBudgetMode::Bytes => format!("…{removed} chars truncated…"),
405        ToolOutputBudgetMode::Tokens => format!("…{removed} tokens truncated…"),
406    }
407}
408
409fn removed_units(mode: ToolOutputBudgetMode, removed_bytes: usize, removed_chars: usize) -> u64 {
410    match mode {
411        ToolOutputBudgetMode::Bytes => u64::try_from(removed_chars).unwrap_or(u64::MAX),
412        ToolOutputBudgetMode::Tokens => approx_tokens_from_byte_count(removed_bytes),
413    }
414}
415
416fn approx_token_count(text: &str) -> usize {
417    text.len()
418        .saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1))
419        / APPROX_BYTES_PER_TOKEN
420}
421
422fn approx_bytes_for_tokens(tokens: usize) -> usize {
423    tokens.saturating_mul(APPROX_BYTES_PER_TOKEN)
424}
425
426fn approx_tokens_from_byte_count(bytes: usize) -> u64 {
427    let bytes = bytes as u64;
428    bytes.saturating_add((APPROX_BYTES_PER_TOKEN as u64).saturating_sub(1))
429        / (APPROX_BYTES_PER_TOKEN as u64)
430}
431
432fn tool_projection_direction(tool_name: &str) -> ProjectionDirection {
433    match tool_name {
434        "exec_command" | "write_stdin" => ProjectionDirection::Tail,
435        _ => ProjectionDirection::Head,
436    }
437}
438
439fn truncation_hint(ctx: Option<&ToolResultProjectionContext>, text: &str) -> String {
440    let output_path = ctx
441        .and_then(existing_tool_output_path)
442        .or_else(|| ctx.and_then(|ctx| spill_tool_output(&ctx.tool_name, &ctx.args, text)));
443    match output_path {
444        Some(path) => format!(
445            "The tool output was truncated. Full output saved to: {}\nUse `read_file` with `offset`/`limit` or `grep` to inspect specific sections instead of reading the whole file at once.",
446            path.display()
447        ),
448        None => "The tool output was truncated. Use `read_file` with `offset`/`limit` or `grep` to inspect specific sections instead of reading the whole file at once.".to_string(),
449    }
450}
451
452fn observation_truncation_hint(text: &str, config: &ToolOutputBudgetConfig) -> String {
453    let limit_unit = match config.mode {
454        ToolOutputBudgetMode::Bytes => "bytes",
455        ToolOutputBudgetMode::Tokens => "tokens",
456    };
457    let total_units = match config.mode {
458        ToolOutputBudgetMode::Bytes => text.len(),
459        ToolOutputBudgetMode::Tokens => approx_token_count(text),
460    };
461    let total_lines = text.lines().count();
462    format!(
463        "The print output was capped at {} {} and {} lines max; original size was {} {} across {} lines. Use a narrower `print` expression to inspect specific fields or slices instead of dumping the whole value at once.",
464        config.limit, limit_unit, config.max_lines, total_units, limit_unit, total_lines
465    )
466}
467
468fn existing_tool_output_path(ctx: &ToolResultProjectionContext) -> Option<PathBuf> {
469    ctx.output
470        .value_for_projection()
471        .get("full_output_path")
472        .and_then(|value| value.as_str())
473        .filter(|value| !value.trim().is_empty())
474        .map(PathBuf::from)
475}
476
477fn spill_tool_output(
478    tool_name: &str,
479    args: &serde_json::Value,
480    full_output: &str,
481) -> Option<PathBuf> {
482    let dir = std::env::temp_dir().join("lash-tool-output");
483    if fs::create_dir_all(&dir).is_err() {
484        return None;
485    }
486
487    let mut hasher = Sha256::new();
488    hasher.update(tool_name.as_bytes());
489    hasher.update(args.to_string().as_bytes());
490    hasher.update(full_output.as_bytes());
491    let digest = format!("{:x}", hasher.finalize());
492    let stem = tool_name
493        .chars()
494        .map(|ch| {
495            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
496                ch
497            } else {
498                '_'
499            }
500        })
501        .collect::<String>();
502    let path = dir.join(format!("{stem}-{}.txt", &digest[..12]));
503    if write_if_changed(&path, full_output).is_err() {
504        return None;
505    }
506    Some(path)
507}
508
509fn write_if_changed(path: &Path, content: &str) -> std::io::Result<()> {
510    let should_write = match fs::read_to_string(path) {
511        Ok(existing) => existing != content,
512        Err(_) => true,
513    };
514    if should_write {
515        fs::write(path, content)?;
516    }
517    Ok(())
518}
519
520fn project_batch_value(
521    config: &ToolOutputBudgetConfig,
522    ctx: &ToolResultProjectionContext,
523) -> serde_json::Value {
524    let value = ctx.output.value_for_projection();
525    let Some(map) = value.as_object() else {
526        return project_json_value(&value, config, ctx);
527    };
528
529    let mut projected = serde_json::Map::new();
530
531    let results = map
532        .get("results")
533        .and_then(|value| value.as_array())
534        .map(|items| {
535            items
536                .iter()
537                .enumerate()
538                .map(|(index, item)| project_batch_child_value(index, item, config, ctx))
539                .collect::<Vec<_>>()
540        })
541        .unwrap_or_default();
542    projected.insert("results".to_string(), serde_json::Value::Array(results));
543    serde_json::Value::Object(projected)
544}
545
546fn project_batch_child_value(
547    index: usize,
548    item: &serde_json::Value,
549    config: &ToolOutputBudgetConfig,
550    ctx: &ToolResultProjectionContext,
551) -> serde_json::Value {
552    let Some(map) = item.as_object() else {
553        return project_json_value(item, config, ctx);
554    };
555
556    let tool_name = map
557        .get("tool")
558        .and_then(|value| value.as_str())
559        .or_else(|| batch_child_tool_name(&ctx.args, index))
560        .unwrap_or("tool")
561        .to_string();
562    let success = map
563        .get("success")
564        .and_then(|value| value.as_bool())
565        .unwrap_or(false);
566    let duration_ms = map
567        .get("duration_ms")
568        .and_then(|value| value.as_u64())
569        .unwrap_or_default();
570    let child_value = if success {
571        map.get("result")
572            .cloned()
573            .unwrap_or(serde_json::Value::Null)
574    } else {
575        map.get("error").cloned().unwrap_or(serde_json::Value::Null)
576    };
577    let child_args = batch_child_args(&ctx.args, index);
578
579    let projected_child = if tool_name == "batch" || !success {
580        project_json_value(&child_value, config, ctx)
581    } else {
582        let model_return = project_tool_result(
583            config,
584            ToolResultProjectionContext {
585                session_id: ctx.session_id.clone(),
586                call_id: format!("{}.{}", ctx.call_id, index),
587                tool_name: tool_name.clone(),
588                args: child_args,
589                output: crate::ToolCallOutput::success(child_value.clone()),
590                duration_ms,
591            },
592        );
593        let rendered = render_model_return_parts(&model_return.parts);
594        rendered
595            .parse::<serde_json::Value>()
596            .unwrap_or(serde_json::Value::String(rendered))
597    };
598
599    let mut projected = serde_json::Map::new();
600    if let Some(value) = map.get("index") {
601        projected.insert("index".to_string(), value.clone());
602    }
603    projected.insert("tool".to_string(), serde_json::json!(tool_name));
604    projected.insert("success".to_string(), serde_json::json!(success));
605    projected.insert("duration_ms".to_string(), serde_json::json!(duration_ms));
606    projected.insert(
607        if success {
608            "result".to_string()
609        } else {
610            "error".to_string()
611        },
612        projected_child,
613    );
614    serde_json::Value::Object(projected)
615}
616
617fn render_model_return_parts(parts: &[ModelToolReturnPart]) -> String {
618    let mut rendered = String::new();
619    for part in parts {
620        match part {
621            ModelToolReturnPart::Text(text) => rendered.push_str(text),
622            ModelToolReturnPart::Attachment(reference) => {
623                rendered.push_str("[Attachment: ");
624                rendered.push_str(
625                    reference
626                        .label
627                        .as_deref()
628                        .unwrap_or_else(|| reference.id.as_str()),
629                );
630                rendered.push(']');
631            }
632        }
633    }
634    rendered
635}
636
637fn project_json_value(
638    value: &serde_json::Value,
639    config: &ToolOutputBudgetConfig,
640    ctx: &ToolResultProjectionContext,
641) -> serde_json::Value {
642    match value {
643        serde_json::Value::String(text) => {
644            serde_json::Value::String(project_text(text, config, ctx))
645        }
646        serde_json::Value::Array(items) => serde_json::Value::Array(
647            items
648                .iter()
649                .map(|item| project_json_value(item, config, ctx))
650                .collect(),
651        ),
652        serde_json::Value::Object(map) => serde_json::Value::Object(
653            map.iter()
654                .map(|(key, value)| (key.clone(), project_json_value(value, config, ctx)))
655                .collect(),
656        ),
657        other => other.clone(),
658    }
659}
660
661fn batch_child_tool_name(batch_args: &serde_json::Value, index: usize) -> Option<&str> {
662    batch_args
663        .get("tool_calls")
664        .and_then(|value| value.as_array())
665        .and_then(|items| items.get(index))
666        .and_then(|value| value.get("tool"))
667        .and_then(|value| value.as_str())
668}
669
670fn batch_child_args(batch_args: &serde_json::Value, index: usize) -> serde_json::Value {
671    batch_args
672        .get("tool_calls")
673        .and_then(|value| value.as_array())
674        .and_then(|items| items.get(index))
675        .and_then(|value| value.get("parameters"))
676        .cloned()
677        .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use serde_json::json;
684
685    #[test]
686    fn truncates_strings_with_terminal_style_marker() {
687        let config = ToolOutputBudgetConfig {
688            mode: ToolOutputBudgetMode::Tokens,
689            limit: 5,
690            max_lines: DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES,
691        };
692        let got = project_text(
693            "this is an example of a long output that should be truncated",
694            &config,
695            &ToolResultProjectionContext {
696                session_id: "root".to_string(),
697                call_id: "call".to_string(),
698                tool_name: "grep".to_string(),
699                args: json!({}),
700                output: crate::ToolCallOutput::success(json!("unused")),
701                duration_ms: 1,
702            },
703        );
704        assert!(got.contains("tokens truncated"));
705        assert!(got.contains("Full output saved to:"));
706    }
707
708    #[test]
709    fn truncation_hint_reuses_existing_full_output_path() {
710        let config = ToolOutputBudgetConfig {
711            limit: 512,
712            ..ToolOutputBudgetConfig::default()
713        };
714        let projected = project_tool_result(
715            &config,
716            ToolResultProjectionContext {
717                session_id: "root".to_string(),
718                call_id: "call".to_string(),
719                tool_name: "exec_command".to_string(),
720                args: json!({}),
721                output: crate::ToolCallOutput::success(json!({
722                    "output": "x".repeat(20_000),
723                    "full_output_path": "/tmp/existing-shell-output.log",
724                })),
725                duration_ms: 1,
726            },
727        );
728        let output = render_model_return_parts(&projected.parts);
729        assert!(output.contains("Full output saved to: /tmp/existing-shell-output.log"));
730    }
731
732    #[test]
733    fn model_projection_can_collapse_large_structured_payload_to_string() {
734        let config = ToolOutputBudgetConfig {
735            mode: ToolOutputBudgetMode::Bytes,
736            limit: 40,
737            max_lines: DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES,
738        };
739        let projected = project_tool_result(
740            &config,
741            ToolResultProjectionContext {
742                session_id: "root".to_string(),
743                call_id: "call".to_string(),
744                tool_name: "search_tools".to_string(),
745                args: json!({}),
746                output: crate::ToolCallOutput::success(json!({
747                    "results": [{"output": "x".repeat(200)}]
748                })),
749                duration_ms: 1,
750            },
751        );
752        assert!(render_model_return_parts(&projected.parts).contains("bytes truncated"));
753    }
754
755    #[test]
756    fn batch_model_projection_preserves_projected_child_payloads() {
757        let projected = project_tool_result(
758            &ToolOutputBudgetConfig::default(),
759            ToolResultProjectionContext {
760                session_id: "root".to_string(),
761                call_id: "call".to_string(),
762                tool_name: "batch".to_string(),
763                args: json!({}),
764                output: crate::ToolCallOutput::success(json!({
765                    "results": [
766                        {"tool": "read_file", "success": true, "duration_ms": 1, "result": "very long child payload"},
767                        {"tool": "grep", "success": false, "duration_ms": 1, "error": "boom"}
768                    ]
769                })),
770                duration_ms: 1,
771            },
772        );
773        let projected_value: serde_json::Value =
774            serde_json::from_str(&render_model_return_parts(&projected.parts)).unwrap();
775        let results = projected_value
776            .get("results")
777            .and_then(|value| value.as_array())
778            .expect("results");
779        assert_eq!(results.len(), 2);
780        assert_eq!(
781            results[0].get("result"),
782            Some(&json!("very long child payload"))
783        );
784        assert_eq!(results[1].get("error"), Some(&json!("boom")));
785    }
786
787    #[test]
788    fn batch_history_projection_recursively_projects_child_payloads() {
789        let projected = project_tool_result(
790            &ToolOutputBudgetConfig {
791                limit: 8,
792                ..ToolOutputBudgetConfig::default()
793            },
794            ToolResultProjectionContext {
795                session_id: "root".to_string(),
796                call_id: "call".to_string(),
797                tool_name: "batch".to_string(),
798                args: json!({}),
799                output: crate::ToolCallOutput::success(json!({
800                    "results": [
801                        {"tool": "read_file", "success": true, "duration_ms": 1, "result": "child payload"},
802                        {"tool": "grep", "success": false, "duration_ms": 1, "error": "boom"}
803                    ]
804                })),
805                duration_ms: 1,
806            },
807        );
808        let projected_value: serde_json::Value =
809            serde_json::from_str(&render_model_return_parts(&projected.parts)).unwrap();
810        let details = projected_value
811            .get("results")
812            .and_then(|value| value.as_array())
813            .expect("results");
814        assert_eq!(details.len(), 2);
815        let child_result = details[0]
816            .get("result")
817            .and_then(|value| value.as_str())
818            .unwrap_or_default();
819        assert!(child_result.contains("truncated"));
820        assert_eq!(details[1].get("error"), Some(&json!("boom")));
821    }
822
823    #[test]
824    fn observation_truncation_uses_shared_limits_and_hint() {
825        let config = ToolOutputBudgetConfig {
826            mode: ToolOutputBudgetMode::Bytes,
827            limit: 12,
828            max_lines: 2,
829        };
830        let projected = truncate_observation_text("line one\nline two\nline three", &config);
831        assert!(projected.contains("truncated"));
832        assert!(projected.contains("print output was capped at 12 bytes and 2 lines max"));
833        assert!(projected.contains("Use a narrower `print` expression"));
834
835        let (_projected, metadata) =
836            project_observation_text("line one\nline two\nline three", &config);
837        assert!(metadata.truncated);
838        assert_eq!(metadata.original_lines, 3);
839        assert_eq!(metadata.limit, 12);
840        assert_eq!(metadata.limit_mode, "bytes");
841    }
842}