1use super::ToolResultLossModeV1;
2use crate::text::truncate_utf8;
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7
8pub const TOOL_RESULT_TRANSFORM_SCHEMA_V1: &str = "a3s.code.tool-result-transform-policy.v1";
9pub const TOOL_RESULT_TRANSFORM_ALGORITHM_V1: &str = "a3s.code.tool-result-transform.v1";
10pub const TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1: &str =
11 "a3s.code.tool-result-transform-binding.v1";
12pub const TOOL_RESULT_TRANSFORM_BINDING_METADATA_KEY: &str = "a3s_tool_result_transform_binding";
13pub const TOOL_RESULT_TRANSFORM_POLICY_DIGEST_DOMAIN_V1: &str =
14 "a3s.code.tool-result-transform-policy-digest.v1";
15const MARKER_RESERVE_BYTES: usize = 512;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ToolResultTransformPolicyV1 {
20 pub schema: String,
21 pub max_output_bytes: usize,
22 pub head_bytes: usize,
23 pub tail_bytes: usize,
24 pub fold_repeated_lines: bool,
25 pub repeated_line_threshold: usize,
26 pub structured_sample_items: usize,
27}
28
29impl Default for ToolResultTransformPolicyV1 {
30 fn default() -> Self {
31 Self::conservative()
32 }
33}
34
35impl ToolResultTransformPolicyV1 {
36 pub fn conservative() -> Self {
37 Self {
38 schema: TOOL_RESULT_TRANSFORM_SCHEMA_V1.to_string(),
39 max_output_bytes: super::MAX_OUTPUT_SIZE,
40 head_bytes: super::MAX_OUTPUT_SIZE,
41 tail_bytes: 0,
42 fold_repeated_lines: false,
43 repeated_line_threshold: 3,
44 structured_sample_items: 0,
45 }
46 }
47
48 pub fn context_efficient() -> Self {
49 Self {
50 schema: TOOL_RESULT_TRANSFORM_SCHEMA_V1.to_string(),
51 max_output_bytes: super::MAX_OUTPUT_SIZE,
52 head_bytes: 64 * 1024,
53 tail_bytes: 32 * 1024,
54 fold_repeated_lines: true,
55 repeated_line_threshold: 3,
56 structured_sample_items: 32,
57 }
58 }
59
60 pub fn validate(&self) -> Result<()> {
61 anyhow::ensure!(
62 self.schema == TOOL_RESULT_TRANSFORM_SCHEMA_V1,
63 "unsupported Tool result transform policy schema {:?}",
64 self.schema
65 );
66 anyhow::ensure!(
67 (1024..=super::MAX_OUTPUT_SIZE).contains(&self.max_output_bytes),
68 "Tool result max_output_bytes must be between 1024 and {}",
69 super::MAX_OUTPUT_SIZE
70 );
71 anyhow::ensure!(
72 self.head_bytes > 0,
73 "Tool result head_bytes must be positive"
74 );
75 let retained = self.head_bytes.saturating_add(self.tail_bytes);
76 let valid_compatibility_profile = self.tail_bytes == 0 && retained == self.max_output_bytes;
77 anyhow::ensure!(
78 valid_compatibility_profile
79 || retained.saturating_add(MARKER_RESERVE_BYTES) <= self.max_output_bytes,
80 "Tool result head_bytes + tail_bytes must reserve {MARKER_RESERVE_BYTES} bytes for transformation evidence"
81 );
82 anyhow::ensure!(
83 (2..=10_000).contains(&self.repeated_line_threshold),
84 "Tool result repeated_line_threshold must be between 2 and 10000"
85 );
86 anyhow::ensure!(
87 self.structured_sample_items <= 1024,
88 "Tool result structured_sample_items must not exceed 1024"
89 );
90 Ok(())
91 }
92
93 pub fn policy_digest(&self) -> Result<String> {
95 self.validate()?;
96 canonical_digest(TOOL_RESULT_TRANSFORM_POLICY_DIGEST_DOMAIN_V1, self)
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct ToolResultTransformBindingV1 {
106 pub schema: String,
107 pub transform_algorithm: String,
108 pub policy_digest: String,
109 pub binding_digest: String,
110}
111
112impl ToolResultTransformBindingV1 {
113 pub fn from_policy(policy: &ToolResultTransformPolicyV1) -> Result<Self> {
114 let mut binding = Self {
115 schema: TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1.to_string(),
116 transform_algorithm: TOOL_RESULT_TRANSFORM_ALGORITHM_V1.to_string(),
117 policy_digest: policy.policy_digest()?,
118 binding_digest: String::new(),
119 };
120 binding.binding_digest = binding.expected_digest()?;
121 binding.validate()?;
122 Ok(binding)
123 }
124
125 pub fn validate(&self) -> Result<()> {
126 anyhow::ensure!(
127 self.schema == TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1,
128 "unsupported Tool result transform binding schema {:?}",
129 self.schema
130 );
131 anyhow::ensure!(
132 self.transform_algorithm == TOOL_RESULT_TRANSFORM_ALGORITHM_V1,
133 "unsupported Tool result transform algorithm {:?}",
134 self.transform_algorithm
135 );
136 anyhow::ensure!(
137 valid_sha256(&self.policy_digest),
138 "Tool result transform policy_digest must be canonical lowercase SHA-256"
139 );
140 anyhow::ensure!(
141 valid_sha256(&self.binding_digest),
142 "Tool result transform binding_digest must be canonical lowercase SHA-256"
143 );
144 anyhow::ensure!(
145 self.binding_digest == self.expected_digest()?,
146 "Tool result transform binding_digest does not bind the exact algorithm and policy"
147 );
148 Ok(())
149 }
150
151 pub fn validate_for_policy(&self, policy: &ToolResultTransformPolicyV1) -> Result<()> {
152 self.validate()?;
153 anyhow::ensure!(
154 self.policy_digest == policy.policy_digest()?,
155 "Tool result transform binding does not match the exact policy"
156 );
157 Ok(())
158 }
159
160 fn expected_digest(&self) -> Result<String> {
161 #[derive(Serialize)]
162 struct DigestInput<'a> {
163 schema: &'a str,
164 transform_algorithm: &'a str,
165 policy_digest: &'a str,
166 }
167
168 canonical_digest(
169 TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1,
170 &DigestInput {
171 schema: &self.schema,
172 transform_algorithm: &self.transform_algorithm,
173 policy_digest: &self.policy_digest,
174 },
175 )
176 }
177}
178
179fn canonical_digest(domain: &str, value: &impl Serialize) -> Result<String> {
180 let encoded = serde_json::to_vec(value).map_err(|error| {
181 anyhow::anyhow!("could not encode Tool result transform identity: {error}")
182 })?;
183 let mut hasher = Sha256::new();
184 hasher.update(domain.as_bytes());
185 hasher.update([0]);
186 hasher.update(encoded);
187 Ok(format!("sha256:{:x}", hasher.finalize()))
188}
189
190fn valid_sha256(value: &str) -> bool {
191 value.strip_prefix("sha256:").is_some_and(|hex| {
192 hex.len() == 64
193 && hex
194 .bytes()
195 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
196 })
197}
198
199pub(crate) struct ToolResultTransform {
200 pub content: String,
201 pub loss_mode: ToolResultLossModeV1,
202 pub retained_original_bytes: usize,
203}
204
205pub(crate) fn transform(output: &str, policy: &ToolResultTransformPolicyV1) -> ToolResultTransform {
206 let mut content = output.to_string();
207 let mut transformed = false;
208
209 if policy.structured_sample_items > 0 && output.len() > policy.max_output_bytes {
210 if let Some(sampled) = sample_structured(output, policy.structured_sample_items) {
211 content = sampled;
212 transformed = true;
213 }
214 }
215 if policy.fold_repeated_lines {
216 let folded = fold_repeated_lines(&content, policy.repeated_line_threshold);
217 transformed |= folded != content;
218 content = folded;
219 }
220 if content.len() <= policy.max_output_bytes {
221 return ToolResultTransform {
222 retained_original_bytes: if transformed { 0 } else { output.len() },
223 content,
224 loss_mode: if transformed {
225 ToolResultLossModeV1::DeterministicTransform
226 } else {
227 ToolResultLossModeV1::None
228 },
229 };
230 }
231
232 let head = truncate_utf8(&content, policy.head_bytes);
233 let tail = utf8_tail(&content, policy.tail_bytes);
234 let omitted = content.len().saturating_sub(head.len() + tail.len());
235 let marker = if policy.tail_bytes == 0 && !transformed {
236 format!(
237 "\n\n[tool output truncated: showing the first {} of {} bytes. Full output is retained as an immutable artifact.]",
238 head.len(),
239 content.len()
240 )
241 } else {
242 format!(
243 "\n\n[tool output bounded by {}: omitted {} bytes between retained head/tail regions]\n\n",
244 TOOL_RESULT_TRANSFORM_ALGORITHM_V1, omitted
245 )
246 };
247 let projected = if tail.is_empty() {
248 format!("{head}{marker}")
249 } else {
250 format!("{head}{marker}{tail}")
251 };
252 ToolResultTransform {
253 content: projected,
254 loss_mode: if transformed {
255 ToolResultLossModeV1::Composite
256 } else if policy.tail_bytes == 0 {
257 ToolResultLossModeV1::BoundedPreview
258 } else {
259 ToolResultLossModeV1::HeadTail
260 },
261 retained_original_bytes: if transformed {
262 0
263 } else {
264 head.len() + tail.len()
265 },
266 }
267}
268
269fn utf8_tail(value: &str, max_bytes: usize) -> &str {
270 if max_bytes == 0 || value.is_empty() {
271 return "";
272 }
273 let mut start = value.len().saturating_sub(max_bytes);
274 while start < value.len() && !value.is_char_boundary(start) {
275 start += 1;
276 }
277 &value[start..]
278}
279
280fn fold_repeated_lines(value: &str, threshold: usize) -> String {
281 let lines = value.split_inclusive('\n').collect::<Vec<_>>();
282 if lines.len() < threshold {
283 return value.to_string();
284 }
285 let mut output = String::with_capacity(value.len());
286 let mut index = 0;
287 while index < lines.len() {
288 let mut end = index + 1;
289 while end < lines.len() && lines[end] == lines[index] {
290 end += 1;
291 }
292 let count = end - index;
293 if count >= threshold {
294 output.push_str(lines[index]);
295 output.push_str(&format!(
296 "[a3s repeated-line fold: {} additional exact copies omitted]\n",
297 count - 1
298 ));
299 } else {
300 for line in &lines[index..end] {
301 output.push_str(line);
302 }
303 }
304 index = end;
305 }
306 if output.len() < value.len() {
307 output
308 } else {
309 value.to_string()
310 }
311}
312
313fn sample_structured(value: &str, max_items: usize) -> Option<String> {
314 let Value::Array(items) = serde_json::from_str::<Value>(value).ok()? else {
315 return None;
316 };
317 if items.len() <= max_items {
318 return None;
319 }
320 let head_count = max_items.div_ceil(2);
321 let tail_count = max_items / 2;
322 let mut sampled = items[..head_count].to_vec();
323 sampled.extend_from_slice(&items[items.len() - tail_count..]);
324 serde_json::to_string(&serde_json::json!({
325 "$a3s_sample": {
326 "schema": TOOL_RESULT_TRANSFORM_ALGORITHM_V1,
327 "kind": "json_array",
328 "original_items": items.len(),
329 "retained_items": sampled.len(),
330 "omitted_items": items.len() - sampled.len(),
331 },
332 "items": sampled,
333 }))
334 .ok()
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn profiles_are_closed_and_valid() {
343 ToolResultTransformPolicyV1::conservative()
344 .validate()
345 .unwrap();
346 ToolResultTransformPolicyV1::context_efficient()
347 .validate()
348 .unwrap();
349 let mut invalid = ToolResultTransformPolicyV1::context_efficient();
350 invalid.schema = "future".into();
351 assert!(invalid.validate().is_err());
352 }
353
354 #[test]
355 fn binding_is_stable_and_rejects_policy_or_evidence_drift() {
356 let policy = ToolResultTransformPolicyV1::context_efficient();
357 let binding = ToolResultTransformBindingV1::from_policy(&policy).unwrap();
358
359 assert_eq!(
360 binding,
361 ToolResultTransformBindingV1::from_policy(&policy).unwrap()
362 );
363 assert_eq!(
364 binding.policy_digest,
365 "sha256:645f65e5d39e3f7aa77fade21ae2daa1e8ccbbc7a0775c94a7f2c38ec5f5b32d"
366 );
367 assert_eq!(
368 binding.binding_digest,
369 "sha256:906e9931692fa7860b7acb5fc0bb5c329f19aeb04976c913750893ad99cd5a27"
370 );
371 binding.validate_for_policy(&policy).unwrap();
372
373 let mut drifted_policy = policy.clone();
374 drifted_policy.structured_sample_items += 1;
375 assert!(binding.validate_for_policy(&drifted_policy).is_err());
376
377 let mut drifted_binding = binding;
378 drifted_binding.policy_digest = format!("sha256:{}", "0".repeat(64));
379 assert!(drifted_binding.validate().is_err());
380 }
381
382 #[test]
383 fn context_profile_retains_utf8_head_and_tail() {
384 let mut policy = ToolResultTransformPolicyV1::context_efficient();
385 policy.max_output_bytes = 1024;
386 policy.head_bytes = 256;
387 policy.tail_bytes = 256;
388 policy.structured_sample_items = 0;
389 let output = format!("BEGIN-{}-END", "界".repeat(600));
390 let transformed = transform(&output, &policy);
391 assert_eq!(transformed.loss_mode, ToolResultLossModeV1::HeadTail);
392 assert!(transformed.content.starts_with("BEGIN-"));
393 assert!(transformed.content.ends_with("-END"));
394 assert!(std::str::from_utf8(transformed.content.as_bytes()).is_ok());
395 }
396
397 #[test]
398 fn folds_exact_runs_and_samples_large_json_arrays() {
399 let policy = ToolResultTransformPolicyV1::context_efficient();
400 let repeated = format!("{}\n", "same".repeat(32));
401 let folded = transform(
402 &format!("{repeated}{repeated}{repeated}{repeated}next\n"),
403 &policy,
404 );
405 assert_eq!(
406 folded.loss_mode,
407 ToolResultLossModeV1::DeterministicTransform
408 );
409 assert!(folded.content.contains("3 additional exact copies"));
410
411 let items = (0..20_000).map(Value::from).collect::<Vec<_>>();
412 let sampled = transform(&serde_json::to_string(&items).unwrap(), &policy);
413 assert!(matches!(
414 sampled.loss_mode,
415 ToolResultLossModeV1::DeterministicTransform | ToolResultLossModeV1::Composite
416 ));
417 assert!(sampled.content.contains("\"original_items\":20000"));
418 }
419}