use serde_json::{Value, json};
use crate::artifact::{
store::ArtifactStore,
text::{head_tail_with_marker, normalize_lines, strip_ansi},
};
pub const ARTIFACT_THRESHOLD_CHARS: usize = 4_000;
#[derive(Debug, Clone, Copy)]
pub struct PreviewBudget {
pub head_lines: u32,
pub tail_lines: u32,
pub max_chars: usize,
}
impl PreviewBudget {
pub const DEFAULT: Self = Self {
head_lines: 40,
tail_lines: 20,
max_chars: 2_400,
};
pub const WEB: Self = Self {
head_lines: 200,
tail_lines: 40,
max_chars: 25_000,
};
}
impl Default for PreviewBudget {
fn default() -> Self {
Self::DEFAULT
}
}
pub fn compact_text(
store: &ArtifactStore,
session_key: &str,
text: &str,
budget: PreviewBudget,
) -> (String, Option<String>) {
if text.chars().count() <= ARTIFACT_THRESHOLD_CHARS {
return (text.to_owned(), None);
}
let id = match store.write(session_key, text) {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = %e, "artifact store write failed; returning raw text");
return (text.to_owned(), None);
}
};
let lines = normalize_lines(&strip_ansi(text));
let id_str = id.as_str().to_owned();
let kept = head_tail_with_marker(&lines, budget.head_lines, budget.tail_lines, |omitted| {
format!(
"... {omitted} lines omitted — call read_artifact(tool_result_id=\"{id_str}\") for full output ..."
)
});
let preview = kept.join("\n");
let preview = char_cap(&preview, budget.max_chars, id.as_str());
(preview, Some(id.0))
}
fn char_cap(s: &str, max: usize, id: &str) -> String {
if s.chars().count() <= max {
return s.to_owned();
}
let marker = format!(
"\n... output truncated — call read_artifact(tool_result_id=\"{id}\") for full output ...\n"
);
let marker_len = marker.chars().count();
let body = max.saturating_sub(marker_len);
let head_n = body * 7 / 10;
let tail_n = body - head_n;
let head: String = s.chars().take(head_n).collect();
let tail: String = s
.chars()
.rev()
.take(tail_n)
.collect::<String>()
.chars()
.rev()
.collect();
format!("{head}{marker}{tail}")
}
pub fn compact_value(
store: &ArtifactStore,
session_key: &str,
mut value: Value,
budget: PreviewBudget,
) -> Value {
if let Value::Object(map) = &mut value {
let heavy_keys = [
"stdout",
"stderr",
"text",
"content",
"messages_text",
"output",
];
let mut any_compacted = false;
let mut compacted_raw: usize = 0;
let mut artifact_ids: Vec<(String, String)> = Vec::new();
for k in heavy_keys.iter() {
if let Some(field) = map.get(*k) {
if let Some(s) = field.as_str() {
let raw = s.chars().count();
if raw > ARTIFACT_THRESHOLD_CHARS {
compacted_raw += raw;
let (preview, id) = compact_text(store, session_key, s, budget);
if let Some(id) = id {
artifact_ids.push((k.to_string(), id));
any_compacted = true;
}
map.insert(k.to_string(), Value::String(preview));
}
}
}
}
if any_compacted {
if artifact_ids.len() == 1 {
map.insert(
"_tool_result_id".to_string(),
Value::String(artifact_ids[0].1.clone()),
);
} else {
let mut by_field = serde_json::Map::new();
for (field, id) in &artifact_ids {
by_field.insert(field.clone(), Value::String(id.clone()));
}
map.insert("_tool_result_ids".to_string(), Value::Object(by_field));
}
map.insert("_truncated".to_string(), Value::Bool(true));
map.insert(
"_raw_chars".to_string(),
Value::Number(serde_json::Number::from(compacted_raw)),
);
map.insert(
"_hint".to_string(),
Value::String(
"Output exceeded inline budget. Use read_artifact(tool_result_id=..., mode=full|head:N|tail:N|lines:A-B|grep:PAT) to see full content.".to_string()
),
);
}
return Value::Object(std::mem::take(map));
}
if let Value::String(s) = &value {
if s.chars().count() > ARTIFACT_THRESHOLD_CHARS {
let (preview, id) = compact_text(store, session_key, s, budget);
return match id {
Some(id) => json!({
"text": preview,
"_tool_result_id": id,
"_truncated": true,
"_raw_chars": s.chars().count(),
"_hint": "Use read_artifact to see full content.",
}),
None => Value::String(preview),
};
}
}
value
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
fn store() -> (tempfile::TempDir, ArtifactStore) {
let tmp = tempdir().unwrap();
let s = ArtifactStore::at(tmp.path().to_path_buf());
(tmp, s)
}
#[test]
fn tiny_text_passes_through() {
let (_t, s) = store();
let (out, id) = compact_text(&s, "sess", "small", PreviewBudget::DEFAULT);
assert_eq!(out, "small");
assert!(id.is_none());
}
#[test]
fn large_text_gets_artifact_and_preview() {
let (_t, s) = store();
let big = (1..=500)
.map(|i| format!("line_{i}"))
.collect::<Vec<_>>()
.join("\n");
let (preview, id) = compact_text(&s, "sess", &big, PreviewBudget::DEFAULT);
let id = id.expect("artifact written");
assert!(preview.contains("line_1"));
assert!(preview.contains("line_500"));
assert!(preview.contains("read_artifact"));
assert!(preview.contains(&id));
let full = s.read("sess", &crate::artifact::ArtifactId(id)).unwrap();
assert_eq!(full, big);
}
#[test]
fn web_budget_keeps_more_lines_than_default() {
let (_t, s) = store();
let big = (1..=500)
.map(|i| format!("line_{i:03}"))
.collect::<Vec<_>>()
.join("\n");
let (default_preview, _) = compact_text(&s, "sess", &big, PreviewBudget::DEFAULT);
let (web_preview, _) = compact_text(&s, "sess", &big, PreviewBudget::WEB);
let default_lines = default_preview.lines().count();
let web_lines = web_preview.lines().count();
assert!(
web_lines > default_lines + 100,
"web should keep many more lines: default={default_lines}, web={web_lines}"
);
assert!(default_preview.contains("read_artifact"));
assert!(web_preview.contains("read_artifact"));
}
#[test]
fn web_preview_respects_max_chars_cap() {
let (_t, s) = store();
let big = (1..=500)
.map(|i| format!("{i:04}: {}", "x".repeat(196)))
.collect::<Vec<_>>()
.join("\n");
let (preview, id) = compact_text(&s, "sess", &big, PreviewBudget::WEB);
assert!(id.is_some());
let preview_chars = preview.chars().count();
assert!(
preview_chars <= PreviewBudget::WEB.max_chars + 50,
"preview {preview_chars} chars exceeded WEB cap {}",
PreviewBudget::WEB.max_chars
);
}
#[test]
fn artifact_on_disk_has_no_size_cap() {
let (_t, s) = store();
let big = "x".repeat(200_000);
let (_preview, id) = compact_text(&s, "sess", &big, PreviewBudget::DEFAULT);
let id = id.expect("artifact written");
let full = s.read("sess", &crate::artifact::ArtifactId(id)).unwrap();
assert_eq!(full.len(), 200_000);
}
#[test]
fn json_object_compacts_stdout_field() {
let (_t, s) = store();
let big = "x".repeat(5_000);
let value = json!({
"exit_code": 0,
"stdout": big,
"stderr": "",
});
let out = compact_value(&s, "sess", value, PreviewBudget::DEFAULT);
let obj = out.as_object().unwrap();
assert_eq!(obj["_truncated"], json!(true));
assert!(obj["_tool_result_id"].is_string());
assert!(obj["stdout"].as_str().unwrap().len() < 5_000);
assert_eq!(obj["exit_code"], json!(0));
}
#[test]
fn json_object_small_passes_through() {
let (_t, s) = store();
let value = json!({"exit_code": 0, "stdout": "hi"});
let out = compact_value(&s, "sess", value.clone(), PreviewBudget::DEFAULT);
assert_eq!(out, value);
}
}