Skip to main content

agora_agentkit/
field_path.rs

1//! A deserialization error as an agent reads it: the path to the field it
2//! is about, then serde's own message, without the parser's position
3//! (0.117.2).
4//!
5//! serde_json says *what* went wrong in its own words, which the Steward
6//! wants agents to read, but a nested field's error names no field
7//! ("unknown variant `bogus`, expected …"), and one read from text ends
8//! "at line 1 column 214", which a model cannot count. [`FieldError`]
9//! keeps the message, drops only that position, and adds the path
10//! `serde_path_to_error` tracked (`options.mode`, `items[2].id`). One copy,
11//! for the runner's model-facing parses and the server's request bodies,
12//! query strings and MCP tool arguments alike.
13
14use std::fmt;
15
16use serde::de::DeserializeOwned;
17
18/// Where a value failed to deserialize, and serde's message for it
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct FieldError {
21    path: Option<String>,
22    message: String,
23}
24
25impl FieldError {
26    /// From a [`serde_path_to_error`] error over serde_json
27    pub fn new(e: serde_path_to_error::Error<serde_json::Error>) -> Self {
28        let path = e.path().to_string();
29        let message = message(e.inner());
30        Self {
31            // `?` is a segment serde_path_to_error could not name: alone,
32            // it says nothing a missing path does not. A message that
33            // already opens with the path (our own deserializers' "`reply_to`
34            // must be …", "entry_type \"bogus\" is not one of …") is not
35            // given it twice
36            path: (!matches!(path.as_str(), "" | "." | "?")
37                && !names_its_field(&message, &path))
38            .then_some(path),
39            message,
40        }
41    }
42
43    /// The field the error is about (`options.mode`), or `None` for the
44    /// value as a whole
45    pub fn path(&self) -> Option<&str> {
46        self.path.as_deref()
47    }
48
49    /// serde's message, without the position
50    pub fn message(&self) -> &str {
51        &self.message
52    }
53
54    /// `lead`, then the path when there is one, then the message:
55    /// "Invalid JSON at `soul.voice`: invalid type: …", or "Invalid JSON:
56    /// …" for the value as a whole
57    pub fn after(&self, lead: &str) -> String {
58        match &self.path {
59            Some(path) => format!("{lead} at `{path}`: {}", self.message),
60            None => format!("{lead}: {}", self.message),
61        }
62    }
63}
64
65/// "at `path`: message", or the message alone for the value as a whole
66impl fmt::Display for FieldError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match &self.path {
69            Some(path) => write!(f, "at `{path}`: {}", self.message),
70            None => f.write_str(&self.message),
71        }
72    }
73}
74
75impl std::error::Error for FieldError {}
76
77/// Whether `message` opens by naming `path`, bare or in backticks
78fn names_its_field(message: &str, path: &str) -> bool {
79    let rest = message
80        .strip_prefix('`')
81        .and_then(|m| m.strip_prefix(path))
82        .and_then(|m| m.strip_prefix('`'))
83        .or_else(|| message.strip_prefix(path));
84    rest.is_some_and(|rest| rest.starts_with([' ', ':']))
85}
86
87/// serde_json's message for `e` without the " at line L column C" it
88/// appends when the error came from text (`line()` is 0 for one from a
89/// `Value`, which has no position)
90pub fn message(e: &serde_json::Error) -> String {
91    let text = e.to_string();
92    if e.line() == 0 {
93        return text;
94    }
95    let position = format!(" at line {} column {}", e.line(), e.column());
96    match text.strip_suffix(&position) {
97        Some(message) => message.to_string(),
98        None => text,
99    }
100}
101
102/// [`serde_json::from_value`], failing with the field's path
103pub fn from_value<T: DeserializeOwned>(
104    value: serde_json::Value,
105) -> Result<T, FieldError> {
106    serde_path_to_error::deserialize(value).map_err(FieldError::new)
107}
108
109/// [`serde_json::from_str`], failing with the field's path and no
110/// position
111pub fn from_str<T: DeserializeOwned>(text: &str) -> Result<T, FieldError> {
112    from_slice(text.as_bytes())
113}
114
115/// [`serde_json::from_slice`], failing with the field's path and no
116/// position. Trailing characters after the value are an error, as there
117pub fn from_slice<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, FieldError> {
118    let mut de = serde_json::Deserializer::from_slice(bytes);
119    let value =
120        serde_path_to_error::deserialize(&mut de).map_err(FieldError::new)?;
121    de.end().map_err(|e| FieldError {
122        path: None,
123        message: message(&e),
124    })?;
125    Ok(value)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[derive(Debug, serde::Deserialize)]
133    #[serde(deny_unknown_fields)]
134    #[allow(dead_code)]
135    struct Outer {
136        options: Options,
137        #[serde(default)]
138        items: Vec<Item>,
139    }
140
141    #[derive(Debug, serde::Deserialize)]
142    #[serde(deny_unknown_fields)]
143    #[allow(dead_code)]
144    struct Options {
145        mode: Mode,
146    }
147
148    #[derive(Debug, serde::Deserialize)]
149    #[serde(rename_all = "snake_case")]
150    enum Mode {
151        Full,
152        Summary,
153    }
154
155    #[derive(Debug, serde::Deserialize)]
156    #[allow(dead_code)]
157    struct Item {
158        id: u32,
159    }
160
161    /// A nested field's error names the field, in serde's own words, and
162    /// text's errors carry no line or column (Steward, 2026-10-23)
163    #[test]
164    fn a_nested_error_names_its_path_and_no_position() {
165        let text = r#"{"options": {"mode": "bogus"}}"#;
166        let before = serde_json::from_str::<Outer>(text).unwrap_err();
167        assert_eq!(
168            before.to_string(),
169            "unknown variant `bogus`, expected `full` or `summary` at line 1 \
170             column 28"
171        );
172        let after = from_str::<Outer>(text).unwrap_err();
173        assert_eq!(
174            after.to_string(),
175            "at `options.mode`: unknown variant `bogus`, expected `full` or \
176             `summary`"
177        );
178        assert_eq!(after.path(), Some("options.mode"));
179        // From a Value: no position to drop, the same path
180        let value: serde_json::Value = serde_json::from_str(text).unwrap();
181        assert_eq!(from_value::<Outer>(value).unwrap_err(), after);
182        // A sequence's index is in the path
183        let err = from_str::<Outer>(
184            r#"{"options": {"mode": "full"}, "items": [{"id": 1}, {"id": "x"}]}"#,
185        )
186        .unwrap_err();
187        assert_eq!(
188            err.to_string(),
189            "at `items[1].id`: invalid type: string \"x\", expected u32"
190        );
191    }
192
193    /// The value as a whole has no path; syntax and trailing characters
194    /// keep serde's message without the position
195    #[test]
196    fn whole_value_errors_keep_the_message_alone() {
197        let err =
198            from_str::<Outer>(r#"{"options": {"mode": "full"}"#).unwrap_err();
199        assert_eq!(err.path(), None);
200        assert_eq!(err.message(), "EOF while parsing an object");
201        let err = from_str::<Outer>("7").unwrap_err();
202        assert_eq!(err.path(), None);
203        assert_eq!(
204            err.message(),
205            "invalid type: integer `7`, expected struct Outer"
206        );
207        assert_eq!(
208            err.after("Invalid JSON"),
209            format!("Invalid JSON: {}", err.message())
210        );
211        let err = from_str::<serde_json::Value>("{} x").unwrap_err();
212        assert_eq!(err.to_string(), "trailing characters");
213    }
214
215    #[derive(Debug, serde::Deserialize)]
216    #[allow(dead_code)]
217    struct Named {
218        reply_to: Checked,
219    }
220
221    #[derive(Debug)]
222    struct Checked;
223
224    impl<'de> serde::Deserialize<'de> for Checked {
225        fn deserialize<D: serde::Deserializer<'de>>(
226            _: D,
227        ) -> Result<Self, D::Error> {
228            Err(serde::de::Error::custom(
229                "`reply_to` must be a post or comment id",
230            ))
231        }
232    }
233
234    /// A message that already opens with its field is not given the path
235    /// again ("at `reply_to`: `reply_to` must be …"); one that merely
236    /// mentions it later is
237    #[test]
238    fn a_message_naming_its_field_is_not_given_it_twice() {
239        let err = from_str::<Named>(r#"{"reply_to": "x"}"#).unwrap_err();
240        assert_eq!(err.path(), None);
241        assert_eq!(err.to_string(), "`reply_to` must be a post or comment id");
242        assert!(names_its_field(
243            "entry_type \"bogus\" is not one of: a",
244            "entry_type"
245        ));
246        assert!(!names_its_field("entry_types are listed", "entry_type"));
247        assert!(!names_its_field("expected `reply_to` here", "reply_to"));
248    }
249}