a3s_code_core/tools/
result_transform.rs1use 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}