goose-context-management 0.1.0-alpha.13

Conversation compaction for Goose
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
use crate::templates::{self, COMPACTION_SUMMARY_TEMPLATE};
use goose_provider_types::json::safely_parse_json;
use serde::{Deserialize, Serialize};

/// Structured output of the compaction LLM call.
///
/// Every list is ordered most-important-first so consumers (the render
/// template, experiments that truncate sections) can cut from the tail.
/// Fields deserialize leniently - omitted fields default to empty, and an
/// object or number where a string was asked for is stringified rather than
/// failing - because models routinely enrich the schema (e.g. `{"error": ..,
/// "fix": ..}` entries) and one such field must not discard a good summary.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StructuredSummary {
    #[serde(default, deserialize_with = "lenient_string_list")]
    pub user_intent: Vec<String>,
    #[serde(default, deserialize_with = "lenient_string_list")]
    pub technical_concepts: Vec<String>,
    #[serde(default, deserialize_with = "lenient_file_list")]
    pub files: Vec<FileActivity>,
    #[serde(default, deserialize_with = "lenient_string_list")]
    pub errors_and_fixes: Vec<String>,
    #[serde(default, deserialize_with = "lenient_string_list")]
    pub problem_solving: Vec<String>,
    #[serde(default, deserialize_with = "lenient_string_list")]
    pub user_messages: Vec<String>,
    #[serde(default, deserialize_with = "lenient_string_list")]
    pub pending_tasks: Vec<String>,
    #[serde(default, deserialize_with = "lenient_string_opt")]
    pub current_work: Option<String>,
    #[serde(default, deserialize_with = "lenient_string_opt")]
    pub next_step: Option<String>,
    /// Unknown top-level fields, kept so a user-customized compaction prompt
    /// that adds fields can still reach them from a customized render
    /// template. Not counted when deciding whether a summary is empty.
    #[serde(flatten)]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileActivity {
    #[serde(default, deserialize_with = "lenient_string")]
    pub path: String,
    #[serde(default, deserialize_with = "lenient_string")]
    pub summary: String,
    #[serde(default, deserialize_with = "lenient_string_opt")]
    pub key_code: Option<String>,
}

fn stringify_lenient(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::String(s) => s.clone(),
        serde_json::Value::Null => String::new(),
        serde_json::Value::Object(map) => map
            .iter()
            .map(|(k, v)| format!("{k}: {}", stringify_lenient(v)))
            .collect::<Vec<_>>()
            .join("; "),
        serde_json::Value::Array(items) => items
            .iter()
            .map(stringify_lenient)
            .collect::<Vec<_>>()
            .join("; "),
        other => other.to_string(),
    }
}

fn lenient_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value = serde_json::Value::deserialize(deserializer)?;
    Ok(match value {
        serde_json::Value::Array(items) => items.iter().map(stringify_lenient).collect(),
        serde_json::Value::Null => Vec::new(),
        other => vec![stringify_lenient(&other)],
    })
}

fn lenient_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value = serde_json::Value::deserialize(deserializer)?;
    Ok(stringify_lenient(&value))
}

fn lenient_string_opt<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value = serde_json::Value::deserialize(deserializer)?;
    Ok(match value {
        serde_json::Value::Null => None,
        other => Some(stringify_lenient(&other)),
    })
}

/// `files` entries should be objects, but a model that over-applies the
/// "plain strings" rule may emit them as strings; render those as path-only
/// activities rather than discarding the whole summary.
fn lenient_file_list<'de, D>(deserializer: D) -> Result<Vec<FileActivity>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value = serde_json::Value::deserialize(deserializer)?;
    let items = match value {
        serde_json::Value::Array(items) => items,
        serde_json::Value::Null => return Ok(Vec::new()),
        other => vec![other],
    };
    Ok(items
        .into_iter()
        .filter_map(|item| match item {
            serde_json::Value::Object(_) => serde_json::from_value(item).ok(),
            other => {
                let path = stringify_lenient(&other);
                (!path.trim().is_empty()).then_some(FileActivity {
                    path,
                    summary: String::new(),
                    key_code: None,
                })
            }
        })
        .collect())
}

impl StructuredSummary {
    /// Returns `None` when no usable JSON document is found so the caller can
    /// keep the raw response text - the lossless fallback.
    pub fn parse(response_text: &str) -> Option<Self> {
        json_candidates(response_text).into_iter().find_map(|c| {
            let value = safely_parse_json(c).ok()?;
            let mut summary: Self = serde_json::from_value(value).ok()?;
            summary.normalize();
            (!summary.is_empty()).then_some(summary)
        })
    }

