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}