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;
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    /// Return the stable, domain-separated identity of this exact policy.
94    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/// Bounded evidence that binds one Tool result to its exact deterministic
101/// transform algorithm and policy without copying Cloud or provider identity
102/// into Core.
103#[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}