1use super::ToolResultLossModeV1;
2use crate::text::truncate_utf8;
3use anyhow::Result;
4use serde::de::{Deserializer, SeqAccess, Visitor};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use sha2::{Digest, Sha256};
8use std::borrow::Cow;
9use std::collections::VecDeque;
10
11pub const TOOL_RESULT_TRANSFORM_SCHEMA_V1: &str = "a3s.code.tool-result-transform-policy.v1";
12pub const TOOL_RESULT_TRANSFORM_ALGORITHM_V1: &str = "a3s.code.tool-result-transform.v1";
13pub const TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1: &str =
14 "a3s.code.tool-result-transform-binding.v1";
15pub const TOOL_RESULT_TRANSFORM_BINDING_METADATA_KEY: &str = "a3s_tool_result_transform_binding";
16pub const TOOL_RESULT_TRANSFORM_POLICY_DIGEST_DOMAIN_V1: &str =
17 "a3s.code.tool-result-transform-policy-digest.v1";
18const MARKER_RESERVE_BYTES: usize = 512;
19const MAX_STRUCTURED_SAMPLE_ITEMS: usize = 1024;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct ToolResultTransformPolicyV1 {
24 pub schema: String,
25 pub max_output_bytes: usize,
26 pub head_bytes: usize,
27 pub tail_bytes: usize,
28 pub fold_repeated_lines: bool,
29 pub repeated_line_threshold: usize,
30 pub structured_sample_items: usize,
31}
32
33impl Default for ToolResultTransformPolicyV1 {
34 fn default() -> Self {
35 Self::conservative()
36 }
37}
38
39impl ToolResultTransformPolicyV1 {
40 pub fn conservative() -> Self {
41 Self {
42 schema: TOOL_RESULT_TRANSFORM_SCHEMA_V1.to_string(),
43 max_output_bytes: super::MAX_OUTPUT_SIZE,
44 head_bytes: super::MAX_OUTPUT_SIZE,
45 tail_bytes: 0,
46 fold_repeated_lines: false,
47 repeated_line_threshold: 3,
48 structured_sample_items: 0,
49 }
50 }
51
52 pub fn context_efficient() -> Self {
53 Self {
54 schema: TOOL_RESULT_TRANSFORM_SCHEMA_V1.to_string(),
55 max_output_bytes: super::MAX_OUTPUT_SIZE,
56 head_bytes: 64 * 1024,
57 tail_bytes: 32 * 1024,
58 fold_repeated_lines: true,
59 repeated_line_threshold: 3,
60 structured_sample_items: 32,
61 }
62 }
63
64 pub fn validate(&self) -> Result<()> {
65 anyhow::ensure!(
66 self.schema == TOOL_RESULT_TRANSFORM_SCHEMA_V1,
67 "unsupported Tool result transform policy schema {:?}",
68 self.schema
69 );
70 anyhow::ensure!(
71 (1024..=super::MAX_OUTPUT_SIZE).contains(&self.max_output_bytes),
72 "Tool result max_output_bytes must be between 1024 and {}",
73 super::MAX_OUTPUT_SIZE
74 );
75 anyhow::ensure!(
76 self.head_bytes > 0,
77 "Tool result head_bytes must be positive"
78 );
79 let retained = self.head_bytes.saturating_add(self.tail_bytes);
80 let valid_compatibility_profile = self.tail_bytes == 0 && retained == self.max_output_bytes;
81 anyhow::ensure!(
82 valid_compatibility_profile
83 || retained.saturating_add(MARKER_RESERVE_BYTES) <= self.max_output_bytes,
84 "Tool result head_bytes + tail_bytes must reserve {MARKER_RESERVE_BYTES} bytes for transformation evidence"
85 );
86 anyhow::ensure!(
87 (2..=10_000).contains(&self.repeated_line_threshold),
88 "Tool result repeated_line_threshold must be between 2 and 10000"
89 );
90 anyhow::ensure!(
91 self.structured_sample_items <= MAX_STRUCTURED_SAMPLE_ITEMS,
92 "Tool result structured_sample_items must not exceed {MAX_STRUCTURED_SAMPLE_ITEMS}"
93 );
94 Ok(())
95 }
96
97 pub fn policy_digest(&self) -> Result<String> {
99 self.validate()?;
100 canonical_digest(TOOL_RESULT_TRANSFORM_POLICY_DIGEST_DOMAIN_V1, self)
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct ToolResultTransformBindingV1 {
110 pub schema: String,
111 pub transform_algorithm: String,
112 pub policy_digest: String,
113 pub binding_digest: String,
114}
115
116impl ToolResultTransformBindingV1 {
117 pub fn from_policy(policy: &ToolResultTransformPolicyV1) -> Result<Self> {
118 let mut binding = Self {
119 schema: TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1.to_string(),
120 transform_algorithm: TOOL_RESULT_TRANSFORM_ALGORITHM_V1.to_string(),
121 policy_digest: policy.policy_digest()?,
122 binding_digest: String::new(),
123 };
124 binding.binding_digest = binding.expected_digest()?;
125 binding.validate()?;
126 Ok(binding)
127 }
128
129 pub fn validate(&self) -> Result<()> {
130 anyhow::ensure!(
131 self.schema == TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1,
132 "unsupported Tool result transform binding schema {:?}",
133 self.schema
134 );
135 anyhow::ensure!(
136 self.transform_algorithm == TOOL_RESULT_TRANSFORM_ALGORITHM_V1,
137 "unsupported Tool result transform algorithm {:?}",
138 self.transform_algorithm
139 );
140 anyhow::ensure!(
141 valid_sha256(&self.policy_digest),
142 "Tool result transform policy_digest must be canonical lowercase SHA-256"
143 );
144 anyhow::ensure!(
145 valid_sha256(&self.binding_digest),
146 "Tool result transform binding_digest must be canonical lowercase SHA-256"
147 );
148 anyhow::ensure!(
149 self.binding_digest == self.expected_digest()?,
150 "Tool result transform binding_digest does not bind the exact algorithm and policy"
151 );
152 Ok(())
153 }
154
155 pub fn validate_for_policy(&self, policy: &ToolResultTransformPolicyV1) -> Result<()> {
156 self.validate()?;
157 anyhow::ensure!(
158 self.policy_digest == policy.policy_digest()?,
159 "Tool result transform binding does not match the exact policy"
160 );
161 Ok(())
162 }
163
164 fn expected_digest(&self) -> Result<String> {
165 #[derive(Serialize)]
166 struct DigestInput<'a> {
167 schema: &'a str,
168 transform_algorithm: &'a str,
169 policy_digest: &'a str,
170 }
171
172 canonical_digest(
173 TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1,
174 &DigestInput {
175 schema: &self.schema,
176 transform_algorithm: &self.transform_algorithm,
177 policy_digest: &self.policy_digest,
178 },
179 )
180 }
181}
182
183fn canonical_digest(domain: &str, value: &impl Serialize) -> Result<String> {
184 let encoded = serde_json::to_vec(value).map_err(|error| {
185 anyhow::anyhow!("could not encode Tool result transform identity: {error}")
186 })?;
187 let mut hasher = Sha256::new();
188 hasher.update(domain.as_bytes());
189 hasher.update([0]);
190 hasher.update(encoded);
191 Ok(format!("sha256:{:x}", hasher.finalize()))
192}
193
194fn valid_sha256(value: &str) -> bool {
195 value.strip_prefix("sha256:").is_some_and(|hex| {
196 hex.len() == 64
197 && hex
198 .bytes()
199 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
200 })
201}
202
203pub(crate) struct ToolResultTransform {
204 pub content: String,
205 pub loss_mode: ToolResultLossModeV1,
206 pub retained_original_bytes: usize,
207}
208
209pub(crate) fn transform(output: &str, policy: &ToolResultTransformPolicyV1) -> ToolResultTransform {
210 let mut content: Cow<'_, str> = Cow::Borrowed(output);
214 let mut transformed = false;
215
216 if policy.structured_sample_items > 0 && output.len() > policy.max_output_bytes {
217 if let Some(sampled) = sample_structured(output, policy.structured_sample_items) {
218 content = Cow::Owned(sampled);
219 transformed = true;
220 }
221 }
222 if policy.fold_repeated_lines {
223 if let Some(folded) = fold_repeated_lines(content.as_ref(), policy.repeated_line_threshold)
224 {
225 content = Cow::Owned(folded);
226 transformed = true;
227 }
228 }
229 if content.len() <= policy.max_output_bytes {
230 return ToolResultTransform {
231 retained_original_bytes: if transformed { 0 } else { output.len() },
232 content: content.into_owned(),
233 loss_mode: if transformed {
234 ToolResultLossModeV1::DeterministicTransform
235 } else {
236 ToolResultLossModeV1::None
237 },
238 };
239 }
240
241 let head = truncate_utf8(content.as_ref(), policy.head_bytes);
242 let tail = utf8_tail(content.as_ref(), policy.tail_bytes);
243 let omitted = content.len().saturating_sub(head.len() + tail.len());
244 let marker = if policy.tail_bytes == 0 && !transformed {
245 format!(
246 "\n\n[tool output truncated: showing the first {} of {} bytes. Full output is retained as an immutable artifact.]",
247 head.len(),
248 content.len()
249 )
250 } else {
251 format!(
252 "\n\n[tool output bounded by {}: omitted {} bytes between retained head/tail regions]\n\n",
253 TOOL_RESULT_TRANSFORM_ALGORITHM_V1, omitted
254 )
255 };
256 let projected = if tail.is_empty() {
257 format!("{head}{marker}")
258 } else {
259 format!("{head}{marker}{tail}")
260 };
261 ToolResultTransform {
262 content: projected,
263 loss_mode: if transformed {
264 ToolResultLossModeV1::Composite
265 } else if policy.tail_bytes == 0 {
266 ToolResultLossModeV1::BoundedPreview
267 } else {
268 ToolResultLossModeV1::HeadTail
269 },
270 retained_original_bytes: if transformed {
271 0
272 } else {
273 head.len() + tail.len()
274 },
275 }
276}
277
278fn utf8_tail(value: &str, max_bytes: usize) -> &str {
279 if max_bytes == 0 || value.is_empty() {
280 return "";
281 }
282 let mut start = value.len().saturating_sub(max_bytes);
283 while start < value.len() && !value.is_char_boundary(start) {
284 start += 1;
285 }
286 &value[start..]
287}
288
289fn fold_repeated_lines(value: &str, threshold: usize) -> Option<String> {
290 let mut lines = value.split_inclusive('\n').peekable();
291 let mut output = String::new();
292 let mut folded_run = false;
293 let mut cursor = 0;
294
295 while let Some(line) = lines.next() {
296 let line_start = cursor;
297 cursor += line.len();
298 let mut count = 1;
299 while let Some(next) = lines.peek().copied() {
300 if next != line {
301 break;
302 }
303 let Some(next) = lines.next() else {
304 break;
305 };
306 cursor += next.len();
307 count += 1;
308 }
309 if count >= threshold {
310 if !folded_run {
311 output.push_str(&value[..line_start]);
314 }
315 output.push_str(line);
316 output.push_str(&format!(
317 "[a3s repeated-line fold: {} additional exact copies omitted]\n",
318 count - 1
319 ));
320 folded_run = true;
321 } else if folded_run {
322 output.push_str(line);
323 }
324 }
325
326 if folded_run && output.len() < value.len() {
327 Some(output)
328 } else {
329 None
330 }
331}
332
333fn sample_structured(value: &str, max_items: usize) -> Option<String> {
334 let mut deserializer = serde_json::Deserializer::from_str(value);
337 let sample = deserializer
338 .deserialize_any(JsonArraySampler::new(max_items))
339 .ok()??;
340 deserializer.end().ok()?;
341 let (original_items, sampled) = sample;
342 serde_json::to_string(&serde_json::json!({
343 "$a3s_sample": {
344 "schema": TOOL_RESULT_TRANSFORM_ALGORITHM_V1,
345 "kind": "json_array",
346 "original_items": original_items,
347 "retained_items": sampled.len(),
348 "omitted_items": original_items - sampled.len(),
349 },
350 "items": sampled,
351 }))
352 .ok()
353}
354
355struct JsonArraySampler {
356 max_items: usize,
357}
358
359impl JsonArraySampler {
360 fn new(max_items: usize) -> Self {
361 Self { max_items }
362 }
363}
364
365impl<'de> Visitor<'de> for JsonArraySampler {
366 type Value = Option<(usize, Vec<Value>)>;
367
368 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 formatter.write_str("a JSON array")
370 }
371
372 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
373 where
374 A: SeqAccess<'de>,
375 {
376 let max_items = self.max_items.min(MAX_STRUCTURED_SAMPLE_ITEMS);
380 let head_count = max_items.div_ceil(2);
381 let tail_count = max_items / 2;
382 let mut head = Vec::with_capacity(head_count);
383 let mut tail = VecDeque::with_capacity(tail_count);
384 let mut original_items = 0;
385
386 while let Some(item) = sequence.next_element::<Value>()? {
387 original_items += 1;
388 if head.len() < head_count {
389 head.push(item);
390 } else if tail_count > 0 {
391 if tail.len() == tail_count {
392 tail.pop_front();
393 }
394 tail.push_back(item);
395 }
396 }
397
398 if original_items <= max_items {
399 return Ok(None);
400 }
401 head.extend(tail);
402 Ok(Some((original_items, head)))
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn profiles_are_closed_and_valid() {
412 ToolResultTransformPolicyV1::conservative()
413 .validate()
414 .unwrap();
415 ToolResultTransformPolicyV1::context_efficient()
416 .validate()
417 .unwrap();
418 let mut invalid = ToolResultTransformPolicyV1::context_efficient();
419 invalid.schema = "future".into();
420 assert!(invalid.validate().is_err());
421 }
422
423 #[test]
424 fn binding_is_stable_and_rejects_policy_or_evidence_drift() {
425 let policy = ToolResultTransformPolicyV1::context_efficient();
426 let binding = ToolResultTransformBindingV1::from_policy(&policy).unwrap();
427
428 assert_eq!(
429 binding,
430 ToolResultTransformBindingV1::from_policy(&policy).unwrap()
431 );
432 assert_eq!(
433 binding.policy_digest,
434 "sha256:645f65e5d39e3f7aa77fade21ae2daa1e8ccbbc7a0775c94a7f2c38ec5f5b32d"
435 );
436 assert_eq!(
437 binding.binding_digest,
438 "sha256:906e9931692fa7860b7acb5fc0bb5c329f19aeb04976c913750893ad99cd5a27"
439 );
440 binding.validate_for_policy(&policy).unwrap();
441
442 let mut drifted_policy = policy.clone();
443 drifted_policy.structured_sample_items += 1;
444 assert!(binding.validate_for_policy(&drifted_policy).is_err());
445
446 let mut drifted_binding = binding;
447 drifted_binding.policy_digest = format!("sha256:{}", "0".repeat(64));
448 assert!(drifted_binding.validate().is_err());
449 }
450
451 #[test]
452 fn context_profile_retains_utf8_head_and_tail() {
453 let mut policy = ToolResultTransformPolicyV1::context_efficient();
454 policy.max_output_bytes = 1024;
455 policy.head_bytes = 256;
456 policy.tail_bytes = 256;
457 policy.structured_sample_items = 0;
458 let output = format!("BEGIN-{}-END", "界".repeat(600));
459 let transformed = transform(&output, &policy);
460 assert_eq!(transformed.loss_mode, ToolResultLossModeV1::HeadTail);
461 assert!(transformed.content.starts_with("BEGIN-"));
462 assert!(transformed.content.ends_with("-END"));
463 assert!(std::str::from_utf8(transformed.content.as_bytes()).is_ok());
464 }
465
466 #[test]
467 fn folds_exact_runs_and_samples_large_json_arrays() {
468 let policy = ToolResultTransformPolicyV1::context_efficient();
469 let repeated = format!("{}\n", "same".repeat(32));
470 let folded = transform(
471 &format!("{repeated}{repeated}{repeated}{repeated}next\n"),
472 &policy,
473 );
474 assert_eq!(
475 folded.loss_mode,
476 ToolResultLossModeV1::DeterministicTransform
477 );
478 assert!(folded.content.contains("3 additional exact copies"));
479
480 let items = (0..20_000).map(Value::from).collect::<Vec<_>>();
481 let sampled = transform(&serde_json::to_string(&items).unwrap(), &policy);
482 assert!(matches!(
483 sampled.loss_mode,
484 ToolResultLossModeV1::DeterministicTransform | ToolResultLossModeV1::Composite
485 ));
486 assert!(sampled.content.contains("\"original_items\":20000"));
487 }
488
489 #[test]
490 fn folding_preserves_lines_before_the_first_repeated_run() {
491 let policy = ToolResultTransformPolicyV1::context_efficient();
492 let repeated = "same".repeat(32);
493 let output = format!("prefix\n{repeated}\n{repeated}\n{repeated}\nsuffix\n");
494 let transformed = transform(&output, &policy);
495
496 assert_eq!(
497 transformed.loss_mode,
498 ToolResultLossModeV1::DeterministicTransform
499 );
500 assert!(transformed
501 .content
502 .starts_with(&format!("prefix\n{repeated}\n")));
503 assert!(transformed.content.contains("2 additional exact copies"));
504 assert!(transformed.content.ends_with("suffix\n"));
505 }
506
507 #[test]
508 fn structured_sampling_keeps_a_bounded_working_set_for_large_arrays() {
509 let policy = ToolResultTransformPolicyV1::context_efficient();
510 let items = (0..250_000).map(Value::from).collect::<Vec<_>>();
511 let output = serde_json::to_string(&items).unwrap();
512 let transformed = transform(&output, &policy);
513
514 assert!(transformed.content.contains("\"original_items\":250000"));
515 assert!(transformed.content.contains("\"retained_items\":32"));
516 assert_eq!(
517 transformed.loss_mode,
518 ToolResultLossModeV1::DeterministicTransform
519 );
520 }
521}