Skip to main content

lc_core/
json_parse.rs

1// src/core/json_parse.rs
2//! Tolerant JSON parsing for LLM outputs.
3//!
4//! LLMs frequently produce malformed JSON: trailing commas, unescaped quotes,
5//! incomplete brackets, or markdown-wrapped code blocks. The repair pipeline
6//! lives in [`lc_shared::json_repair`] so that every parser of LLM JSON —
7//! including `ToolCall::parse_arguments` in `lc-shared` — shares one tolerant
8//! parser. This module keeps the historical public API (`parse_llm_json`,
9//! `parse_llm_json_with_retry`, `LlmJsonParseError`) and delegates to it.
10
11use lc_shared::json_repair::{parse_tolerant_json, JsonRepairError};
12use serde::de::DeserializeOwned;
13
14/// Error types for LLM JSON parsing.
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum LlmJsonParseError {
18    /// The raw text could not be repaired into valid JSON.
19    #[error("JSON repair failed: {0}")]
20    RepairFailed(String),
21
22    /// The repaired JSON could not be deserialized into the target type.
23    #[error("Deserialization failed: {details}")]
24    DeserializationFailed {
25        /// The deserialization error detail.
26        details: String,
27    },
28
29    /// All retry attempts failed.
30    #[error("All {attempts} retry attempts failed")]
31    RetryExhausted {
32        /// The number of retry attempts that were made.
33        attempts: usize,
34    },
35}
36
37impl From<JsonRepairError> for LlmJsonParseError {
38    fn from(e: JsonRepairError) -> Self {
39        match e {
40            JsonRepairError::RepairFailed(msg) => LlmJsonParseError::RepairFailed(msg),
41            JsonRepairError::DeserializationFailed { details } => {
42                LlmJsonParseError::DeserializationFailed { details }
43            }
44            // `JsonRepairError` is `#[non_exhaustive]`; forward any future
45            // variants to the generic repair-failure error.
46            _ => LlmJsonParseError::RepairFailed(e.to_string()),
47        }
48    }
49}
50
51/// Parses LLM JSON output with automatic repair of common errors.
52///
53/// Applies these repairs in order:
54/// 1. Strip markdown code fences (```json ... ```)
55/// 2. Find the outermost JSON bracket pair
56/// 3. Fix unescaped inner quotes within string values (heuristic)
57/// 4. Remove trailing commas before `}` or `]`
58/// 5. Truncate to the last matching `}` or `]` if there's trailing garbage
59///
60/// Returns the deserialized value or an error if repair fails.
61pub fn parse_llm_json<T: DeserializeOwned>(raw: &str) -> Result<T, LlmJsonParseError> {
62    parse_tolerant_json::<T>(raw).map_err(Into::into)
63}
64
65/// Parses LLM JSON with retry: if parsing fails, calls the provided callback
66/// to get a corrected response from the LLM, and tries again.
67///
68/// The callback receives the original raw text and the parse error message,
69/// and should return the LLM's corrected output. Up to `max_retries` attempts
70/// are made.
71pub async fn parse_llm_json_with_retry<T, F, Fut>(
72    raw: &str,
73    max_retries: usize,
74    retry_callback: F,
75) -> Result<T, LlmJsonParseError>
76where
77    T: DeserializeOwned,
78    F: Fn(&str, &str) -> Fut,
79    Fut: std::future::Future<Output = Result<String, LlmJsonParseError>>,
80{
81    let mut current_raw = raw.to_string();
82
83    for attempt in 0..=max_retries {
84        match parse_llm_json::<T>(&current_raw) {
85            Ok(value) => return Ok(value),
86            Err(e) if attempt < max_retries => {
87                let error_msg = e.to_string();
88                let corrected =
89                    retry_callback(&current_raw, &error_msg)
90                        .await
91                        .map_err(|repair_err| {
92                            LlmJsonParseError::RepairFailed(repair_err.to_string())
93                        })?;
94                current_raw = corrected;
95            }
96            Err(_) => {
97                return Err(LlmJsonParseError::RetryExhausted {
98                    attempts: attempt + 1,
99                });
100            }
101        }
102    }
103
104    Err(LlmJsonParseError::RetryExhausted {
105        attempts: max_retries + 1,
106    })
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use serde::Deserialize;
113
114    #[derive(Debug, Deserialize, PartialEq)]
115    struct TestStruct {
116        name: String,
117        value: i32,
118    }
119
120    #[test]
121    fn test_parse_valid_json() {
122        let raw = r#"{"name": "test", "value": 42}"#;
123        let result: TestStruct = parse_llm_json(raw).unwrap();
124        assert_eq!(result.name, "test");
125        assert_eq!(result.value, 42);
126    }
127
128    #[test]
129    fn test_parse_json_with_code_fence() {
130        let raw = "```json\n{\"name\": \"test\", \"value\": 42}\n```";
131        let result: TestStruct = parse_llm_json(raw).unwrap();
132        assert_eq!(result.name, "test");
133    }
134
135    #[test]
136    fn test_parse_json_with_trailing_comma() {
137        let raw = r#"{"name": "test", "value": 42,}"#;
138        let result: TestStruct = parse_llm_json(raw).unwrap();
139        assert_eq!(result.name, "test");
140        assert_eq!(result.value, 42);
141    }
142
143    #[test]
144    fn test_parse_json_with_surrounding_text() {
145        let raw = "Here is the result: {\"name\": \"test\", \"value\": 42} done.";
146        let result: TestStruct = parse_llm_json(raw).unwrap();
147        assert_eq!(result.name, "test");
148    }
149
150    #[test]
151    fn test_parse_json_with_trailing_garbage() {
152        let raw = r#"{"name": "test", "value": 42} and some extra text"#;
153        let result: TestStruct = parse_llm_json(raw).unwrap();
154        assert_eq!(result.name, "test");
155    }
156
157    #[test]
158    fn test_parse_json_with_unescaped_inner_quotes() {
159        let raw = r#"{"name": "He said "hi" and left", "value": 1}"#;
160        let result: TestStruct = parse_llm_json(raw).unwrap();
161        assert_eq!(result.name, "He said \"hi\" and left");
162        assert_eq!(result.value, 1);
163    }
164
165    #[test]
166    fn test_parse_json_array_with_trailing_comma() {
167        let raw = r#"[{"name": "a", "value": 1}, {"name": "b", "value": 2},]"#;
168        let result: Vec<TestStruct> = parse_llm_json(raw).unwrap();
169        assert_eq!(result.len(), 2);
170    }
171
172    #[test]
173    fn test_parse_empty_text_fails() {
174        let result: Result<TestStruct, _> = parse_llm_json("");
175        assert!(result.is_err());
176    }
177
178    #[test]
179    fn test_parse_no_json_content_fails() {
180        let result: Result<TestStruct, _> = parse_llm_json("just some plain text");
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn test_parse_json_no_closing_fence() {
186        let raw = "```json\n{\"name\": \"test\", \"value\": 42}";
187        let result: TestStruct = parse_llm_json(raw).unwrap();
188        assert_eq!(result.name, "test");
189    }
190
191    #[test]
192    fn test_error_display() {
193        let err = LlmJsonParseError::RepairFailed("no json".to_string());
194        assert!(err.to_string().contains("no json"));
195
196        let err = LlmJsonParseError::RetryExhausted { attempts: 3 };
197        assert!(err.to_string().contains("3"));
198    }
199
200    #[tokio::test]
201    async fn test_parse_with_retry_succeeds_on_first_try() {
202        let raw = r#"{"name": "test", "value": 42}"#;
203        let result: TestStruct = parse_llm_json_with_retry(raw, 2, |_raw, _err| async {
204            Ok("should not be called".to_string())
205        })
206        .await
207        .unwrap();
208        assert_eq!(result.name, "test");
209    }
210
211    #[tokio::test]
212    async fn test_parse_with_retry_succeeds_on_second_try() {
213        let bad_raw = "not json at all";
214        let good_raw = r#"{"name": "retry", "value": 7}"#;
215        let result: TestStruct =
216            parse_llm_json_with_retry(bad_raw, 2, |_raw, _err| async { Ok(good_raw.to_string()) })
217                .await
218                .unwrap();
219        assert_eq!(result.name, "retry");
220        assert_eq!(result.value, 7);
221    }
222
223    #[tokio::test]
224    async fn test_parse_with_retry_fails_all_attempts() {
225        let bad_raw = "not json";
226        let result: Result<TestStruct, _> =
227            parse_llm_json_with_retry(bad_raw, 1, |_raw, _err| async {
228                Ok("still not json".to_string())
229            })
230            .await;
231        assert!(result.is_err());
232    }
233}