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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum TruncationUnit {
78 Bytes,
80 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#[derive(Clone, Copy, Debug)]
98pub struct WindowedTruncation<'a> {
99 pub max_lines: usize,
101 pub max_bytes: usize,
103 pub direction: TruncationDirection,
105 pub unit: TruncationUnit,
107 pub hint: &'a str,
110}
111
112pub 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 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 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
207fn 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
290pub 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 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 let line = "★".repeat(100); let got = truncate_windowed(
864 &line,
865 &WindowedTruncation {
866 max_lines: 400,
867 max_bytes: 10, 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}