Skip to main content

agent_engine/engine/
stream.rs

1//! History repair after a failed turn, shared by the session actor and the
2//! subagent archive.
3
4/// Repair conversation history after a turn failure.
5///
6/// Only messages appended by the ACTIVE turn (index >= `turn_baseline`) may
7/// be removed, and only while the trailing message is invalid history — an
8/// assistant message with unmatched `tool_use` blocks or empty content.
9/// Valid partial output (assistant text, completed `tool_result` messages)
10/// and everything pre-existing (including a trailing user prompt) survive.
11pub fn repair_history_after_failure(
12    messages: &mut Vec<crate::SharedMessage>,
13    turn_baseline: usize,
14) {
15    fn trailing_is_valid(msg: &serde_json::Value) -> bool {
16        match msg["role"].as_str() {
17            // User messages (text prompts or tool_result blocks) are always
18            // a valid trailing state.
19            Some("user") => true,
20            Some("assistant") => match &msg["content"] {
21                serde_json::Value::String(text) => !text.is_empty(),
22                serde_json::Value::Array(blocks) => {
23                    !blocks.is_empty()
24                        && !blocks
25                            .iter()
26                            .any(|b| b["type"].as_str() == Some("tool_use"))
27                }
28                _ => false,
29            },
30            _ => false,
31        }
32    }
33
34    while messages.len() > turn_baseline {
35        match messages.last() {
36            Some(last) if trailing_is_valid(last) => break,
37            _ => {
38                messages.pop();
39            }
40        }
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use serde_json::json;
48    use std::sync::Arc;
49
50    fn msg(v: serde_json::Value) -> crate::SharedMessage {
51        Arc::new(v)
52    }
53
54    /// T3 criterion 4: a pre-existing trailing user message (the prompt that
55    /// started the turn) must never be removed by failure repair.
56    #[test]
57    fn error_repair_keeps_preexisting_trailing_user_message() {
58        let mut messages = vec![msg(json!({"role": "user", "content": "hello"}))];
59        repair_history_after_failure(&mut messages, 1);
60        assert_eq!(
61            messages.len(),
62            1,
63            "pre-existing trailing user message must survive turn failure"
64        );
65        assert_eq!(messages[0]["content"], "hello");
66    }
67
68    /// T3 criterion 2: partial assistant output and completed tool results
69    /// appended by the active turn must survive an unrecovered failure.
70    #[test]
71    fn error_repair_keeps_partial_assistant_output_and_tool_results() {
72        let mut messages = vec![
73            msg(json!({"role": "user", "content": "do the thing"})),
74            msg(json!({"role": "assistant", "content": [
75                {"type": "text", "text": "working on it"},
76                {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}},
77            ]})),
78            msg(json!({"role": "user", "content": [
79                {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"},
80            ]})),
81            msg(json!({"role": "assistant", "content": [
82                {"type": "text", "text": "partial answer before the failure"},
83            ]})),
84        ];
85        repair_history_after_failure(&mut messages, 1);
86        assert_eq!(
87            messages.len(),
88            4,
89            "valid partial assistant output and completed tool results must survive"
90        );
91        assert_eq!(
92            messages[3]["content"][0]["text"],
93            "partial answer before the failure"
94        );
95    }
96
97    /// A turn-appended trailing assistant message with unmatched tool_use
98    /// blocks is invalid history and must be removed by repair.
99    #[test]
100    fn error_repair_drops_turn_appended_unmatched_tool_use() {
101        let mut messages = vec![
102            msg(json!({"role": "user", "content": "do the thing"})),
103            msg(json!({"role": "assistant", "content": [
104                {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}},
105            ]})),
106        ];
107        repair_history_after_failure(&mut messages, 1);
108        assert_eq!(
109            messages.len(),
110            1,
111            "unmatched trailing tool_use appended by the turn must be removed"
112        );
113        assert_eq!(messages[0]["content"], "do the thing");
114    }
115}