Skip to main content

agent_engine/engine/
interrupt.rs

1//! How an interrupted turn is recorded in conversation history.
2//!
3//! When a turn is cancelled, the engine publishes the REAL partial history —
4//! the assistant's partial message (text / signed thinking / completed tool
5//! calls), every completed tool round, and a canceled `tool_result` for any
6//! call that did not finish. The frontend adopts that history verbatim and
7//! then appends ONE short, factual user message: the interruption marker.
8//!
9//! This replaces the old "ABORT CONTEXT" recap, which re-described the
10//! model's own reasoning and tool calls as text inside the NEXT user message.
11//! A user turn that impersonates the model's output reads as a prompt
12//! injection, and current models refuse it. The marker carries no
13//! instructions and claims nothing about what the model did — the history
14//! already says that, in the provider's own message format.
15//!
16//! Prompt caching: the marker is only ever APPENDED after the adopted
17//! history, never folded into or edited onto an existing message, so every
18//! byte the provider already cached stays identical.
19
20use serde_json::{json, Value};
21
22use crate::SharedMessage;
23
24/// Why a turn was interrupted. Selects the marker text only.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum InterruptReason {
27    /// Esc / Cancel command / quitting the client mid-turn.
28    User,
29    /// The host's session cost cap tripped mid-turn.
30    CostCap,
31    /// The daemon checkpointed the session to restart (`daemon reload`).
32    Restart,
33    /// Host-side teardown: `daemon stop`, a host checkpoint request, an
34    /// error ending the session.
35    Host,
36    /// A session persisted by an older Synaps with an `abort_context`
37    /// recap: the turn was interrupted, the reason is not recorded.
38    Unknown,
39    /// The process died with a turn open (crash, `kill -9`, power loss):
40    /// found on load via the in-flight turn draft (`session_draft`).
41    Crash,
42    /// A plugin session driver was running the turn and was revoked (the
43    /// user took over, competing work arrived, the driver failed a check).
44    /// The revocation reason itself goes to clients (`DriverRevoked`), never
45    /// into history.
46    Driver,
47}
48
49impl InterruptReason {
50    /// The exact marker text appended to history.
51    pub fn marker(self) -> &'static str {
52        match self {
53            InterruptReason::User => "[Request interrupted by user]",
54            InterruptReason::CostCap => "[Request interrupted: session cost cap reached]",
55            InterruptReason::Restart => "[Request interrupted: Synaps restarted]",
56            InterruptReason::Host => "[Request interrupted: session stopped by the host]",
57            InterruptReason::Unknown => "[Request interrupted]",
58            InterruptReason::Crash => "[Request interrupted: Synaps stopped unexpectedly]",
59            InterruptReason::Driver => "[Request interrupted: session driver revoked]",
60        }
61    }
62
63    /// How a canceled `tool_result` of this turn words the cancel. `None`
64    /// for reasons that are never a live cancel (found on load).
65    pub fn cancel_cause(self) -> Option<crate::runtime::CancelCause> {
66        use crate::runtime::CancelCause;
67        match self {
68            InterruptReason::User => Some(CancelCause::User),
69            InterruptReason::CostCap => Some(CancelCause::CostCap),
70            InterruptReason::Restart => Some(CancelCause::Restart),
71            InterruptReason::Host => Some(CancelCause::Host),
72            InterruptReason::Driver => Some(CancelCause::Driver),
73            InterruptReason::Unknown | InterruptReason::Crash => None,
74        }
75    }
76
77    /// Every reason, i.e. every marker this build writes or recognises.
78    pub const ALL: [InterruptReason; 7] = [
79        InterruptReason::User,
80        InterruptReason::CostCap,
81        InterruptReason::Restart,
82        InterruptReason::Host,
83        InterruptReason::Unknown,
84        InterruptReason::Crash,
85        InterruptReason::Driver,
86    ];
87}
88
89/// True iff `text` is EXACTLY one of the interruption markers. Clients use
90/// this to render the marker as an "interrupted" line instead of a user
91/// bubble (the daemon's `DisplayItem` has no dedicated variant: adding one
92/// would break older clients, whose `DisplayItem` has no `#[serde(other)]`).
93/// Exact, not a prefix: a user message that merely starts like a marker is
94/// the user's, and is shown (and deduplicated) as such.
95pub fn is_interruption_marker(text: &str) -> bool {
96    InterruptReason::ALL.iter().any(|r| r.marker() == text)
97}
98
99/// `"[Request interrupted by user]"` → `"Request interrupted by user"`, for
100/// display. `None` for anything that is not a marker.
101pub fn interruption_label(text: &str) -> Option<&str> {
102    is_interruption_marker(text).then(|| &text[1..text.len() - 1])
103}
104
105/// The note a cancel records when the engine never reported back on a tool
106/// call the interrupted response made (`session::actor`
107/// `keep_streamed_partial`): the engine's canceled-`tool_result` wording,
108/// hedged to "may have already started" because the actor can't tell
109/// whether the call began. A plain user-role message, recognised by its
110/// exact text like the interruption marker (never by a host key a
111/// rolled-back 0.10.2 would take for an archive marker); the generator and
112/// the matcher live in `agent_core::core::host_note`, which archive
113/// projection (agent-core) uses too.
114pub fn tool_in_flight_note(reason: InterruptReason, tools: &[String]) -> String {
115    let phrase = reason.cancel_cause().map_or("Canceled", |c| c.phrase());
116    agent_core::core::host_note::tool_in_flight_note_text(phrase, tools)
117}
118
119/// The note as a history message.
120pub fn tool_in_flight_note_message(reason: InterruptReason, tools: &[String]) -> SharedMessage {
121    std::sync::Arc::new(json!({"role": "user", "content": tool_in_flight_note(reason, tools)}))
122}
123
124/// Exactly a generated note (text / message): never a user request, never
125/// archived or captured as the user's words, a system line on replay.
126pub use agent_core::core::host_note::{is_tool_in_flight_note, tool_in_flight_note_label};
127
128fn marker_of(message: &Value) -> bool {
129    message["role"] == "user"
130        && message["content"]
131            .as_str()
132            .is_some_and(is_interruption_marker)
133}
134
135/// Append the marker for `reason` unless the history already ends with a
136/// marker (a second interruption with no turn in between records nothing
137/// new). Returns whether a message was appended.
138pub fn append_marker(messages: &mut Vec<SharedMessage>, reason: InterruptReason) -> bool {
139    if messages.last().is_some_and(|m| marker_of(m)) {
140        return false;
141    }
142    messages.push(std::sync::Arc::new(
143        json!({"role": "user", "content": reason.marker()}),
144    ));
145    true
146}
147
148/// Load-time migration for sessions saved before this change. A non-`None`
149/// `abort_context` means the saved history ends at the interrupted turn and
150/// the recap was waiting to be prepended to the next user message. The recap
151/// is DISCARDED (it is exactly the injection-shaped text this module
152/// replaces) and the marker is appended in its place.
153///
154/// Returns whether the session was migrated. Idempotent: `abort_context` is
155/// taken, so a second call is a no-op.
156pub fn migrate_legacy_abort_context(
157    messages: &mut Vec<SharedMessage>,
158    abort_context: &mut Option<String>,
159) -> bool {
160    if abort_context.take().is_none() {
161        return false;
162    }
163    // A recap with nothing to attach to (empty history) is simply dropped.
164    if !messages.is_empty() {
165        append_marker(messages, InterruptReason::Unknown);
166    }
167    true
168}
169
170/// Fold an in-flight turn draft left by a process that died mid-turn
171/// (`agent_core::core::session_draft`) into the loaded history.
172///
173/// The saved history is valid (it is only ever saved at round boundaries),
174/// so recovery only APPENDS — the cached prefix is untouched:
175/// - the history already ends with an interruption marker → the turn was
176///   already concluded (aborted, or recovered before): nothing to do;
177/// - it ends with an assistant message → the turn completed (its final
178///   history was saved) and only the draft's removal was lost: nothing to
179///   do;
180/// - otherwise the turn was cut off: the partial reply is appended as a real
181///   assistant message — only when the draft belongs to exactly this
182///   history (`base_len`), never a stale one — then the `Crash` marker.
183///
184/// Returns whether `messages` changed. Either way the caller removes the
185/// draft once the result is saved.
186pub fn recover_crashed_turn(
187    messages: &mut Vec<SharedMessage>,
188    draft: &agent_core::core::session_draft::TurnDraft,
189) -> bool {
190    match messages.last() {
191        // Nothing to attach to: the prompt checkpoint never landed.
192        None => return false,
193        Some(last) if marker_of(last) || last["role"] == "assistant" => return false,
194        Some(_) => {}
195    }
196    if draft.base_len == messages.len() && !draft.partial_text.trim().is_empty() {
197        messages.push(std::sync::Arc::new(json!({
198            "role": "assistant",
199            "content": [{"type": "text", "text": draft.partial_text}]
200        })));
201    }
202    append_marker(messages, InterruptReason::Crash);
203    true
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::sync::Arc;
210
211    #[test]
212    fn tool_note_names_the_tools_hedges_like_the_engine_and_is_recognised() {
213        let one = tool_in_flight_note(InterruptReason::User, &["bash".into()]);
214        assert_eq!(
215            one,
216            "[Canceled by user while the tool call `bash` was running. It may have already \
217             started and partially applied its effects.]"
218        );
219        let two = tool_in_flight_note(InterruptReason::Driver, &["read".into(), "bash".into()]);
220        assert_eq!(
221            two,
222            "[Canceled (session driver revoked) while the tool calls `read`, `bash` were \
223             running. They may have already started and partially applied their effects.]"
224        );
225        for note in [&one, &two] {
226            assert_eq!(tool_in_flight_note_label(note), Some(&note[1..note.len() - 1]));
227            assert!(!is_interruption_marker(note), "never mistaken for a marker");
228        }
229        // Every reason's note is recognised (the phrase lists agree).
230        for reason in InterruptReason::ALL {
231            let note = tool_in_flight_note(reason, &["x".into()]);
232            assert!(tool_in_flight_note_label(&note).is_some(), "{reason:?}: {note}");
233        }
234        // A plain message, untagged: recognised by its text alone.
235        let msg = tool_in_flight_note_message(InterruptReason::User, &["bash".into()]);
236        assert_eq!(*msg, json!({"role": "user", "content": one}));
237        assert!(is_tool_in_flight_note(&msg));
238        assert!(!is_tool_in_flight_note(&json!({"role": "assistant", "content": one})));
239        assert!(!is_tool_in_flight_note(&json!({"role": "user", "content": [{"type": "text", "text": one}]})));
240    }
241
242    /// Only the exact generated sentence is the note: nothing free-form in
243    /// the middle, a single line, a known cancel phrase.
244    #[test]
245    fn tool_note_matcher_is_exact() {
246        let one = tool_in_flight_note(InterruptReason::User, &["bash".into()]);
247        let not_notes = [
248            "[Request interrupted by user]".to_string(),
249            "[Canceled by user]".into(),
250            "hello".into(),
251            "[x effects.]".into(),
252            // Multi-line, or anything inserted, removed or reworded.
253            one.replace(" was running.", " was\nrunning."),
254            format!("{one}\n"),
255            format!("{one} please"),
256            one.replace("`bash`", "`bash` and also rm -rf /"),
257            one.replace("`bash`", "`bash`, `ls`"),
258            one.replace("It may", "They may"),
259            one.replace("Canceled by user", "Canceled by someone"),
260            one.replace("tool call `bash` was", "tool calls `bash` were"),
261            one.replace("`bash`", "``"),
262            one.replace("`bash`", "`ba\nsh`"),
263            "[Canceled by user while the tool call `a` was running. Anything at all goes here \
264             effects.]"
265                .into(),
266        ];
267        for text in &not_notes {
268            assert_eq!(tool_in_flight_note_label(text), None, "{text:?}");
269        }
270        // Whatever the model put in a tool name never reaches the note.
271        for bad in ["a`b".to_string(), "a\nb".into(), "z".repeat(200)] {
272            let note = tool_in_flight_note(InterruptReason::User, std::slice::from_ref(&bad));
273            assert!(note.contains("(an unrecognised tool)"), "{note}");
274            assert!(!note.contains(bad.as_str()), "{note}");
275            assert!(tool_in_flight_note_label(&note).is_some(), "{bad:?}: {note}");
276            assert!(is_tool_in_flight_note(&tool_in_flight_note_message(
277                InterruptReason::User,
278                std::slice::from_ref(&bad)
279            )));
280        }
281    }
282
283    const ALL: [InterruptReason; 7] = InterruptReason::ALL;
284
285    fn user(text: &str) -> SharedMessage {
286        Arc::new(json!({"role": "user", "content": text}))
287    }
288
289    #[test]
290    fn every_marker_is_recognised_and_labelled() {
291        for r in ALL {
292            let m = r.marker();
293            assert!(is_interruption_marker(m), "{m}");
294            let label = interruption_label(m).unwrap();
295            assert!(label.starts_with("Request interrupted"), "{label}");
296            assert!(!label.starts_with('[') && !label.ends_with(']'));
297        }
298    }
299
300    #[test]
301    fn markers_carry_no_instructions_or_recap() {
302        for r in ALL {
303            let m = r.marker().to_lowercase();
304            for banned in ["abort context", "continue", "you ", "your ", "tool", "note"] {
305                assert!(!m.contains(banned), "{m:?} contains {banned:?}");
306            }
307        }
308    }
309
310    #[test]
311    fn ordinary_user_text_is_not_a_marker() {
312        for t in [
313            "",
314            "[Request interrupted by user] but actually do Y",
315            "[Request interrupted by user]\nand more",
316            "please [Request interrupted by user]",
317            "[ABORT CONTEXT — your previous response was interrupted]",
318            // Shaped like a marker, but not one this build writes: the
319            // user's own text (it used to match a prefix rule).
320            "[Request interrupted: do Y instead]",
321            "[Request interrupted by user] ",
322        ] {
323            assert!(!is_interruption_marker(t), "{t:?}");
324        }
325    }
326
327    #[test]
328    fn append_is_append_only_and_deduplicated() {
329        let first = user("do X");
330        let mut msgs = vec![first.clone()];
331        assert!(append_marker(&mut msgs, InterruptReason::User));
332        assert!(
333            !append_marker(&mut msgs, InterruptReason::CostCap),
334            "no double marker"
335        );
336        assert_eq!(msgs.len(), 2);
337        assert!(
338            Arc::ptr_eq(&msgs[0], &first),
339            "earlier messages are never rebuilt"
340        );
341        assert_eq!(msgs[1]["content"], "[Request interrupted by user]");
342    }
343
344    #[test]
345    fn legacy_abort_context_is_dropped_and_replaced_by_the_marker() {
346        let mut msgs = vec![user("do X")];
347        let mut ctx = Some("(System note — ABORT CONTEXT: …)".to_string());
348        assert!(migrate_legacy_abort_context(&mut msgs, &mut ctx));
349        assert!(ctx.is_none());
350        assert_eq!(msgs.len(), 2);
351        assert_eq!(msgs[1]["content"], "[Request interrupted]");
352        assert!(!serde_json::to_string(&msgs)
353            .unwrap()
354            .contains("ABORT CONTEXT"));
355        // Idempotent.
356        assert!(!migrate_legacy_abort_context(&mut msgs, &mut ctx));
357        assert_eq!(msgs.len(), 2);
358    }
359
360    fn draft(base_len: usize, text: &str) -> agent_core::core::session_draft::TurnDraft {
361        agent_core::core::session_draft::TurnDraft {
362            base_len,
363            partial_text: text.into(),
364        }
365    }
366
367    fn assistant(text: &str) -> SharedMessage {
368        Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": text}]}))
369    }
370
371    #[test]
372    fn crash_mid_reply_keeps_the_partial_reply_then_the_marker() {
373        let prompt = user("do X");
374        let mut msgs = vec![prompt.clone()];
375        assert!(recover_crashed_turn(&mut msgs, &draft(1, "Let me look")));
376        assert_eq!(msgs.len(), 3);
377        assert!(Arc::ptr_eq(&msgs[0], &prompt), "append-only");
378        assert_eq!(msgs[1]["role"], "assistant");
379        assert_eq!(msgs[1]["content"][0]["text"], "Let me look");
380        assert_eq!(msgs[2]["content"], InterruptReason::Crash.marker());
381    }
382
383    #[test]
384    fn crash_with_no_streamed_text_records_only_the_marker() {
385        for text in ["", "  \n"] {
386            let mut msgs = vec![user("do X")];
387            assert!(recover_crashed_turn(&mut msgs, &draft(1, text)));
388            assert_eq!(msgs.len(), 2);
389            assert_eq!(msgs[1]["content"], InterruptReason::Crash.marker());
390        }
391    }
392
393    #[test]
394    fn a_stale_draft_never_contributes_its_text() {
395        // The round the text belonged to was committed (history grew past
396        // base_len) or its save never landed (history shorter): marker only.
397        for base in [0, 2, 5] {
398            let mut msgs = vec![user("do X"), assistant("calling"), user("tool result")];
399            assert!(recover_crashed_turn(&mut msgs, &draft(base, "stale text")));
400            assert_eq!(msgs.len(), 4, "base {base}");
401            assert!(!serde_json::to_string(&msgs).unwrap().contains("stale text"));
402            assert_eq!(msgs[3]["content"], InterruptReason::Crash.marker());
403        }
404    }
405
406    #[test]
407    fn a_concluded_turn_is_left_alone() {
408        // Completed (final reply saved) — only the draft removal was lost.
409        let mut done = vec![user("do X"), assistant("done")];
410        assert!(!recover_crashed_turn(&mut done, &draft(2, "done")));
411        assert_eq!(done.len(), 2);
412        // Aborted (marker saved) — same.
413        let mut aborted = vec![user("do X"), user(InterruptReason::User.marker())];
414        assert!(!recover_crashed_turn(&mut aborted, &draft(1, "partial")));
415        assert_eq!(aborted.len(), 2);
416        // Empty history — nothing to attach to.
417        let mut empty = Vec::new();
418        assert!(!recover_crashed_turn(&mut empty, &draft(0, "partial")));
419        assert!(empty.is_empty());
420    }
421
422    #[test]
423    fn recovery_is_idempotent_through_its_own_marker() {
424        let mut msgs = vec![user("do X")];
425        let d = draft(1, "partial");
426        assert!(recover_crashed_turn(&mut msgs, &d));
427        let once = msgs.clone();
428        assert!(!recover_crashed_turn(&mut msgs, &d), "second load (removal lost)");
429        assert_eq!(msgs, once);
430    }
431
432    #[test]
433    fn legacy_migration_without_history_just_drops_the_recap() {
434        let mut msgs = Vec::new();
435        let mut ctx = Some("recap".to_string());
436        assert!(migrate_legacy_abort_context(&mut msgs, &mut ctx));
437        assert!(msgs.is_empty() && ctx.is_none());
438    }
439}