Skip to main content

sqlite_graphrag/output/
error_envelope.rs

1//! Failure envelopes on stdout, with a hand-rolled fallback.
2//!
3//! The stdout JSON contract holds on error paths too: a machine consumer must
4//! be able to parse the failure, not just read a stderr line. That is why each
5//! emitter here has a second path built with `writeln!` — if `serde_json`
6//! itself fails, the caller still receives a parseable envelope rather than
7//! empty stdout.
8//!
9//! These envelopes carry `error: true`, which [`crate::agent_surface`] treats
10//! as pass-through. A `--filter` can never suppress a failure.
11
12use super::envelope::emit_json;
13
14/// Escapes a string for inclusion in a hand-built JSON string literal.
15///
16/// Only the two characters that can break out of a quoted literal are
17/// handled, which is exactly what the fallback paths below need: the inputs
18/// are localized messages and suggestions, never arbitrary binary. A control
19/// character would produce technically invalid JSON, but the alternative —
20/// pulling in a serializer on the path taken *because* serialization failed —
21/// defeats the point of having a fallback at all.
22fn escape(value: &str) -> String {
23    value.replace('\\', "\\\\").replace('"', "\\\"")
24}
25
26/// The one failure envelope every emitter below renders.
27///
28/// `error_class` and `retryable` carry no `skip_serializing_if`: an agent needs
29/// the retry verdict on EVERY failure, and a field that vanishes when it is
30/// `false` forces the reader to distinguish "not retryable" from "this build
31/// does not report it". Only `suggestion` is optional, because a variant whose
32/// message is already self-remediating has nothing to add.
33#[derive(serde::Serialize)]
34struct ErrorEnvelope<'a> {
35    error: bool,
36    code: i32,
37    message: &'a str,
38    error_class: &'a str,
39    retryable: bool,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    suggestion: Option<&'a str>,
42    /// Flags the caller passed that the invocation could not apply.
43    ///
44    /// Omitted when empty, because most failures discard nothing and an always
45    /// present empty array would invite a reader to treat `[]` as meaningful.
46    #[serde(skip_serializing_if = "<[String]>::is_empty")]
47    discarded_flags: &'a [String],
48    /// GAP-SG-205: the database this process resolved, and which layer named it.
49    ///
50    /// Carried by the struct rather than attached by [`crate::agent_surface`],
51    /// which treats a failure envelope as pass-through — deliberately, so a
52    /// `--filter` can never suppress an error. Two consequences make this the
53    /// right home: the invariant "a failure reaches the caller verbatim" stays
54    /// literally true, and the hand-rolled fallback below still emits the target
55    /// on the one path that exists BECAUSE serialization failed. Knowing which
56    /// database a failed write was aimed at matters most exactly there.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    agent_surface: Option<serde_json::Map<String, serde_json::Value>>,
59}
60
61/// Emits the failure envelope, with a hand-rolled fallback.
62///
63/// Single rendering point: the fallback path used to exist twice, once per
64/// public emitter, so a field added to one envelope silently skipped the other.
65///
66/// Shape: `{"error": true, "code": <exit>, "message": "...", "error_class":
67/// "transient|permanent|ambiguous", "retryable": <bool>, "suggestion": "..."}`.
68/// A `BrokenPipe` is silenced so piping to an early-closing consumer does not
69/// surface a secondary error.
70#[cold]
71#[inline(never)]
72pub fn emit_error_envelope(
73    code: i32,
74    message: &str,
75    error_class: &str,
76    retryable: bool,
77    suggestion: Option<&str>,
78    discarded_flags: &[String],
79) {
80    // Production call site, so it reads the process-wide ceiling directly. The
81    // failure envelope reports the same target and ceiling a success would have,
82    // which is what lets a caller diagnose a refusal without a second query.
83    let target = crate::agent_surface::target::record(
84        crate::agent_surface::get(),
85        crate::agent_surface::universe::get(),
86    );
87    let envelope = ErrorEnvelope {
88        error: true,
89        code,
90        message,
91        error_class,
92        retryable,
93        suggestion,
94        discarded_flags,
95        agent_surface: target.clone(),
96    };
97    if emit_json(&envelope).is_err() {
98        use std::io::Write;
99        let escaped = escape(message);
100        let esc_class = escape(error_class);
101        let mut line = format!(
102            r#"{{"error":true,"code":{code},"message":"{escaped}","error_class":"{esc_class}","retryable":{retryable}"#
103        );
104        if let Some(s) = suggestion {
105            line.push_str(&format!(r#","suggestion":"{}""#, escape(s)));
106        }
107        if !discarded_flags.is_empty() {
108            let list = discarded_flags
109                .iter()
110                .map(|f| format!(r#""{}""#, escape(f)))
111                .collect::<Vec<_>>()
112                .join(",");
113            line.push_str(&format!(r#","discarded_flags":[{list}]"#));
114        }
115        // Rendered by hand for the same reason as everything else on this path:
116        // the branch exists because `serde_json` already failed once, so leaning
117        // on it again to serialize the target would drop exactly the field that
118        // says where a failed write was pointed.
119        if let Some(map) = &target {
120            let members = map
121                .iter()
122                .map(|(k, v)| {
123                    let text = v.as_str().unwrap_or_default();
124                    format!(r#""{}":"{}""#, escape(k), escape(text))
125                })
126                .collect::<Vec<_>>()
127                .join(",");
128            line.push_str(&format!(r#","agent_surface":{{{members}}}"#));
129        }
130        line.push('}');
131        let _ = writeln!(std::io::stdout().lock(), "{line}");
132    }
133}
134
135/// Emits a configuration failure, which is permanent by construction.
136///
137/// Bootstrap failures — a missing provider key, an unreadable XDG file, a model
138/// the catalogue rejects — cannot be fixed by trying again, so they are always
139/// `permanent` / `retryable: false`. Callers holding a real [`AppError`] must
140/// use [`emit_error_json_with_suggestion`] instead, which reads the verdict off
141/// the variant.
142///
143/// [`AppError`]: crate::errors::AppError
144#[cold]
145#[inline(never)]
146pub fn emit_error_json(code: i32, message: &str) {
147    emit_error_envelope(code, message, "permanent", false, None, &[]);
148}
149
150/// GAP-SG-39: emits the actionable failure envelope for a classified error.
151///
152/// The `suggestion` tells the operator HOW to recover instead of leaving an exit
153/// code without guidance, and `error_class` / `retryable` tell an agent whether
154/// recovering is even possible — which is what makes a write rejection
155/// observable, fixable, and safe to automate.
156#[cold]
157#[inline(never)]
158pub fn emit_error_json_with_suggestion(
159    code: i32,
160    message: &str,
161    error_class: &str,
162    retryable: bool,
163    suggestion: Option<&str>,
164    discarded_flags: &[String],
165) {
166    emit_error_envelope(
167        code,
168        message,
169        error_class,
170        retryable,
171        suggestion,
172        discarded_flags,
173    );
174}
175
176#[cfg(test)]
177mod tests {
178    use super::escape;
179
180    #[test]
181    fn escape_leaves_plain_text_untouched() {
182        assert_eq!(escape("database is malformed"), "database is malformed");
183    }
184
185    #[test]
186    fn escape_protects_quotes_and_backslashes() {
187        assert_eq!(escape(r#"say "hi""#), r#"say \"hi\""#);
188        assert_eq!(escape(r"C:\path"), r"C:\\path");
189    }
190
191    #[test]
192    fn escape_orders_backslash_before_quote() {
193        // Escaping the quote first would then double the backslash it just
194        // introduced, producing `\\"` and breaking out of the literal.
195        assert_eq!(escape(r#"\""#), r#"\\\""#);
196    }
197}