Skip to main content

lash_plugin_tool_output_budget/
lib.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use sha2::{Digest, Sha256};
6
7use lash_core::plugin::{
8    PluginError, PluginFactory, PluginRegistrar, PluginSessionContext, SessionPlugin,
9    ToolResultProjectionContext,
10};
11use lash_core::{
12    ModelToolReturn, ModelToolReturnPart, PluginStack, TextProjectionMetadata, ToolCallOutcome,
13    ToolValue,
14};
15
16const APPROX_BYTES_PER_TOKEN: usize = 4;
17pub const DEFAULT_TOOL_OUTPUT_BUDGET_LIMIT_BYTES: usize = 16 * 1024;
18pub const DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES: usize = 400;
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ToolOutputBudgetMode {
23    Bytes,
24    Tokens,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28#[serde(default)]
29pub struct ToolOutputBudgetConfig {
30    pub mode: ToolOutputBudgetMode,
31    pub limit: usize,
32    pub max_lines: usize,
33}
34
35impl Default for ToolOutputBudgetConfig {
36    fn default() -> Self {
37        Self {
38            mode: ToolOutputBudgetMode::Bytes,
39            limit: DEFAULT_TOOL_OUTPUT_BUDGET_LIMIT_BYTES,
40            max_lines: DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES,
41        }
42    }
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46enum ProjectionDirection {
47    Head,
48    Tail,
49}
50
51/// Which end of the output a windowed truncation keeps.
52///
53/// `Head` keeps the leading lines (the common case); `Tail` keeps the
54/// trailing lines (used for streaming command output where the end is
55/// the interesting part). This is the public mirror of the budget
56/// plugin's internal `ProjectionDirection`, exported so other plugins
57/// (e.g. rolling-history) can share the one canonical truncation core
58/// instead of forking it.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum TruncationDirection {
61    Head,
62    Tail,
63}
64
65impl From<ProjectionDirection> for TruncationDirection {
66    fn from(direction: ProjectionDirection) -> Self {
67        match direction {
68            ProjectionDirection::Head => TruncationDirection::Head,
69            ProjectionDirection::Tail => TruncationDirection::Tail,
70        }
71    }
72}
73
74/// The unit reported in the `...N <unit> truncated...` marker when a
75/// windowed truncation hits its byte budget (rather than its line cap).
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum TruncationUnit {
78    /// Report removed *characters*, labelled `bytes`.
79    Bytes,
80    /// Report an approximate removed *token* count, labelled `tokens`.
81    Tokens,
82}
83
84impl TruncationUnit {
85    fn label(self) -> &'static str {
86        match self {
87            TruncationUnit::Bytes => "bytes",
88            TruncationUnit::Tokens => "tokens",
89        }
90    }
91}
92
93/// Parameters for [`truncate_windowed`], the single canonical
94/// head/tail-window + byte-cap truncation implementation shared by the
95/// tool-output-budget projector and the rolling-history compaction
96/// plugin.
97#[derive(Clone, Copy, Debug)]
98pub struct WindowedTruncation<'a> {
99    /// Maximum number of lines retained in the preview window.
100    pub max_lines: usize,
101    /// Maximum number of bytes retained in the preview window.
102    pub max_bytes: usize,
103    /// Which end of the output to keep.
104    pub direction: TruncationDirection,
105    /// The unit reported in the byte-budget truncation marker.
106    pub unit: TruncationUnit,
107    /// Trailing hint text appended to (Head) / prepended to (Tail) the
108    /// preview, explaining the truncation and where the full output is.
109    pub hint: &'a str,
110}
111
112/// The canonical head/tail-window + byte-cap truncation core.
113///
114/// Returns `text` unchanged when it already fits within `max_lines` and
115/// `max_bytes`. Otherwise keeps a preview window from the configured end
116/// and wraps it with a `...N <unit> truncated...` marker plus the
117/// caller-supplied `hint`.
118///
119/// A single line that is itself larger than `max_bytes` is truncated at
120/// a UTF-8 char boundary rather than dropped, so over-long lines never
121/// silently disappear and the function never panics on multi-byte text.
122pub fn truncate_windowed(text: &str, opts: &WindowedTruncation) -> String {
123    let lines: Vec<&str> = text.lines().collect();
124    let total_bytes = text.len();
125    if lines.len() <= opts.max_lines && total_bytes <= opts.max_bytes {
126        return text.to_string();
127    }
128
129    let mut preview_lines: Vec<String> = Vec::new();
130    let mut bytes = 0usize;
131    let mut hit_budget = false;
132
133    let mut push_line = |line: &str, bytes: &mut usize, hit_budget: &mut bool| -> bool {
134        // `separator` accounts for the `\n` re-joined between lines; the
135        // first retained line carries no separator.
136        let separator = usize::from(!preview_lines.is_empty());
137        let remaining = opts.max_bytes.saturating_sub(*bytes + separator);
138        if line.len() + separator <= opts.max_bytes.saturating_sub(*bytes) {
139            preview_lines.push(line.to_string());
140            *bytes += line.len() + separator;
141            true
142        } else if preview_lines.is_empty() && remaining > 0 {
143            // A lone line longer than the whole budget: truncate it at a
144            // char boundary instead of dropping it entirely.
145            let cut = char_floor(line, remaining);
146            if cut == 0 {
147                *hit_budget = true;
148                return false;
149            }
150            preview_lines.push(line[..cut].to_string());
151            *bytes += cut;
152            *hit_budget = true;
153            false
154        } else {
155            *hit_budget = true;
156            false
157        }
158    };
159
160    match opts.direction {
161        TruncationDirection::Head => {
162            for line in lines.iter().take(opts.max_lines) {
163                if !push_line(line, &mut bytes, &mut hit_budget) {
164                    break;
165                }
166            }
167        }
168        TruncationDirection::Tail => {
169            for line in lines.iter().rev().take(opts.max_lines) {
170                if !push_line(line, &mut bytes, &mut hit_budget) {
171                    break;
172                }
173            }
174            preview_lines.reverse();
175        }
176    }
177
178    let preview = preview_lines.join("\n");
179    let (removed, unit) = if hit_budget {
180        let removed = match opts.unit {
181            TruncationUnit::Bytes => {
182                u64::try_from(text.chars().count().saturating_sub(preview.chars().count()))
183                    .unwrap_or(u64::MAX)
184            }
185            TruncationUnit::Tokens => {
186                approx_tokens_from_byte_count(total_bytes.saturating_sub(preview.len()))
187            }
188        };
189        (removed, opts.unit.label())
190    } else {
191        (
192            u64::try_from(lines.len().saturating_sub(preview_lines.len())).unwrap_or(u64::MAX),
193            "lines",
194        )
195    };
196    let hint = opts.hint;
197    match opts.direction {
198        TruncationDirection::Head => {
199            format!("{preview}\n\n...{removed} {unit} truncated...\n\n{hint}")
200        }
201        TruncationDirection::Tail => {
202            format!("...{removed} {unit} truncated...\n\n{hint}\n\n{preview}")
203        }
204    }
205}
206
207/// Largest byte offset `<= max` that lands on a UTF-8 char boundary.
208fn char_floor(text: &str, max: usize) -> usize {
209    if max >= text.len() {
210        return text.len();
211    }
212    let mut cut = max;
213    while cut > 0 && !text.is_char_boundary(cut) {
214        cut -= 1;
215    }
216    cut
217}
218
219pub struct ToolOutputBudgetPluginFactory {
220    config: ToolOutputBudgetConfig,
221}
222
223impl ToolOutputBudgetPluginFactory {
224    pub fn new(config: ToolOutputBudgetConfig) -> Self {
225        Self { config }
226    }
227}
228
229impl Default for ToolOutputBudgetPluginFactory {
230    fn default() -> Self {
231        Self::new(ToolOutputBudgetConfig::default())
232    }
233}
234
235pub fn tool_output_budget_stack() -> PluginStack {
236    let mut stack = PluginStack::new();
237    stack.push(Arc::new(ToolOutputBudgetPluginFactory::default()));
238    stack
239}
240
241impl PluginFactory for ToolOutputBudgetPluginFactory {
242    fn id(&self) -> &'static str {
243        "tool_output_budget"
244    }
245
246    fn build(&self, _ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
247        Ok(Arc::new(ToolOutputBudgetPlugin {
248            config: self.config.clone(),
249        }))
250    }
251}
252
253struct ToolOutputBudgetPlugin {
254    config: ToolOutputBudgetConfig,
255}
256
257impl SessionPlugin for ToolOutputBudgetPlugin {
258    fn id(&self) -> &'static str {
259        "tool_output_budget"
260    }
261
262    fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
263        register_projector(reg, &self.config)
264    }
265}
266
267fn register_projector(
268    reg: &mut PluginRegistrar,
269    config: &ToolOutputBudgetConfig,
270) -> Result<(), PluginError> {
271    let config = config.clone();
272    reg.tool_results().projector(Arc::new(move |ctx| {
273        let config = config.clone();
274        Box::pin(async move { Ok(project_tool_result(&config, ctx)) })
275    }))
276}
277
278fn project_tool_result(
279    config: &ToolOutputBudgetConfig,
280    ctx: ToolResultProjectionContext,
281) -> ModelToolReturn {
282    let parts = project_model_parts(config, &ctx);
283    ModelToolReturn {
284        call_id: ctx.call_id.clone(),
285        tool_name: ctx.tool_name.clone(),
286        parts,
287    }
288}
289
290/// Project a tool result into the rendered model-facing text using the
291/// canonical projector — including the structure-aware `batch` path that
292/// recurses into each child result with the correct per-child truncation
293/// direction.
294///
295/// Exposed so other plugins (e.g. rolling-history) can reuse the one
296/// canonical batch/structured projection instead of flattening batched
297/// output to an opaque string and tail-truncating it (which cuts JSON
298/// mid-structure and loses per-child windowing). Attachments are rendered
299/// inline using the same `[Attachment: …]` placeholder the budget plugin
300/// uses internally.
301pub fn project_tool_result_text(
302    config: &ToolOutputBudgetConfig,
303    ctx: ToolResultProjectionContext,
304) -> String {
305    render_model_return_parts(&project_tool_result(config, ctx).parts)
306}
307
308fn project_model_parts(
309    config: &ToolOutputBudgetConfig,
310    ctx: &ToolResultProjectionContext,
311) -> Vec<ModelToolReturnPart> {
312    if ctx.tool_name == "batch" {
313        let value = project_batch_value(config, ctx);
314        return vec![ModelToolReturnPart::text(render_projected_model_value(
315            &value,
316        ))];
317    }
318
319    match &ctx.output.outcome {
320        ToolCallOutcome::Success(value) => project_tool_value_parts(config, ctx, value),
321        ToolCallOutcome::Failure(failure) => {
322            let mut parts = vec![ModelToolReturnPart::text(
323                lash_core::session_model::format_tool_output_content(&ctx.output),
324            )];
325            if let Some(raw) = &failure.raw {
326                parts.extend(
327                    raw.attachments()
328                        .into_iter()
329                        .map(ModelToolReturnPart::Attachment),
330                );
331            }
332            parts
333        }
334        ToolCallOutcome::Cancelled(cancellation) => {
335            let mut parts = vec![ModelToolReturnPart::text(
336                lash_core::session_model::format_tool_output_content(&ctx.output),
337            )];
338            if let Some(raw) = &cancellation.raw {
339                parts.extend(
340                    raw.attachments()
341                        .into_iter()
342                        .map(ModelToolReturnPart::Attachment),
343                );
344            }
345            parts
346        }
347    }
348}
349
350fn render_projected_model_value(value: &serde_json::Value) -> String {
351    match value {
352        serde_json::Value::String(text) => text.clone(),
353        other => serde_json::to_string(other).unwrap_or_else(|_| "null".to_string()),
354    }
355}
356
357fn project_tool_value_parts(
358    config: &ToolOutputBudgetConfig,
359    ctx: &ToolResultProjectionContext,
360    value: &ToolValue,
361) -> Vec<ModelToolReturnPart> {
362    let mut parts = Vec::new();
363    match value {
364        ToolValue::String(text) => {
365            parts.push(ModelToolReturnPart::text(project_text(text, config, ctx)))
366        }
367        ToolValue::Attachment(reference) => {
368            parts.push(ModelToolReturnPart::Attachment(reference.clone()));
369        }
370        ToolValue::Null
371        | ToolValue::Bool(_)
372        | ToolValue::Number(_)
373        | ToolValue::Array(_)
374        | ToolValue::Object(_) => {
375            push_projected_tool_value_parts(value, &mut parts, config, ctx);
376        }
377    }
378    parts
379}
380
381fn push_projected_tool_value_parts(
382    value: &ToolValue,
383    parts: &mut Vec<ModelToolReturnPart>,
384    config: &ToolOutputBudgetConfig,
385    ctx: &ToolResultProjectionContext,
386) {
387    match value {
388        ToolValue::Null => push_text_part(parts, "null"),
389        ToolValue::Bool(value) => push_text_part(parts, value.to_string()),
390        ToolValue::Number(value) => push_text_part(parts, value.to_string()),
391        ToolValue::String(text) => push_text_part(
392            parts,
393            serde_json::to_string(&project_text(text, config, ctx))
394                .unwrap_or_else(|_| "\"\"".to_string()),
395        ),
396        ToolValue::Attachment(reference) => {
397            parts.push(ModelToolReturnPart::Attachment(reference.clone()));
398        }
399        ToolValue::Array(items) => {
400            push_text_part(parts, "[");
401            for (index, item) in items.iter().enumerate() {
402                if index > 0 {
403                    push_text_part(parts, ",");
404                }
405                push_projected_tool_value_parts(item, parts, config, ctx);
406            }
407            push_text_part(parts, "]");
408        }
409        ToolValue::Object(map) => {
410            push_text_part(parts, "{");
411            for (index, (key, value)) in map.iter().enumerate() {
412                if index > 0 {
413                    push_text_part(parts, ",");
414                }
415                push_text_part(
416                    parts,
417                    serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string()),
418                );
419                push_text_part(parts, ":");
420                push_projected_tool_value_parts(value, parts, config, ctx);
421            }
422            push_text_part(parts, "}");
423        }
424    }
425}
426
427fn push_text_part(parts: &mut Vec<ModelToolReturnPart>, text: impl Into<String>) {
428    let text = text.into();
429    if text.is_empty() {
430        return;
431    }
432    if let Some(ModelToolReturnPart::Text { text: existing }) = parts.last_mut() {
433        existing.push_str(&text);
434    } else {
435        parts.push(ModelToolReturnPart::text(text));
436    }
437}
438
439fn project_text(
440    text: &str,
441    config: &ToolOutputBudgetConfig,
442    ctx: &ToolResultProjectionContext,
443) -> String {
444    if !needs_truncation(text, config) {
445        return text.to_string();
446    }
447    truncate_text(
448        text,
449        config,
450        tool_projection_direction(&ctx.tool_name),
451        Some(ctx),
452    )
453}
454
455fn needs_truncation(text: &str, config: &ToolOutputBudgetConfig) -> bool {
456    if text.lines().count() > config.max_lines {
457        return true;
458    }
459    match config.mode {
460        ToolOutputBudgetMode::Bytes => text.len() > config.limit,
461        ToolOutputBudgetMode::Tokens => approx_token_count(text) > config.limit,
462    }
463}
464
465pub fn truncate_observation_text(text: &str, config: &ToolOutputBudgetConfig) -> String {
466    if !needs_truncation(text, config) {
467        return text.to_string();
468    }
469    truncate_text_with_hint(
470        text,
471        config,
472        ProjectionDirection::Head,
473        observation_truncation_hint(text, config),
474    )
475}
476
477pub fn project_observation_text(
478    text: &str,
479    config: &ToolOutputBudgetConfig,
480) -> (String, TextProjectionMetadata) {
481    let projected = truncate_observation_text(text, config);
482    let metadata = observation_projection_metadata(text, &projected, config);
483    (projected, metadata)
484}
485
486pub fn observation_projection_metadata(
487    original: &str,
488    projected: &str,
489    config: &ToolOutputBudgetConfig,
490) -> TextProjectionMetadata {
491    let limit_mode = match config.mode {
492        ToolOutputBudgetMode::Bytes => "bytes",
493        ToolOutputBudgetMode::Tokens => "tokens",
494    };
495    TextProjectionMetadata {
496        truncated: original != projected,
497        original_chars: original.chars().count(),
498        projected_chars: projected.chars().count(),
499        original_lines: original.lines().count(),
500        projected_lines: projected.lines().count(),
501        limit: config.limit,
502        limit_mode: limit_mode.to_string(),
503        max_lines: config.max_lines,
504    }
505}
506
507fn truncate_text(
508    text: &str,
509    config: &ToolOutputBudgetConfig,
510    direction: ProjectionDirection,
511    ctx: Option<&ToolResultProjectionContext>,
512) -> String {
513    truncate_text_with_hint(text, config, direction, truncation_hint(ctx, text))
514}
515
516fn truncate_text_with_hint(
517    text: &str,
518    config: &ToolOutputBudgetConfig,
519    direction: ProjectionDirection,
520    hint: String,
521) -> String {
522    if text.is_empty() {
523        return String::new();
524    }
525    let max_bytes = match config.mode {
526        ToolOutputBudgetMode::Bytes => config.limit,
527        ToolOutputBudgetMode::Tokens => approx_bytes_for_tokens(config.limit),
528    };
529    if max_bytes == 0 {
530        return format_truncation_marker(
531            config.mode,
532            removed_units(config.mode, text.len(), text.chars().count()),
533        );
534    }
535    if !needs_truncation(text, config) {
536        return text.to_string();
537    }
538    truncate_windowed(
539        text,
540        &WindowedTruncation {
541            max_lines: config.max_lines,
542            max_bytes,
543            direction: direction.into(),
544            unit: match config.mode {
545                ToolOutputBudgetMode::Bytes => TruncationUnit::Bytes,
546                ToolOutputBudgetMode::Tokens => TruncationUnit::Tokens,
547            },
548            hint: &hint,
549        },
550    )
551}
552
553fn format_truncation_marker(mode: ToolOutputBudgetMode, removed: u64) -> String {
554    match mode {
555        ToolOutputBudgetMode::Bytes => format!("…{removed} chars truncated…"),
556        ToolOutputBudgetMode::Tokens => format!("…{removed} tokens truncated…"),
557    }
558}
559
560fn removed_units(mode: ToolOutputBudgetMode, removed_bytes: usize, removed_chars: usize) -> u64 {
561    match mode {
562        ToolOutputBudgetMode::Bytes => u64::try_from(removed_chars).unwrap_or(u64::MAX),
563        ToolOutputBudgetMode::Tokens => approx_tokens_from_byte_count(removed_bytes),
564    }
565}
566
567fn approx_token_count(text: &str) -> usize {
568    text.len()
569        .saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1))
570        / APPROX_BYTES_PER_TOKEN
571}
572
573fn approx_bytes_for_tokens(tokens: usize) -> usize {
574    tokens.saturating_mul(APPROX_BYTES_PER_TOKEN)
575}
576
577fn approx_tokens_from_byte_count(bytes: usize) -> u64 {
578    let bytes = bytes as u64;
579    bytes.saturating_add((APPROX_BYTES_PER_TOKEN as u64).saturating_sub(1))
580        / (APPROX_BYTES_PER_TOKEN as u64)
581}
582
583fn tool_projection_direction(tool_name: &str) -> ProjectionDirection {
584    match tool_name {
585        "exec_command" | "write_stdin" => ProjectionDirection::Tail,
586        _ => ProjectionDirection::Head,
587    }
588}
589
590fn truncation_hint(ctx: Option<&ToolResultProjectionContext>, text: &str) -> String {
591    let output_path = ctx
592        .and_then(existing_tool_output_path)
593        .or_else(|| ctx.and_then(|ctx| spill_tool_output(&ctx.tool_name, &ctx.args, text)));
594    match output_path {
595        Some(path) => format!(
596            "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.",
597            path.display()
598        ),
599        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(),
600    }
601}
602
603fn observation_truncation_hint(text: &str, config: &ToolOutputBudgetConfig) -> String {
604    let limit_unit = match config.mode {
605        ToolOutputBudgetMode::Bytes => "bytes",
606        ToolOutputBudgetMode::Tokens => "tokens",
607    };
608    let total_units = match config.mode {
609        ToolOutputBudgetMode::Bytes => text.len(),
610        ToolOutputBudgetMode::Tokens => approx_token_count(text),
611    };
612    let total_lines = text.lines().count();
613    format!(
614        "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.",
615        config.limit, limit_unit, config.max_lines, total_units, limit_unit, total_lines
616    )
617}
618
619fn existing_tool_output_path(ctx: &ToolResultProjectionContext) -> Option<PathBuf> {
620    ctx.output
621        .value_for_projection()
622        .get("full_output_path")
623        .and_then(|value| value.as_str())
624        .filter(|value| !value.trim().is_empty())
625        .map(PathBuf::from)
626}
627
628fn spill_tool_output(
629    tool_name: &str,
630    args: &serde_json::Value,
631    full_output: &str,
632) -> Option<PathBuf> {
633    let dir = std::env::temp_dir().join("lash-tool-output");
634    if fs::create_dir_all(&dir).is_err() {
635        return None;
636    }
637
638    let mut hasher = Sha256::new();
639    hasher.update(tool_name.as_bytes());
640    hasher.update(args.to_string().as_bytes());
641    hasher.update(full_output.as_bytes());
642    let digest = format!("{:x}", hasher.finalize());
643    let stem = tool_name
644        .chars()
645        .map(|ch| {
646            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
647                ch
648            } else {
649                '_'
650            }
651        })
652        .collect::<String>();
653    let path = dir.join(format!("{stem}-{}.txt", &digest[..12]));
654    if write_if_changed(&path, full_output).is_err() {
655        return None;
656    }
657    Some(path)
658}
659
660fn write_if_changed(path: &Path, content: &str) -> std::io::Result<()> {
661    let should_write = match fs::read_to_string(path) {
662        Ok(existing) => existing != content,
663        Err(_) => true,
664    };
665    if should_write {
666        fs::write(path, content)?;
667    }
668    Ok(())
669}
670
671fn project_batch_value(
672    config: &ToolOutputBudgetConfig,
673    ctx: &ToolResultProjectionContext,
674) -> serde_json::Value {
675    let value = ctx.output.value_for_projection();
676    let Some(map) = value.as_object() else {
677        return project_json_value(&value, config, ctx);
678    };
679
680    let mut projected = serde_json::Map::new();
681
682    let results = map
683        .get("results")
684        .and_then(|value| value.as_array())
685        .map(|items| {
686            items
687                .iter()
688                .enumerate()
689                .map(|(index, item)| project_batch_child_value(index, item, config, ctx))
690                .collect::<Vec<_>>()
691        })
692        .unwrap_or_default();
693    projected.insert("results".to_string(), serde_json::Value::Array(results));
694    serde_json::Value::Object(projected)
695}
696
697fn project_batch_child_value(
698    index: usize,
699    item: &serde_json::Value,
700    config: &ToolOutputBudgetConfig,
701    ctx: &ToolResultProjectionContext,
702) -> serde_json::Value {
703    let Some(map) = item.as_object() else {
704        return project_json_value(item, config, ctx);
705    };
706
707    let tool_name = map
708        .get("tool")
709        .and_then(|value| value.as_str())
710        .or_else(|| batch_child_tool_name(&ctx.args, index))
711        .unwrap_or("tool")
712        .to_string();
713    let success = map
714        .get("success")
715        .and_then(|value| value.as_bool())
716        .unwrap_or(false);
717    let duration_ms = map
718        .get("duration_ms")
719        .and_then(|value| value.as_u64())
720        .unwrap_or_default();
721    let child_value = if success {
722        map.get("result")
723            .cloned()
724            .unwrap_or(serde_json::Value::Null)
725    } else {
726        map.get("error").cloned().unwrap_or(serde_json::Value::Null)
727    };
728    let child_args = batch_child_args(&ctx.args, index);
729
730    let projected_child = if tool_name == "batch" || !success {
731        project_json_value(&child_value, config, ctx)
732    } else {
733        let model_return = project_tool_result(
734            config,
735            ToolResultProjectionContext {
736                session_id: ctx.session_id.clone(),
737                call_id: format!("{}.{}", ctx.call_id, index),
738                tool_name: tool_name.clone(),
739                args: child_args,
740                output: lash_core::ToolCallOutput::success(child_value.clone()),
741                duration_ms,
742            },
743        );
744        let rendered = render_model_return_parts(&model_return.parts);
745        rendered
746            .parse::<serde_json::Value>()
747            .unwrap_or(serde_json::Value::String(rendered))
748    };
749
750    let mut projected = serde_json::Map::new();
751    if let Some(value) = map.get("index") {
752        projected.insert("index".to_string(), value.clone());
753    }
754    projected.insert("tool".to_string(), serde_json::json!(tool_name));
755    projected.insert("success".to_string(), serde_json::json!(success));
756    projected.insert("duration_ms".to_string(), serde_json::json!(duration_ms));
757    projected.insert(
758        if success {
759            "result".to_string()
760        } else {
761            "error".to_string()
762        },
763        projected_child,
764    );
765    serde_json::Value::Object(projected)
766}
767
768fn render_model_return_parts(parts: &[ModelToolReturnPart]) -> String {
769    let mut rendered = String::new();
770    for part in parts {
771        match part {
772            ModelToolReturnPart::Text { text } => rendered.push_str(text),
773            ModelToolReturnPart::Attachment(reference) => {
774                rendered.push_str("[Attachment: ");
775                rendered.push_str(
776                    reference
777                        .label
778                        .as_deref()
779                        .unwrap_or_else(|| reference.id.as_str()),
780                );
781                rendered.push(']');
782            }
783        }
784    }
785    rendered
786}
787
788fn project_json_value(
789    value: &serde_json::Value,
790    config: &ToolOutputBudgetConfig,
791    ctx: &ToolResultProjectionContext,
792) -> serde_json::Value {
793    match value {
794        serde_json::Value::String(text) => {
795            serde_json::Value::String(project_text(text, config, ctx))
796        }
797        serde_json::Value::Array(items) => serde_json::Value::Array(
798            items
799                .iter()
800                .map(|item| project_json_value(item, config, ctx))
801                .collect(),
802        ),
803        serde_json::Value::Object(map) => serde_json::Value::Object(
804            map.iter()
805                .map(|(key, value)| (key.clone(), project_json_value(value, config, ctx)))
806                .collect(),
807        ),
808        other => other.clone(),
809    }
810}
811
812fn batch_child_tool_name(batch_args: &serde_json::Value, index: usize) -> Option<&str> {
813    batch_args
814        .get("tool_calls")
815        .and_then(|value| value.as_array())
816        .and_then(|items| items.get(index))
817        .and_then(|value| value.get("tool"))
818        .and_then(|value| value.as_str())
819}
820
821fn batch_child_args(batch_args: &serde_json::Value, index: usize) -> serde_json::Value {
822    batch_args
823        .get("tool_calls")
824        .and_then(|value| value.as_array())
825        .and_then(|items| items.get(index))
826        .and_then(|value| value.get("parameters"))
827        .cloned()
828        .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use serde_json::json;
835
836    #[test]
837    fn windowed_truncation_truncates_over_long_single_line_instead_of_dropping_it() {
838        // A single line longer than the whole byte budget must be cut at a
839        // char boundary, not dropped (which would leave an empty preview).
840        let line = "x".repeat(1000);
841        let got = truncate_windowed(
842            &line,
843            &WindowedTruncation {
844                max_lines: 400,
845                max_bytes: 64,
846                direction: TruncationDirection::Head,
847                unit: TruncationUnit::Bytes,
848                hint: "hint",
849            },
850        );
851        let preview = got.split("\n\n...").next().expect("preview");
852        assert!(!preview.is_empty(), "preview must not be empty: {got:?}");
853        assert!(preview.len() <= 64);
854        assert!(preview.chars().all(|c| c == 'x'));
855        assert!(got.contains("bytes truncated"));
856    }
857
858    #[test]
859    fn windowed_truncation_never_splits_a_multibyte_char() {
860        // Budget lands mid-way through a 3-byte char; must back off to a
861        // boundary rather than panic or emit invalid UTF-8.
862        let line = "★".repeat(100); // each '★' is 3 bytes
863        let got = truncate_windowed(
864            &line,
865            &WindowedTruncation {
866                max_lines: 400,
867                max_bytes: 10, // not a multiple of 3
868                direction: TruncationDirection::Head,
869                unit: TruncationUnit::Bytes,
870                hint: "hint",
871            },
872        );
873        let preview = got.split("\n\n...").next().expect("preview");
874        assert!(!preview.is_empty());
875        assert!(preview.chars().all(|c| c == '★'));
876        assert_eq!(preview.len() % 3, 0, "must cut on a char boundary");
877        assert!(preview.len() <= 10);
878    }
879
880    #[test]
881    fn windowed_truncation_returns_input_unchanged_when_within_budget() {
882        let text = "a\nb\nc";
883        let got = truncate_windowed(
884            text,
885            &WindowedTruncation {
886                max_lines: 400,
887                max_bytes: 1024,
888                direction: TruncationDirection::Head,
889                unit: TruncationUnit::Bytes,
890                hint: "hint",
891            },
892        );
893        assert_eq!(got, text);
894    }
895
896    #[test]
897    fn truncates_strings_with_terminal_style_marker() {
898        let config = ToolOutputBudgetConfig {
899            mode: ToolOutputBudgetMode::Tokens,
900            limit: 5,
901            max_lines: DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES,
902        };
903        let got = project_text(
904            "this is an example of a long output that should be truncated",
905            &config,
906            &ToolResultProjectionContext {
907                session_id: "root".to_string(),
908                call_id: "call".to_string(),
909                tool_name: "grep".to_string(),
910                args: json!({}),
911                output: lash_core::ToolCallOutput::success(json!("unused")),
912                duration_ms: 1,
913            },
914        );
915        assert!(got.contains("tokens truncated"));
916        assert!(got.contains("Full output saved to:"));
917    }
918
919    #[test]
920    fn truncation_hint_reuses_existing_full_output_path() {
921        let config = ToolOutputBudgetConfig {
922            limit: 512,
923            ..ToolOutputBudgetConfig::default()
924        };
925        let projected = project_tool_result(
926            &config,
927            ToolResultProjectionContext {
928                session_id: "root".to_string(),
929                call_id: "call".to_string(),
930                tool_name: "exec_command".to_string(),
931                args: json!({}),
932                output: lash_core::ToolCallOutput::success(json!({
933                    "output": "x".repeat(20_000),
934                    "full_output_path": "/tmp/existing-shell-output.log",
935                })),
936                duration_ms: 1,
937            },
938        );
939        let output = render_model_return_parts(&projected.parts);
940        assert!(output.contains("Full output saved to: /tmp/existing-shell-output.log"));
941    }
942
943    #[test]
944    fn model_projection_can_collapse_large_structured_payload_to_string() {
945        let config = ToolOutputBudgetConfig {
946            mode: ToolOutputBudgetMode::Bytes,
947            limit: 40,
948            max_lines: DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES,
949        };
950        let projected = project_tool_result(
951            &config,
952            ToolResultProjectionContext {
953                session_id: "root".to_string(),
954                call_id: "call".to_string(),
955                tool_name: "search_tools".to_string(),
956                args: json!({}),
957                output: lash_core::ToolCallOutput::success(json!({
958                    "results": [{"output": "x".repeat(200)}]
959                })),
960                duration_ms: 1,
961            },
962        );
963        assert!(render_model_return_parts(&projected.parts).contains("bytes truncated"));
964    }
965
966    #[test]
967    fn batch_model_projection_preserves_projected_child_payloads() {
968        let projected = project_tool_result(
969            &ToolOutputBudgetConfig::default(),
970            ToolResultProjectionContext {
971                session_id: "root".to_string(),
972                call_id: "call".to_string(),
973                tool_name: "batch".to_string(),
974                args: json!({}),
975                output: lash_core::ToolCallOutput::success(json!({
976                    "results": [
977                        {"tool": "read_file", "success": true, "duration_ms": 1, "result": "very long child payload"},
978                        {"tool": "grep", "success": false, "duration_ms": 1, "error": "boom"}
979                    ]
980                })),
981                duration_ms: 1,
982            },
983        );
984        let projected_value: serde_json::Value =
985            serde_json::from_str(&render_model_return_parts(&projected.parts)).unwrap();
986        let results = projected_value
987            .get("results")
988            .and_then(|value| value.as_array())
989            .expect("results");
990        assert_eq!(results.len(), 2);
991        assert_eq!(
992            results[0].get("result"),
993            Some(&json!("very long child payload"))
994        );
995        assert_eq!(results[1].get("error"), Some(&json!("boom")));
996    }
997
998    #[test]
999    fn batch_history_projection_recursively_projects_child_payloads() {
1000        let projected = project_tool_result(
1001            &ToolOutputBudgetConfig {
1002                limit: 8,
1003                ..ToolOutputBudgetConfig::default()
1004            },
1005            ToolResultProjectionContext {
1006                session_id: "root".to_string(),
1007                call_id: "call".to_string(),
1008                tool_name: "batch".to_string(),
1009                args: json!({}),
1010                output: lash_core::ToolCallOutput::success(json!({
1011                    "results": [
1012                        {"tool": "read_file", "success": true, "duration_ms": 1, "result": "child payload"},
1013                        {"tool": "grep", "success": false, "duration_ms": 1, "error": "boom"}
1014                    ]
1015                })),
1016                duration_ms: 1,
1017            },
1018        );
1019        let projected_value: serde_json::Value =
1020            serde_json::from_str(&render_model_return_parts(&projected.parts)).unwrap();
1021        let details = projected_value
1022            .get("results")
1023            .and_then(|value| value.as_array())
1024            .expect("results");
1025        assert_eq!(details.len(), 2);
1026        let child_result = details[0]
1027            .get("result")
1028            .and_then(|value| value.as_str())
1029            .unwrap_or_default();
1030        assert!(child_result.contains("truncated"));
1031        assert_eq!(details[1].get("error"), Some(&json!("boom")));
1032    }
1033
1034    #[test]
1035    fn observation_truncation_uses_shared_limits_and_hint() {
1036        let config = ToolOutputBudgetConfig {
1037            mode: ToolOutputBudgetMode::Bytes,
1038            limit: 12,
1039            max_lines: 2,
1040        };
1041        let projected = truncate_observation_text("line one\nline two\nline three", &config);
1042        assert!(projected.contains("truncated"));
1043        assert!(projected.contains("print output was capped at 12 bytes and 2 lines max"));
1044        assert!(projected.contains("Use a narrower `print` expression"));
1045
1046        let (_projected, metadata) =
1047            project_observation_text("line one\nline two\nline three", &config);
1048        assert!(metadata.truncated);
1049        assert_eq!(metadata.original_lines, 3);
1050        assert_eq!(metadata.limit, 12);
1051        assert_eq!(metadata.limit_mode, "bytes");
1052    }
1053}