    pub fn render(&self) -> Result<String, minijinja::Error> {
        self.render_with(
            &templates::builtin_template(COMPACTION_SUMMARY_TEMPLATE)
                .expect("builtin compaction summary template"),
        )
    }

    pub fn render_with(&self, template: &str) -> Result<String, minijinja::Error> {
        templates::render(template, self)
    }

    /// Drops blank entries so a response of blank strings counts as empty
    /// (raw-text fallback) rather than rendering a summary of nothing.
    fn normalize(&mut self) {
        fn blank(s: &str) -> bool {
            s.trim().is_empty()
        }
        for list in [
            &mut self.user_intent,
            &mut self.technical_concepts,
            &mut self.errors_and_fixes,
            &mut self.problem_solving,
            &mut self.user_messages,
            &mut self.pending_tasks,
        ] {
            list.retain(|s| !blank(s));
        }
        for file in &mut self.files {
            if file.key_code.as_deref().is_some_and(blank) {
                file.key_code = None;
            }
        }
        self.files
            .retain(|f| !blank(&f.path) || !blank(&f.summary) || f.key_code.is_some());
        if self.current_work.as_deref().is_some_and(blank) {
            self.current_work = None;
        }
        if self.next_step.as_deref().is_some_and(blank) {
            self.next_step = None;
        }
    }

    fn is_empty(&self) -> bool {
        self.user_intent.is_empty()
            && self.technical_concepts.is_empty()
            && self.files.is_empty()
            && self.errors_and_fixes.is_empty()
            && self.problem_solving.is_empty()
            && self.user_messages.is_empty()
            && self.pending_tasks.is_empty()
            && self.current_work.is_none()
            && self.next_step.is_none()
    }
}

/// Candidate JSON documents in the model's response, tried in order until one
/// parses: after each `</analysis>` terminator (last first) the
/// post-terminator ```json fences (last first) then a leading object, and
/// finally a leading object of the whole text.
///
/// Terminators before the last are retried because the summary JSON may
/// itself quote `</analysis>` (e.g. a session editing compaction prompts),
/// hiding the real terminator from a plain rfind. Such a candidate is
/// accepted only if it contains every later terminator occurrence - proof
/// they were quoted inside it - so a fenced example inside the scratchpad,
/// which precedes the real terminator, can never leak through.
///
/// Every candidate must sit directly at its marker and brace-balance to a
/// close. Anything looser erodes the lossless fallback: JSON merely quoted in
/// a prose response, or a fenced example inside the discarded scratchpad,
/// would silently replace the raw text. Extraction is brace-balanced rather
/// than fence-delimited because string values may legally contain ```, and an
/// unterminated object (output cut off mid-JSON) is not repaired - repair
/// would drop the late, continuation-critical sections that the raw-text
/// fallback preserves.
#[allow(clippy::string_slice)] // All markers are ASCII; indices are byte offsets of ASCII matches.
fn json_candidates(text: &str) -> Vec<&str> {
    const TERMINATOR: &str = "</analysis>";

    let mut cuts: Vec<usize> = text
        .match_indices(TERMINATOR)
        .map(|(idx, _)| idx + TERMINATOR.len())
        .collect();
    if cuts.is_empty() {
        cuts.push(0);
    }

    let mut candidates: Vec<&str> = Vec::new();
    for &cut in cuts.iter().rev() {
        let tail = &text[cut..];
        let later_terminators = tail.matches(TERMINATOR).count();
        candidates.extend(
            fenced_json_blocks(tail)
                .chain(leading_object(tail))
                .filter(|candidate| candidate.matches(TERMINATOR).count() == later_terminators),
        );
    }
    candidates.extend(leading_object(text));
    candidates.dedup();
    candidates
}

/// Every fence is tried, last first, because a string value may itself quote
/// a fenced JSON snippet, and such an embedded fence must not shadow the real
/// one.
#[allow(clippy::string_slice)] // The marker is ASCII; indices are byte offsets of ASCII matches.
fn fenced_json_blocks(text: &str) -> impl Iterator<Item = &str> {
    text.match_indices("```json")
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .filter_map(|(idx, marker)| leading_object(&text[idx + marker.len()..]))
}

