Skip to main content

a3s_code_core/tools/
result_transform.rs

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    /// Return the stable, domain-separated identity of this exact policy.
98    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/// Bounded evidence that binds one Tool result to its exact deterministic
105/// transform algorithm and policy without copying Cloud or provider identity
106/// into Core.
107#[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    // Avoid copying an unmodified result before we know that a transform is
211    // needed. Tool output is untrusted and can be substantially larger than
212    // the model-facing budget.
213    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                // Delay allocation until a fold is actually found. The
312                // original prefix is copied exactly once at that point.
313                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    // Consume one array element at a time. Keeping only the head sample and a
335    // bounded tail avoids materializing an attacker-controlled `Vec<Value>`.
336    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        // `validate` normally enforces this bound before a policy reaches the
377        // transform. Clamp again at the parser boundary so an invalid or
378        // host-constructed policy cannot request an enormous preallocation.
379        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}