Skip to main content

opencode_ctl/
compact.rs

1//! Output compaction, byte caps and redaction.
2
3use serde::Serialize;
4use serde_json::{json, Map, Value};
5
6use crate::error::{ErrorCode, ToolError};
7
8/// Bytes reserved inside the budget for the `truncated` / `original_bytes` keys (and a wrapper for a non-object root).
9const MARKER_RESERVE: usize = 64;
10/// A string at or below this length is never shortened (the marker would not save bytes).
11const MIN_SHORTEN_LEN: usize = 64;
12/// Extra bytes removed past the excess when shortening a string, so the `...[+N bytes]` tail always nets a saving.
13const SHORTEN_SLACK: usize = 40;
14
15#[derive(Debug, Clone)]
16pub struct Cap {
17    pub max_bytes: usize,
18    pub exempt_arrays: &'static [&'static str],
19    pub exempt_entry_max: usize,
20    pub exempt_len_max: usize,
21}
22
23fn scalar_len(v: &Value) -> usize {
24    serde_json::to_string(v).map(|s| s.len()).unwrap_or(0)
25}
26
27/// Serialised length of `v`, counting an array under an exempt key as `[]`.
28fn size(v: &Value, skip: &[&str]) -> usize {
29    match v {
30        Value::Array(a) => {
31            2 + a.iter().map(|e| size(e, skip)).sum::<usize>() + a.len().saturating_sub(1)
32        }
33        Value::Object(o) => {
34            2 + o
35                .iter()
36                .map(|(k, e)| {
37                    let key = scalar_len(&Value::String(k.clone())) + 1;
38                    if e.is_array() && skip.contains(&k.as_str()) {
39                        key + 2
40                    } else {
41                        key + size(e, skip)
42                    }
43                })
44                .sum::<usize>()
45                + o.len().saturating_sub(1)
46        }
47        other => scalar_len(other),
48    }
49}
50
51/// Visit every child value that is not an exempt array.
52fn children<'a>(v: &'a Value, skip: &[&str], out: &mut Vec<&'a Value>) {
53    match v {
54        Value::Array(a) => out.extend(a.iter()),
55        Value::Object(o) => out.extend(
56            o.iter()
57                .filter(|(k, e)| !(e.is_array() && skip.contains(&k.as_str())))
58                .map(|(_, e)| e),
59        ),
60        _ => {}
61    }
62}
63
64fn largest(v: &Value, skip: &[&str], pick: &dyn Fn(&Value) -> Option<usize>, best: &mut usize) {
65    if let Some(n) = pick(v) {
66        *best = (*best).max(n);
67    }
68    let mut kids = Vec::new();
69    children(v, skip, &mut kids);
70    for k in kids {
71        largest(k, skip, pick, best);
72    }
73}
74
75fn for_first_mut(
76    v: &mut Value,
77    skip: &[&str],
78    pick: &dyn Fn(&Value) -> Option<usize>,
79    target: usize,
80    act: &mut dyn FnMut(&mut Value),
81) -> bool {
82    if pick(v) == Some(target) {
83        act(v);
84        return true;
85    }
86    match v {
87        Value::Array(a) => a
88            .iter_mut()
89            .any(|e| for_first_mut(e, skip, pick, target, act)),
90        Value::Object(o) => o
91            .iter_mut()
92            .filter(|(k, e)| !(e.is_array() && skip.contains(&k.as_str())))
93            .any(|(_, e)| for_first_mut(e, skip, pick, target, act)),
94        _ => false,
95    }
96}
97
98fn cut_array_tail(a: &mut Vec<Value>, excess: usize, skip: &[&str]) {
99    let mut removed = 0usize;
100    while removed < excess {
101        match a.pop() {
102            Some(e) => removed += size(&e, skip) + 1,
103            None => break,
104        }
105    }
106}
107
108fn shorten_string(s: &mut String, excess: usize) {
109    let removed = (excess + SHORTEN_SLACK).min(s.len());
110    let mut keep = s.len() - removed;
111    while !s.is_char_boundary(keep) {
112        keep -= 1;
113    }
114    let gone = s.len() - keep;
115    s.truncate(keep);
116    s.push_str(&format!("...[+{gone} bytes]"));
117}
118
119fn cut_object_tail(o: &mut Map<String, Value>, excess: usize, skip: &[&str]) {
120    let mut removed = 0usize;
121    while removed < excess {
122        match o.keys().next_back().cloned() {
123            Some(k) => {
124                if let Some(e) = o.remove(&k) {
125                    removed += size(&e, skip) + scalar_len(&Value::String(k)) + 2;
126                }
127            }
128            None => break,
129        }
130    }
131}
132
133/// Reduce `v` until `size(v, skip) <= budget`: arrays cut at element boundaries, then the longest strings shortened,
134/// then (last resort, so the bound always holds) object entries dropped.
135fn shrink(v: &mut Value, budget: usize, skip: &[&str]) {
136    let arr_len = |v: &Value| match v {
137        Value::Array(a) if !a.is_empty() => Some(size(v, skip)),
138        _ => None,
139    };
140    let str_len = |v: &Value| match v {
141        Value::String(s) if s.len() > MIN_SHORTEN_LEN => Some(s.len()),
142        _ => None,
143    };
144    let obj_len = |v: &Value| match v {
145        Value::Object(o) if !o.is_empty() => Some(size(v, skip)),
146        _ => None,
147    };
148    loop {
149        let cur = size(v, skip);
150        if cur <= budget {
151            return;
152        }
153        let excess = cur - budget;
154        let mut best = 0usize;
155        largest(v, skip, &arr_len, &mut best);
156        if best > 0 {
157            for_first_mut(v, skip, &arr_len, best, &mut |n| {
158                if let Value::Array(a) = n {
159                    cut_array_tail(a, excess, skip)
160                }
161            });
162            continue;
163        }
164        largest(v, skip, &str_len, &mut best);
165        if best > 0 {
166            for_first_mut(v, skip, &str_len, best, &mut |n| {
167                if let Value::String(s) = n {
168                    shorten_string(s, excess)
169                }
170            });
171            continue;
172        }
173        largest(v, skip, &obj_len, &mut best);
174        if best > 0 {
175            for_first_mut(v, skip, &obj_len, best, &mut |n| {
176                if let Value::Object(o) = n {
177                    cut_object_tail(o, excess, skip)
178                }
179            });
180            continue;
181        }
182        // Nothing reducible is left (scalars only): replace the root with a placeholder.
183        *v = Value::Null;
184        return;
185    }
186}
187
188/// Reduce every exempt array found under `v`; returns whether anything changed.
189fn reduce_exempt(v: &mut Value, cap: &Cap) -> bool {
190    let mut changed = false;
191    match v {
192        Value::Object(o) => {
193            for (k, e) in o.iter_mut() {
194                if cap.exempt_arrays.contains(&k.as_str()) {
195                    if let Value::Array(a) = e {
196                        if a.len() > cap.exempt_len_max {
197                            a.truncate(cap.exempt_len_max);
198                            changed = true;
199                        }
200                        for entry in a.iter_mut() {
201                            if scalar_or_full_len(entry) > cap.exempt_entry_max {
202                                shrink(entry, cap.exempt_entry_max, &[]);
203                                changed = true;
204                            }
205                        }
206                        continue;
207                    }
208                }
209                changed |= reduce_exempt(e, cap);
210            }
211        }
212        Value::Array(a) => {
213            for e in a.iter_mut() {
214                changed |= reduce_exempt(e, cap);
215            }
216        }
217        _ => {}
218    }
219    changed
220}
221
222fn scalar_or_full_len(v: &Value) -> usize {
223    size(v, &[])
224}
225
226/// Cap `value` at `cap.max_bytes` (exempt arrays aside) and keep it valid JSON. A reduced result carries
227/// `"truncated": true` and `"original_bytes": n`.
228pub fn cap_json(mut value: Value, cap: &Cap) -> Value {
229    let original = size(&value, &[]);
230    let reduced = reduce_exempt(&mut value, cap);
231    let over = size(&value, cap.exempt_arrays) > cap.max_bytes;
232    if !reduced && !over {
233        return value;
234    }
235    if over {
236        shrink(
237            &mut value,
238            cap.max_bytes.saturating_sub(MARKER_RESERVE),
239            cap.exempt_arrays,
240        );
241    }
242    let mut obj = match value {
243        Value::Object(o) => o,
244        other => {
245            let mut o = Map::new();
246            o.insert("data".to_string(), other);
247            o
248        }
249    };
250    obj.insert("truncated".to_string(), json!(true));
251    obj.insert("original_bytes".to_string(), json!(original));
252    Value::Object(obj)
253}
254
255/// Replace every container nested deeper than `max_depth` (the root is depth 0) with `"<depth>"`.
256pub fn depth_cap(value: Value, max_depth: usize) -> Value {
257    fn walk(v: Value, depth: usize, max: usize) -> Value {
258        match v {
259            Value::Array(_) | Value::Object(_) if depth > max => {
260                Value::String("<depth>".to_string())
261            }
262            Value::Array(a) => {
263                Value::Array(a.into_iter().map(|e| walk(e, depth + 1, max)).collect())
264            }
265            Value::Object(o) => Value::Object(
266                o.into_iter()
267                    .map(|(k, e)| (k, walk(e, depth + 1, max)))
268                    .collect(),
269            ),
270            other => other,
271        }
272    }
273    walk(value, 0, max_depth)
274}
275
276fn sensitive_key(key: &str) -> bool {
277    let k = key.to_lowercase().replace('-', "_");
278    // The `tokens` exemption applies to the `token` term only (token counts are not secrets).
279    k.replace("tokens", "").contains("token")
280        || [
281            "header",
282            "apikey",
283            "api_key",
284            "secret",
285            "password",
286            "auth",
287            "credential",
288        ]
289        .iter()
290        .any(|n| k.contains(n))
291}
292
293/// Redact secret-bearing keys and strip URL userinfo from every string.
294pub fn redact(value: &mut Value) {
295    match value {
296        Value::Object(o) => {
297            for (k, e) in o.iter_mut() {
298                if sensitive_key(k) {
299                    *e = Value::String("<redacted>".to_string());
300                } else {
301                    redact(e);
302                }
303            }
304        }
305        Value::Array(a) => a.iter_mut().for_each(redact),
306        Value::String(s) => {
307            let t = redact_text(s);
308            if t != *s {
309                *s = t;
310            }
311        }
312        _ => {}
313    }
314}
315
316/// Remove `user[:pass]@` from every `scheme://user:pass@host` in `s`.
317pub fn redact_text(s: &str) -> String {
318    let mut out = String::with_capacity(s.len());
319    let mut rest = s;
320    while let Some(i) = rest.find("://") {
321        let (head, tail) = rest.split_at(i + 3);
322        out.push_str(head);
323        let end = tail
324            .find(|c: char| {
325                c.is_whitespace() || matches!(c, '/' | '?' | '#' | '"' | '\'' | '<' | '>')
326            })
327            .unwrap_or(tail.len());
328        let authority = &tail[..end];
329        match authority.rfind('@') {
330            Some(at)
331                if head[..head.len() - 3].ends_with(|c: char| {
332                    c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')
333                }) =>
334            {
335                out.push_str(&authority[at + 1..]);
336            }
337            _ => out.push_str(authority),
338        }
339        rest = &tail[end..];
340    }
341    out.push_str(rest);
342    out
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum ToolsMode {
347    None,
348    Summary,
349    Full,
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize)]
353pub struct Message {
354    pub id: String,
355    #[serde(rename = "type")]
356    pub r#type: String,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub outcome: Option<String>,
359    #[serde(skip)]
360    pub created: i64,
361    pub text: String,
362    #[serde(skip)]
363    pub last_text: Option<String>,
364    pub tools: Vec<Value>,
365}
366
367fn str_of(v: &Value, key: &str) -> Option<String> {
368    v.get(key).and_then(Value::as_str).map(str::to_string)
369}
370
371pub fn compact_message(raw: &Value, reasoning: bool, tools: ToolsMode) -> Message {
372    let r#type = str_of(raw, "type").unwrap_or_default();
373    let mut m = Message {
374        id: str_of(raw, "id").unwrap_or_default(),
375        r#type: r#type.clone(),
376        outcome: None,
377        created: raw
378            .pointer("/time/created")
379            .and_then(Value::as_i64)
380            .unwrap_or(0),
381        text: String::new(),
382        last_text: None,
383        tools: Vec::new(),
384    };
385    match r#type.as_str() {
386        "user" | "synthetic" => m.text = str_of(raw, "text").unwrap_or_default(),
387        "idle" => m.outcome = str_of(raw, "outcome"),
388        "assistant" => {
389            for part in raw
390                .get("content")
391                .and_then(Value::as_array)
392                .into_iter()
393                .flatten()
394            {
395                let part_text = || str_of(part, "text").unwrap_or_default();
396                match part.get("type").and_then(Value::as_str) {
397                    Some("text") => {
398                        let t = part_text();
399                        m.text.push_str(&t);
400                        m.last_text = Some(t);
401                    }
402                    Some("reasoning") if reasoning => m.text.push_str(&part_text()),
403                    Some("tool") => match tools {
404                        ToolsMode::None => {}
405                        ToolsMode::Summary => m.tools.push(json!({
406                            "name": part.get("name").cloned().unwrap_or(Value::Null),
407                            "status": part.pointer("/state/status").cloned().unwrap_or(Value::Null),
408                        })),
409                        ToolsMode::Full => m.tools.push(part.clone()),
410                    },
411                    _ => {}
412                }
413            }
414        }
415        _ => {}
416    }
417    m
418}
419
420fn pick(raw: &Value, keys: &[&str]) -> Map<String, Value> {
421    keys.iter()
422        .filter_map(|k| raw.get(*k).map(|v| ((*k).to_string(), v.clone())))
423        .collect()
424}
425
426/// Keep the form-field keys the agent needs (`packages/schema/src/form.ts:53-119`).
427pub fn compact_form_field(raw: &Value) -> Value {
428    let mut keys = vec![
429        "key",
430        "type",
431        "title",
432        "description",
433        "required",
434        "hidden",
435        "when",
436    ];
437    let extra: &[&str] = match raw.get("type").and_then(Value::as_str) {
438        Some("string") => &[
439            "format",
440            "minLength",
441            "maxLength",
442            "pattern",
443            "placeholder",
444            "default",
445            "options",
446            "custom",
447        ],
448        Some("number" | "integer") => &["minimum", "maximum", "default"],
449        Some("boolean") => &["default"],
450        Some("multiselect") => &["options", "minItems", "maxItems", "custom", "default"],
451        Some("external") => &["url"],
452        _ => &[],
453    };
454    keys.extend_from_slice(extra);
455    Value::Object(pick(raw, &keys))
456}
457
458pub fn compact_permission(raw: &Value) -> Value {
459    Value::Object(pick(
460        raw,
461        &["id", "action", "resources", "save", "message", "metadata"],
462    ))
463}
464
465/// Split a CLI envelope into `(data, cursor, meta)`; a `_tag` envelope is a `CLI_ERROR`.
466pub fn unwrap_envelope(raw: Value) -> Result<(Value, Option<Value>, Option<Value>), ToolError> {
467    if let Some(tag) = raw.get("_tag").and_then(Value::as_str) {
468        let message = raw
469            .get("message")
470            .and_then(Value::as_str)
471            .unwrap_or(tag)
472            .to_string();
473        return Err(ToolError::new(ErrorCode::CliError, message).details(json!({ "tag": tag })));
474    }
475    match raw {
476        Value::Object(mut o) if o.contains_key("data") => {
477            let data = o.remove("data").unwrap_or(Value::Null);
478            let cursor = o.remove("cursor").filter(|c| !c.is_null());
479            let meta = if o.is_empty() {
480                None
481            } else {
482                Some(Value::Object(o))
483            };
484            Ok((data, cursor, meta))
485        }
486        other => Ok((other, None, None)),
487    }
488}