#[allow(clippy::string_slice)] // Indices come from char_indices(); slicing is safe.
fn leading_object(text: &str) -> Option<&str> {
    let text = text.trim_start();
    if !text.starts_with('{') {
        return None;
    }

    let mut depth = 0usize;
    let mut in_string = false;
    let mut escaped = false;

    for (idx, ch) in text.char_indices() {
        if in_string {
            if escaped {
                escaped = false;
            } else if ch == '\\' {
                escaped = true;
            } else if ch == '"' {
                in_string = false;
            }
            continue;
        }
        match ch {
            '"' => in_string = true,
            '{' => depth += 1,
            '}' => {
                depth -= 1;
                if depth == 0 {
                    return Some(&text[..=idx]);
                }
            }
            _ => {}
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;

    const FULL_RESPONSE: &str = r#"<analysis>
The user asked to fix a bug in parser.rs. I traced it to an off-by-one
in {brace handling} and patched it.
</analysis>

```json
{
  "user_intent": ["Fix the parser bug", "Add a regression test"],
  "technical_concepts": ["off-by-one", "tokenizer"],
  "files": [
    {"path": "src/parser.rs", "summary": "Fixed off-by-one in scan loop", "key_code": "fn scan(&mut self) { .. }"}
  ],
  "errors_and_fixes": ["Panic on empty input, fixed with early return"],
  "problem_solving": ["Root-caused via failing unit test"],
  "user_messages": ["fix the parser bug", "add a test"],
  "pending_tasks": ["Add a regression test"],
  "current_work": "Writing the regression test in tests/parser.rs",
  "next_step": "Finish the regression test"
}
```"#;

    #[test]
    fn parses_fenced_json_after_analysis() {
        let summary = StructuredSummary::parse(FULL_RESPONSE).expect("should parse");
        assert_eq!(
            summary.user_intent,
            vec!["Fix the parser bug", "Add a regression test"]
        );
        assert_eq!(summary.files.len(), 1);
        assert_eq!(summary.files[0].path, "src/parser.rs");
        assert_eq!(
            summary.current_work.as_deref(),
            Some("Writing the regression test in tests/parser.rs")
        );
    }

    #[test]
    fn unusable_responses_fall_back_to_raw_text() {
        for text in [
            // freeform prose, no JSON document
            "Here is a summary of the conversation. The user asked about compaction.",
            // no visible content
            "{}",
            r#"{"notes": "unknown fields alone are not a summary"}"#,
            r#"{"current_work": ""}"#,
            r#"{"files": [{}], "user_intent": [" "]}"#,
            // output cut off mid-JSON: never repaired
            "```json\n{\"user_intent\": [\"Fix the bug\"], \"pending_tasks\": [\"Write tests\", \"Update docs",
            // JSON quoted inside prose is not anchored at a marker
            r#"The session focused on the parser migration. The tracker entry {"current_work": "migrate parser"} is unchanged, and tests still need porting."#,
            "<analysis>reviewing</analysis>\nA prose recap: the config was set to {\"user_intent\": [\"quoted example\"]} per the docs, then the run passed.",
            // a fenced example inside the scratchpad is not the summary
            "<analysis>\nThe target shape is:\n```json\n{\"user_intent\": [\"example only\"]}\n```\nNow let me review the conversation.\n</analysis>\nSorry, I ran out of room and could not produce the summary document.",
            // a quoted terminator inside the scratchpad must not expose its fenced example
            "<analysis>\nThe prompt ends with </analysis> and shows the shape:\n```json\n{\"user_intent\": [\"example only\"]}\n```\nNow let me review the conversation.\n</analysis>\nSorry, I ran out of room and could not produce the summary document.",
        ] {
            assert!(
                StructuredSummary::parse(text).is_none(),
                "should fall back to raw text for: {text}"
            );
        }
    }

    #[test]
    fn embedded_fences_in_string_values_do_not_break_extraction() {
        let text = "```json\n{\"user_intent\": [\"Document the build\"], \"files\": [{\"path\": \"README.md\", \"summary\": \"Added build docs\", \"key_code\": \"```bash\\ncargo build\\n```\"}], \"pending_tasks\": [\"Publish the docs\"]}\n```";
        let summary = StructuredSummary::parse(text).expect("should parse");
        assert_eq!(
            summary.files[0].key_code.as_deref(),
            Some("```bash\ncargo build\n```")
        );
        assert_eq!(summary.pending_tasks, vec!["Publish the docs"]);

        let quoted_json_fence = "```json\n{\"user_intent\": [\"Document the config\"], \"files\": [{\"path\": \"docs/config.md\", \"summary\": \"Added config examples\", \"key_code\": \"```json\\n{\\\"retries\\\": 3}\\n```\"}]}\n```";
        let summary =
            StructuredSummary::parse(quoted_json_fence).expect("should parse via the outer fence");
        assert_eq!(
            summary.files[0].key_code.as_deref(),
            Some("```json\n{\"retries\": 3}\n```")
        );
    }

    #[test]
    fn quoted_terminator_inside_summary_json_does_not_hide_it() {
        let text = "<analysis>\nThe session edited the compaction prompt itself.\n</analysis>\n```json\n{\"user_intent\": [\"Rework the scratchpad prompt\"], \"files\": [{\"path\": \"prompts/compact.md\", \"summary\": \"Tightened the <analysis>...</analysis> instructions\"}]}\n```";
        let summary = StructuredSummary::parse(text).expect("should parse");
        assert_eq!(summary.user_intent, vec!["Rework the scratchpad prompt"]);
        assert_eq!(
            summary.files[0].summary,
            "Tightened the <analysis>...</analysis> instructions"
        );
    }

    #[test]
    fn retries_next_candidate_when_fenced_extraction_fails() {
        let text = "<analysis>the model was told to emit ```json with {braces</analysis>\n{\"user_intent\": [\"Real goal\"]}";
        let summary = StructuredSummary::parse(text).expect("should parse");
        assert_eq!(summary.user_intent, vec!["Real goal"]);
    }

    #[test]
    fn lenient_shapes_are_stringified_not_rejected() {
        let text = r#"{
            "user_intent": "fix the flaky test",
            "errors_and_fixes": [
                {"error": "cursor drifted after replay batch 34", "fix": "bounded mpsc channel"},
                "plain string entry",
                null
            ],
            "pending_tasks": [42],
            "current_work": {"task": "regression test", "status": "in progress"}
        }"#;
        let summary = StructuredSummary::parse(text).expect("should parse leniently");
        assert_eq!(summary.user_intent, vec!["fix the flaky test"]);
        assert_eq!(
            summary.errors_and_fixes,
            vec![
                "error: cursor drifted after replay batch 34; fix: bounded mpsc channel",
                "plain string entry",
            ]
        );
        assert_eq!(summary.pending_tasks, vec!["42"]);
        assert_eq!(
            summary.current_work.as_deref(),
            Some("task: regression test; status: in progress")
        );
    }

    #[test]
    fn file_entries_parse_leniently() {
        let text = r#"{"files": [
            "src/parser.rs",
            {"path": "tests/parser.rs", "summary": "Added regression test"},
            {"path": "src/scan.rs", "summary": 42, "key_code": ["fn a() {}", "fn b() {}"]},
            ""
        ]}"#;
        let summary = StructuredSummary::parse(text).expect("should parse");
        assert_eq!(summary.files.len(), 3);
        assert_eq!(summary.files[0].path, "src/parser.rs");
        assert_eq!(summary.files[0].summary, "");
        assert_eq!(summary.files[1].summary, "Added regression test");
        assert_eq!(summary.files[2].summary, "42");
        assert_eq!(
            summary.files[2].key_code.as_deref(),
            Some("fn a() {}; fn b() {}")
        );
    }

    #[test]
    fn drops_blank_entries_but_keeps_content() {
        let text = r#"{"user_intent": ["", "Fix the bug"], "files": [{"path": "a.rs", "summary": "Patched", "key_code": "  "}], "next_step": " "}"#;
        let summary = StructuredSummary::parse(text).expect("should parse");
        assert_eq!(summary.user_intent, vec!["Fix the bug"]);
        assert_eq!(summary.files[0].key_code, None);
        assert_eq!(summary.next_step, None);
    }

    #[test]
    fn renders_markdown_sections() {
        let summary = StructuredSummary::parse(FULL_RESPONSE).unwrap();
        let rendered = summary.render().expect("should render");
        assert!(rendered.contains("## User Intent"));
        assert!(rendered.contains("- Fix the parser bug"));
        assert!(rendered.contains("### src/parser.rs"));
        assert!(rendered.contains("fn scan(&mut self) { .. }"));
        assert!(rendered.contains("## Next Step"));
    }

    #[test]
    fn render_fences_exceed_backtick_runs_in_key_code() {
        let summary = StructuredSummary {
            files: vec![FileActivity {
                path: "docs/build.md".to_string(),
                summary: "Documented the build".to_string(),
                key_code: Some(
                    "```bash\ncargo build\n```\n````\nnested fence docs\n````".to_string(),
                ),
            }],
            errors_and_fixes: vec!["None".to_string()],
            ..Default::default()
        };
        let rendered = summary.render().expect("should render");
        // key_code's longest backtick run is four, so the fence must be five
        assert_eq!(rendered.matches("\n`````\n").count(), 2);
        let errors_heading = rendered.find("## Errors + Fixes").unwrap();
        let closing_fence = rendered.rfind("\n`````\n").unwrap();
        assert!(errors_heading > closing_fence);
    }
}