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}