Skip to main content

a3s_code_core/tools/
result_transform.rs

1use super::ToolResultLossModeV1;
2use crate::text::truncate_utf8;
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7pub const TOOL_RESULT_TRANSFORM_SCHEMA_V1: &str = "a3s.code.tool-result-transform-policy.v1";
8pub const TOOL_RESULT_TRANSFORM_ALGORITHM_V1: &str = "a3s.code.tool-result-transform.v1";
9const MARKER_RESERVE_BYTES: usize = 512;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct ToolResultTransformPolicyV1 {
14    pub schema: String,
15    pub max_output_bytes: usize,
16    pub head_bytes: usize,
17    pub tail_bytes: usize,
18    pub fold_repeated_lines: bool,
19    pub repeated_line_threshold: usize,
20    pub structured_sample_items: usize,
21}
22
23impl Default for ToolResultTransformPolicyV1 {
24    fn default() -> Self {
25        Self::conservative()
26    }
27}
28
29impl ToolResultTransformPolicyV1 {
30    pub fn conservative() -> Self {
31        Self {
32            schema: TOOL_RESULT_TRANSFORM_SCHEMA_V1.to_string(),
33            max_output_bytes: super::MAX_OUTPUT_SIZE,
34            head_bytes: super::MAX_OUTPUT_SIZE,
35            tail_bytes: 0,
36            fold_repeated_lines: false,
37            repeated_line_threshold: 3,
38            structured_sample_items: 0,
39        }
40    }
41
42    pub fn context_efficient() -> Self {
43        Self {
44            schema: TOOL_RESULT_TRANSFORM_SCHEMA_V1.to_string(),
45            max_output_bytes: super::MAX_OUTPUT_SIZE,
46            head_bytes: 64 * 1024,
47            tail_bytes: 32 * 1024,
48            fold_repeated_lines: true,
49            repeated_line_threshold: 3,
50            structured_sample_items: 32,
51        }
52    }
53
54    pub fn validate(&self) -> Result<()> {
55        anyhow::ensure!(
56            self.schema == TOOL_RESULT_TRANSFORM_SCHEMA_V1,
57            "unsupported Tool result transform policy schema {:?}",
58            self.schema
59        );
60        anyhow::ensure!(
61            (1024..=super::MAX_OUTPUT_SIZE).contains(&self.max_output_bytes),
62            "Tool result max_output_bytes must be between 1024 and {}",
63            super::MAX_OUTPUT_SIZE
64        );
65        anyhow::ensure!(
66            self.head_bytes > 0,
67            "Tool result head_bytes must be positive"
68        );
69        let retained = self.head_bytes.saturating_add(self.tail_bytes);
70        let valid_compatibility_profile = self.tail_bytes == 0 && retained == self.max_output_bytes;
71        anyhow::ensure!(
72            valid_compatibility_profile
73                || retained.saturating_add(MARKER_RESERVE_BYTES) <= self.max_output_bytes,
74            "Tool result head_bytes + tail_bytes must reserve {MARKER_RESERVE_BYTES} bytes for transformation evidence"
75        );
76        anyhow::ensure!(
77            (2..=10_000).contains(&self.repeated_line_threshold),
78            "Tool result repeated_line_threshold must be between 2 and 10000"
79        );
80        anyhow::ensure!(
81            self.structured_sample_items <= 1024,
82            "Tool result structured_sample_items must not exceed 1024"
83        );
84        Ok(())
85    }
86}
87
88pub(crate) struct ToolResultTransform {
89    pub content: String,
90    pub loss_mode: ToolResultLossModeV1,
91    pub retained_original_bytes: usize,
92}
93
94pub(crate) fn transform(output: &str, policy: &ToolResultTransformPolicyV1) -> ToolResultTransform {
95    let mut content = output.to_string();
96    let mut transformed = false;
97
98    if policy.structured_sample_items > 0 && output.len() > policy.max_output_bytes {
99        if let Some(sampled) = sample_structured(output, policy.structured_sample_items) {
100            content = sampled;
101            transformed = true;
102        }
103    }
104    if policy.fold_repeated_lines {
105        let folded = fold_repeated_lines(&content, policy.repeated_line_threshold);
106        transformed |= folded != content;
107        content = folded;
108    }
109    if content.len() <= policy.max_output_bytes {
110        return ToolResultTransform {
111            retained_original_bytes: if transformed { 0 } else { output.len() },
112            content,
113            loss_mode: if transformed {
114                ToolResultLossModeV1::DeterministicTransform
115            } else {
116                ToolResultLossModeV1::None
117            },
118        };
119    }
120
121    let head = truncate_utf8(&content, policy.head_bytes);
122    let tail = utf8_tail(&content, policy.tail_bytes);
123    let omitted = content.len().saturating_sub(head.len() + tail.len());
124    let marker = if policy.tail_bytes == 0 && !transformed {
125        format!(
126            "\n\n[tool output truncated: showing the first {} of {} bytes. Full output is retained as an immutable artifact.]",
127            head.len(),
128            content.len()
129        )
130    } else {
131        format!(
132            "\n\n[tool output bounded by {}: omitted {} bytes between retained head/tail regions]\n\n",
133            TOOL_RESULT_TRANSFORM_ALGORITHM_V1, omitted
134        )
135    };
136    let projected = if tail.is_empty() {
137        format!("{head}{marker}")
138    } else {
139        format!("{head}{marker}{tail}")
140    };
141    ToolResultTransform {
142        content: projected,
143        loss_mode: if transformed {
144            ToolResultLossModeV1::Composite
145        } else if policy.tail_bytes == 0 {
146            ToolResultLossModeV1::BoundedPreview
147        } else {
148            ToolResultLossModeV1::HeadTail
149        },
150        retained_original_bytes: if transformed {
151            0
152        } else {
153            head.len() + tail.len()
154        },
155    }
156}
157
158fn utf8_tail(value: &str, max_bytes: usize) -> &str {
159    if max_bytes == 0 || value.is_empty() {
160        return "";
161    }
162    let mut start = value.len().saturating_sub(max_bytes);
163    while start < value.len() && !value.is_char_boundary(start) {
164        start += 1;
165    }
166    &value[start..]
167}
168
169fn fold_repeated_lines(value: &str, threshold: usize) -> String {
170    let lines = value.split_inclusive('\n').collect::<Vec<_>>();
171    if lines.len() < threshold {
172        return value.to_string();
173    }
174    let mut output = String::with_capacity(value.len());
175    let mut index = 0;
176    while index < lines.len() {
177        let mut end = index + 1;
178        while end < lines.len() && lines[end] == lines[index] {
179            end += 1;
180        }
181        let count = end - index;
182        if count >= threshold {
183            output.push_str(lines[index]);
184            output.push_str(&format!(
185                "[a3s repeated-line fold: {} additional exact copies omitted]\n",
186                count - 1
187            ));
188        } else {
189            for line in &lines[index..end] {
190                output.push_str(line);
191            }
192        }
193        index = end;
194    }
195    if output.len() < value.len() {
196        output
197    } else {
198        value.to_string()
199    }
200}
201
202fn sample_structured(value: &str, max_items: usize) -> Option<String> {
203    let Value::Array(items) = serde_json::from_str::<Value>(value).ok()? else {
204        return None;
205    };
206    if items.len() <= max_items {
207        return None;
208    }
209    let head_count = max_items.div_ceil(2);
210    let tail_count = max_items / 2;
211    let mut sampled = items[..head_count].to_vec();
212    sampled.extend_from_slice(&items[items.len() - tail_count..]);
213    serde_json::to_string(&serde_json::json!({
214        "$a3s_sample": {
215            "schema": TOOL_RESULT_TRANSFORM_ALGORITHM_V1,
216            "kind": "json_array",
217            "original_items": items.len(),
218            "retained_items": sampled.len(),
219            "omitted_items": items.len() - sampled.len(),
220        },
221        "items": sampled,
222    }))
223    .ok()
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn profiles_are_closed_and_valid() {
232        ToolResultTransformPolicyV1::conservative()
233            .validate()
234            .unwrap();
235        ToolResultTransformPolicyV1::context_efficient()
236            .validate()
237            .unwrap();
238        let mut invalid = ToolResultTransformPolicyV1::context_efficient();
239        invalid.schema = "future".into();
240        assert!(invalid.validate().is_err());
241    }
242
243    #[test]
244    fn context_profile_retains_utf8_head_and_tail() {
245        let mut policy = ToolResultTransformPolicyV1::context_efficient();
246        policy.max_output_bytes = 1024;
247        policy.head_bytes = 256;
248        policy.tail_bytes = 256;
249        policy.structured_sample_items = 0;
250        let output = format!("BEGIN-{}-END", "界".repeat(600));
251        let transformed = transform(&output, &policy);
252        assert_eq!(transformed.loss_mode, ToolResultLossModeV1::HeadTail);
253        assert!(transformed.content.starts_with("BEGIN-"));
254        assert!(transformed.content.ends_with("-END"));
255        assert!(std::str::from_utf8(transformed.content.as_bytes()).is_ok());
256    }
257
258    #[test]
259    fn folds_exact_runs_and_samples_large_json_arrays() {
260        let policy = ToolResultTransformPolicyV1::context_efficient();
261        let repeated = format!("{}\n", "same".repeat(32));
262        let folded = transform(
263            &format!("{repeated}{repeated}{repeated}{repeated}next\n"),
264            &policy,
265        );
266        assert_eq!(
267            folded.loss_mode,
268            ToolResultLossModeV1::DeterministicTransform
269        );
270        assert!(folded.content.contains("3 additional exact copies"));
271
272        let items = (0..20_000).map(Value::from).collect::<Vec<_>>();
273        let sampled = transform(&serde_json::to_string(&items).unwrap(), &policy);
274        assert!(matches!(
275            sampled.loss_mode,
276            ToolResultLossModeV1::DeterministicTransform | ToolResultLossModeV1::Composite
277        ));
278        assert!(sampled.content.contains("\"original_items\":20000"));
279    }
280}