Skip to main content

agent_core/core/
host_note.rs

1//! Host-authored notes the session records into history as plain user-role
2//! text, recognised by their exact generated wording (like the interruption
3//! markers in `synaps_engine::engine::interrupt`), never by a host key.
4//!
5//! A host key (`_synaps_context`) would make a rolled-back 0.10.2 take the
6//! note for a context-archive marker and refuse every turn under Axel
7//! memory, so the note stays ordinary text on disk and on the wire. Readers
8//! that must not treat it as something the user said (turn starts, archive
9//! projection, memory capture) ask [`is_tool_in_flight_note`].
10
11use serde_json::Value;
12
13/// Every cancel phrase a note can lead with: the engine's
14/// `CancelCause::phrase` values, plus the generic one for reasons without a
15/// live cancel. The engine pins that its phrases are all listed here.
16pub const CANCEL_PHRASES: &[&str] = &[
17    "Canceled by user",
18    "Canceled (session cost cap reached)",
19    "Canceled (Synaps restarted)",
20    "Canceled (session stopped by the host)",
21    "Canceled (session driver revoked)",
22    "Canceled",
23];
24
25/// How the note names a tool whose name isn't a plausible tool name.
26pub const UNRECOGNISED_TOOL: &str = "(an unrecognised tool)";
27
28/// A name the note may quote: what tool wire names look like
29/// (`ToolRegistry` exposes `[A-Za-z0-9_-]{1,128}`), plus `.` and `:` for
30/// runtime-style names. The name comes straight from the model's call, so
31/// anything else (a backtick, a newline, an overlong string) is never
32/// copied into a user-role message: it reads [`UNRECOGNISED_TOOL`].
33pub fn is_plausible_tool_name(name: &str) -> bool {
34    (1..=128).contains(&name.len())
35        && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-'))
36}
37
38/// The note a cancel records for tool calls the interrupted response made
39/// when the engine never reported back on them: the engine's canceled
40/// `tool_result` wording, hedged to "may have already started" because the
41/// host can't tell whether the call began. One line.
42pub fn tool_in_flight_note_text(phrase: &str, tools: &[String]) -> String {
43    let names = tools
44        .iter()
45        .map(|t| if is_plausible_tool_name(t) { format!("`{t}`") } else { UNRECOGNISED_TOOL.into() })
46        .collect::<Vec<_>>()
47        .join(", ");
48    let (call, was, it, its) = if tools.len() == 1 {
49        ("tool call", "was", "It", "its")
50    } else {
51        ("tool calls", "were", "They", "their")
52    };
53    format!(
54        "[{phrase} while the {call} {names} {was} running. {it} may have already started and \
55         partially applied {its} effects.]"
56    )
57}
58
59/// `text` without its brackets iff it is exactly a note
60/// [`tool_in_flight_note_text`] generates: a single line, a known phrase,
61/// one or more names that are each a backticked plausible tool name or
62/// [`UNRECOGNISED_TOOL`], and nothing else. Checked by regenerating the
63/// note from the names it contains, so there is no free-form part. `None`
64/// for anything else.
65pub fn tool_in_flight_note_label(text: &str) -> Option<&str> {
66    if text.contains(['\n', '\r']) {
67        return None;
68    }
69    for phrase in CANCEL_PHRASES {
70        for (call, was) in [("tool call", "was"), ("tool calls", "were")] {
71            let Some(rest) = text.strip_prefix(&format!("[{phrase} while the {call} ")) else {
72                continue;
73            };
74            let Some(end) = rest.find(&format!(" {was} running. ")) else {
75                continue;
76            };
77            // A plausible name has no space or comma, so ", " splits items.
78            let tools: Option<Vec<String>> = rest[..end]
79                .split(", ")
80                .map(|item| match item.strip_prefix('`').and_then(|i| i.strip_suffix('`')) {
81                    Some(name) if is_plausible_tool_name(name) => Some(name.to_string()),
82                    // Regenerates as the fallback item.
83                    None if item == UNRECOGNISED_TOOL => Some(String::new()),
84                    _ => None,
85                })
86                .collect();
87            if tools.is_some_and(|tools| tool_in_flight_note_text(phrase, &tools) == text) {
88                return Some(&text[1..text.len() - 1]);
89            }
90        }
91    }
92    None
93}
94
95/// A user-role history message whose string content is exactly a
96/// tool-in-flight note.
97pub fn is_tool_in_flight_note(message: &Value) -> bool {
98    message["role"] == "user"
99        && message["content"]
100            .as_str()
101            .is_some_and(|text| tool_in_flight_note_label(text).is_some())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use serde_json::json;
108
109    #[test]
110    fn generated_notes_round_trip_and_nothing_else_matches() {
111        for phrase in CANCEL_PHRASES {
112            for tools in [vec!["bash".to_string()], vec!["read".into(), "edit".into(), "bash".into()]] {
113                let note = tool_in_flight_note_text(phrase, &tools);
114                assert_eq!(tool_in_flight_note_label(&note), Some(&note[1..note.len() - 1]));
115                assert!(is_tool_in_flight_note(&json!({"role": "user", "content": note})));
116            }
117        }
118        let note = tool_in_flight_note_text("Canceled by user", &["bash".into()]);
119        for text in [
120            note.replace("running.", "running.\n"),
121            note.replace("`bash`", "`bash` `x`"),
122            note.replace("`bash`", "`bash"),
123            format!(" {note}"),
124            "[]".into(),
125            "``".into(),
126        ] {
127            assert_eq!(tool_in_flight_note_label(&text), None, "{text:?}");
128        }
129        assert!(!is_tool_in_flight_note(&json!({"role": "assistant", "content": note})));
130    }
131
132    /// A tool name comes straight from the model's call: one that isn't a
133    /// plausible tool name (a backtick, a newline, too long) never reaches
134    /// the note; it reads "(an unrecognised tool)", and the note is still
135    /// recognised exactly.
136    #[test]
137    fn implausible_tool_names_become_the_fallback_and_stay_recognised() {
138        for bad in ["a`b".to_string(), "a\nb".into(), "a".repeat(200), String::new(), "rm -rf /".into()] {
139            let note = tool_in_flight_note_text("Canceled by user", std::slice::from_ref(&bad));
140            assert_eq!(
141                note,
142                "[Canceled by user while the tool call (an unrecognised tool) was running. It \
143                 may have already started and partially applied its effects.]",
144                "{bad:?}"
145            );
146            assert!(tool_in_flight_note_label(&note).is_some(), "{bad:?}");
147        }
148        let mixed = tool_in_flight_note_text("Canceled", &["read".into(), "x`y".into()]);
149        assert_eq!(
150            mixed,
151            "[Canceled while the tool calls `read`, (an unrecognised tool) were running. They \
152             may have already started and partially applied their effects.]"
153        );
154        assert!(tool_in_flight_note_label(&mixed).is_some());
155        // Plausible names, including MCP/extension style, are kept.
156        let kept = tool_in_flight_note_text("Canceled", &["mcp__fs.read:v2-x".into(), "a".repeat(128)]);
157        assert!(kept.contains("`mcp__fs.read:v2-x`") && kept.contains(&format!("`{}`", "a".repeat(128))));
158        assert!(tool_in_flight_note_label(&kept).is_some());
159        // A hand-written name that isn't plausible is not the note.
160        let forged = "[Canceled while the tool call `a b` was running. It may have already started \
161                      and partially applied its effects.]";
162        assert_eq!(tool_in_flight_note_label(forged), None);
163    }
164}