diff --git a/crates/tinytools-agent/src/parse/grammar/bare_json.rs b/crates/tinytools-agent/src/parse/grammar/bare_json.rs index 4185b1c..155838b 100644 --- a/crates/tinytools-agent/src/parse/grammar/bare_json.rs +++ b/crates/tinytools-agent/src/parse/grammar/bare_json.rs @@ -16,7 +16,7 @@ //! alone; `{"name":"Alice","input":"hi"}` is left alone. use crate::parse::call_object::{AliasPolicy, read_calls}; -use crate::repair::json::{recover_object, strip_code_fence}; +use crate::repair::json::{recover_whole_object, strip_code_fence}; use crate::types::{CallSource, ParseOptions, ParsedToolCall}; /// The calls in a whole-response JSON value, plus any `content` text it @@ -36,7 +36,7 @@ pub(crate) fn parse( Ok(value) => value, // A non-object that parsed strictly is not a call; do not "repair" // it into one. Only an object-shaped candidate is worth recovering. - Err(_) if first == '{' => recover_object(candidate)?, + Err(_) if first == '{' => recover_whole_object(candidate)?, Err(_) => return None, }; diff --git a/crates/tinytools-agent/src/parse/grammar/harmony.rs b/crates/tinytools-agent/src/parse/grammar/harmony.rs index 2f27b28..d23c4ae 100644 --- a/crates/tinytools-agent/src/parse/grammar/harmony.rs +++ b/crates/tinytools-agent/src/parse/grammar/harmony.rs @@ -24,6 +24,9 @@ pub(crate) struct Harmony; const CHANNEL: &str = "<|channel|>"; const MESSAGE: &str = "<|message|>"; const TERMINATORS: &[&str] = &["<|call|>", "<|end|>", "<|return|>"]; +/// The Harmony template's per-turn preamble, always immediately before the +/// first channel. Furniture, not narrative — see the module doc. +const START_PREFIX: &str = "<|start|>assistant"; impl Grammar for Harmony { fn source(&self) -> CallSource { @@ -33,10 +36,11 @@ impl Grammar for Harmony { fn probe(&self, text: &str, from: usize, _options: &ParseOptions<'_>, mode: ScanMode) -> Probe { let mut cursor = from; while let Some(idx) = find_ci(text, CHANNEL, cursor) { + let start = absorb_start_prefix(text, idx); let header_start = idx + CHANNEL.len(); let Some(message_rel) = find_ci(text, MESSAGE, header_start) else { if mode == ScanMode::Stream { - return Probe::Pending { start: idx }; + return Probe::Pending { start }; } return Probe::None; }; @@ -54,12 +58,17 @@ impl Grammar for Harmony { .min_by_key(|(i, _)| *i); let Some((payload_end, term_end)) = terminator else { if mode == ScanMode::Stream { - return Probe::Pending { start: idx }; + return Probe::Pending { start }; } // Batch: the payload runs to the end of the text. - return found(idx, text.len(), &name, after); + return found(start, text.len(), &name, after); }; - return found(idx, payload_start + term_end, &name, &after[..payload_end]); + return found( + start, + payload_start + term_end, + &name, + &after[..payload_end], + ); } Probe::None } @@ -69,6 +78,23 @@ impl Grammar for Harmony { } } +/// Extends a channel marker's position backward over an immediately +/// preceding [`START_PREFIX`], so it is dropped along with the call instead +/// of leaking into the narrative (or, mid-stream, being released before the +/// scrubber knows a call follows it). +fn absorb_start_prefix(text: &str, idx: usize) -> usize { + let Some(prefix_start) = idx.checked_sub(START_PREFIX.len()) else { + return idx; + }; + if text.is_char_boundary(prefix_start) + && text[prefix_start..idx].eq_ignore_ascii_case(START_PREFIX) + { + prefix_start + } else { + idx + } +} + fn found(start: usize, end: usize, name: &str, payload: &str) -> Probe { let arguments = recover_object(payload).unwrap_or_else(|| serde_json::json!({})); Probe::Found(Block { diff --git a/crates/tinytools-agent/src/parse/grammar/invoke_xml.rs b/crates/tinytools-agent/src/parse/grammar/invoke_xml.rs index 499fa23..859cb25 100644 --- a/crates/tinytools-agent/src/parse/grammar/invoke_xml.rs +++ b/crates/tinytools-agent/src/parse/grammar/invoke_xml.rs @@ -49,6 +49,20 @@ static WRAPPER_RE: LazyLock> = LazyLock::new(|| { .ok() }); +/// The start of an `` to match. +/// +/// [`OPEN_RE`] only matches a *complete* opening tag, so a fragment boundary +/// that falls before the `>` — `> = + LazyLock::new(|| Regex::new(&format!(r"(?is)<{PREFIX}(?:invoke|function)\b")).ok()); + /// A closing tag ending an invoke: its own, ``, or a stray /// `` some templates substitute. static CLOSE_RE: LazyLock> = LazyLock::new(|| { @@ -76,13 +90,19 @@ impl Grammar for InvokeXml { } fn probe(&self, text: &str, from: usize, _options: &ParseOptions<'_>, mode: ScanMode) -> Probe { - let pending = pending_opener( + let literal_pending = pending_opener( text, from, &["", mode, ); + let namespaced_pending = + (mode == ScanMode::Stream).then(|| Self::pending_namespaced_open(text, from)); + let pending = [literal_pending, namespaced_pending.flatten()] + .into_iter() + .flatten() + .min(); prefer_pending(Self::probe_decided(text, from, mode), pending) } @@ -103,6 +123,18 @@ impl Grammar for InvokeXml { } impl InvokeXml { + /// The start of a namespaced `` has not arrived yet, if any. + fn pending_namespaced_open(text: &str, from: usize) -> Option { + let re = OPEN_START_RE.as_ref()?; + let hay = &text[from..]; + let m = re.find(hay)?; + if hay[m.end()..].contains('>') { + return None; + } + Some(from + m.start()) + } + /// The next block whose opener is fully present. fn probe_decided(text: &str, from: usize, mode: ScanMode) -> Probe { let (Some(open_re), Some(wrapper_re), Some(close_re)) = diff --git a/crates/tinytools-agent/src/parse/grammar/mistral.rs b/crates/tinytools-agent/src/parse/grammar/mistral.rs index 0e575ca..8659af4 100644 --- a/crates/tinytools-agent/src/parse/grammar/mistral.rs +++ b/crates/tinytools-agent/src/parse/grammar/mistral.rs @@ -50,9 +50,16 @@ impl Grammar for Mistral { // v11+: `NAME[ARGS]{…}`, possibly several in a row. let mut calls = Vec::new(); let mut cursor = 0usize; + // Set when the loop stopped for a reason a stream fragment boundary + // can explain — a name (and possibly a partial `[ARGS]`) not yet + // finished, or a complete `NAME[ARGS]` whose JSON body has not fully + // arrived — as opposed to text that is definitively not a + // continuation (an invalid name). + let mut ambiguous_tail = false; loop { let rest = &after[cursor..]; let Some(args_rel) = rest.find(ARGS) else { + ambiguous_tail = could_be_v11_continuation(rest); break; }; let name = rest[..args_rel].trim(); @@ -65,6 +72,10 @@ impl Grammar for Mistral { } let payload = &rest[args_rel + ARGS.len()..]; let Some((value, consumed)) = extract_first_json_value_with_end(payload) else { + // A valid name and a complete `[ARGS]` marker, but the JSON + // body has not arrived complete yet — streaming cannot tell + // that apart from a fragment boundary landing mid-object. + ambiguous_tail = true; break; }; let arguments = if value.is_object() { @@ -78,18 +89,13 @@ impl Grammar for Mistral { if !calls.is_empty() { // The v11 form allows a second call to follow directly with no // fresh `[TOOL_CALLS]` marker (`NAME[ARGS]{…}NAME2[ARGS]{…}`). - // If the buffered text ends right where the trailing bytes - // could still grow into another such name, a stream fragment - // has not necessarily finished the block — finalizing now would - // drop the continuation call the moment it arrives split across - // a fragment boundary. Hold the whole block until either more + // If the buffered text ends right where the trailing bytes are + // still ambiguous, a stream fragment has not necessarily + // finished the block — finalizing now would drop the + // continuation call the moment it arrives split across a + // fragment boundary. Hold the whole block until either more // text disambiguates it or the stream ends. - let could_continue = mode == ScanMode::Stream - && after[cursor..] - .trim_start() - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.'); - if could_continue { + if mode == ScanMode::Stream && ambiguous_tail { return Probe::Pending { start }; } return Probe::Found(Block { @@ -115,3 +121,17 @@ impl Grammar for Mistral { &["[TOOL_CALLS]"] } } + +/// Whether `rest` (the text left over after the last complete v11 call, or +/// the whole body when no call has been read yet) is still consistent with +/// growing into another `NAME[ARGS]` pair: a run of name characters, +/// optionally followed by a proper prefix of the `[ARGS]` marker. +/// `NAME[ARGS]` itself never reaches this check — the caller only calls it +/// once `rest.find(ARGS)` has already failed. +fn could_be_v11_continuation(rest: &str) -> bool { + let trimmed = rest.trim_start(); + let name_len = trimmed + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.')) + .unwrap_or(trimmed.len()); + ARGS.starts_with(&trimmed[name_len..]) +} diff --git a/crates/tinytools-agent/src/parse/grammar/sentinel.rs b/crates/tinytools-agent/src/parse/grammar/sentinel.rs index 9b3611e..9fdc5d8 100644 --- a/crates/tinytools-agent/src/parse/grammar/sentinel.rs +++ b/crates/tinytools-agent/src/parse/grammar/sentinel.rs @@ -56,26 +56,26 @@ impl Grammar for Sentinel { } fn probe(&self, text: &str, from: usize, options: &ParseOptions<'_>, mode: ScanMode) -> Probe { - let pending = pending_opener( - text, - from, - &[ - "<|tool_call", - "<|tool▁call", - "<|tool_calls", - "<|tool▁calls", - ], - ">", - mode, - ); + let pending = pending_opener(text, from, self.openers(), ">", mode); prefer_pending(Self::probe_decided(text, from, options, mode), pending) } fn openers(&self) -> &'static [&'static str] { + // The bar style (`|` / `|`) and the word separator (`_` / `▁`) vary + // independently — `SEP_RE`/`CALL_BEGIN_RE`/etc. accept all four + // combinations — so every combination needs its own literal here. + // Only two of the four were listed before, which let a split after + // e.g. `<|tool_` (fullwidth bar, ASCII underscore) leak: neither + // literal is a substring of it, so `hold_from` released it as plain + // text and the completed marker was never recognized. &[ "<|tool_call", + "<|tool▁call", + "<|tool_call", "<|tool▁call", "<|tool_calls", + "<|tool▁calls", + "<|tool_calls", "<|tool▁calls", ] } diff --git a/crates/tinytools-agent/src/parse/grammar/tagged.rs b/crates/tinytools-agent/src/parse/grammar/tagged.rs index ec32fb3..0075afa 100644 --- a/crates/tinytools-agent/src/parse/grammar/tagged.rs +++ b/crates/tinytools-agent/src/parse/grammar/tagged.rs @@ -45,8 +45,19 @@ pub(crate) struct Tagged; static TAG_RE: LazyLock> = LazyLock::new(|| Regex::new(r"(?i)<[|/\s]*tool[_-]?call(?:[|/\s]*|\s+[^>]*)>").ok()); -/// Openers a fenced block can carry. -const FENCE_OPENERS: &[&str] = &["```tool_call", "```toolcall", "```tool-call", "```invoke"]; +/// Openers a fenced block can carry. `` ```tool_calls `` (plural) is listed +/// separately from `` ```tool_call `` rather than relying on a prefix match: +/// `next_opener` requires the language to end exactly at the literal, so +/// without its own entry the plural spelling — which +/// [`crate::parse::protected::TOOL_CALL_LANGUAGES`] already classifies as a +/// call language, not a protected example — would never be recognized here. +const FENCE_OPENERS: &[&str] = &[ + "```tool_call", + "```toolcall", + "```tool-call", + "```tool_calls", + "```invoke", +]; /// Kimi-family argument-quote sentinel that leaks in place of `"`. const ARG_QUOTE_SENTINEL: &str = "<|\"|>"; @@ -94,6 +105,7 @@ impl Grammar for Tagged { "```tool_call", "```toolcall", "```tool-call", + "```tool_calls", "```invoke", ] } diff --git a/crates/tinytools-agent/src/parse/test/bare_json.rs b/crates/tinytools-agent/src/parse/test/bare_json.rs index a0f4d16..234c08a 100644 --- a/crates/tinytools-agent/src/parse/test/bare_json.rs +++ b/crates/tinytools-agent/src/parse/test/bare_json.rs @@ -73,6 +73,12 @@ fn bare_recovery_never_swallows_a_genuine_text_answer() { r#"{"name":42}"#, r#""just a string""#, "[1, 2, 3]", + // A damaged leading object followed by unrelated trailing prose (or + // another object) must not be recovered via the trailing-noise rung + // that `recover_object` allows for marker-delimited call bodies — + // bare JSON has no such marker, so the whole response must be the + // call. + r#"{"name":"shell","arguments":{"command":"x"}} explanation {}"#, ] { let (cleaned, calls) = parse(text); assert!( @@ -83,6 +89,17 @@ fn bare_recovery_never_swallows_a_genuine_text_answer() { } } +#[test] +fn parse_options_default_matches_new_and_allows_bare_json() { + // The struct doc says the default allows bare JSON; a derived `Default` + // would instead leave `allow_bare_json` at `bool`'s `false`. + let outcome = crate::parse::parse_text( + r#"{"name":"echo","arguments":{}}"#, + &ParseOptions::default(), + ); + assert_eq!(outcome.calls.len(), 1); +} + #[test] fn bare_json_can_be_disabled() { let options = ParseOptions::new().without_bare_json(); diff --git a/crates/tinytools-agent/src/parse/test/harmony_mistral.rs b/crates/tinytools-agent/src/parse/test/harmony_mistral.rs index 85af5fb..ada3872 100644 --- a/crates/tinytools-agent/src/parse/test/harmony_mistral.rs +++ b/crates/tinytools-agent/src/parse/test/harmony_mistral.rs @@ -31,6 +31,17 @@ fn harmony_call_with_start_prefix_and_no_terminator_parses_in_batch() { assert_eq!(calls[0].name, "read"); } +#[test] +fn harmony_start_prefix_is_consumed_as_furniture() { + // `<|start|>assistant` is documented as furniture that precedes the + // first channel of a turn; it must not leak into the narrative. + let response = "<|start|>assistant<|channel|>commentary to=functions.read<|message|>{\"path\":\"a\"}<|call|>"; + let (text, calls) = parse(response); + assert!(text.is_empty(), "{text:?}"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "read"); +} + #[test] fn harmony_channel_with_target_but_no_message_is_not_a_call_in_batch_mode() { // No `<|message|>` ever arrives, so a batch parse cannot know whether a @@ -160,6 +171,30 @@ fn mistral_marker_with_no_call_yet_is_held_while_streaming() { assert_eq!(second.calls[0].name, "get_weather"); } +#[test] +fn mistral_v11_second_call_split_mid_args_bracket_is_not_lost() { + // The split falls after the second call's opening `[`, inside the + // `[ARGS]` marker itself rather than inside its name — a stream + // fragment boundary a naive name-only predicate does not recognize as + // still-ambiguous. + use crate::stream::StreamScrubber; + + let mut s = StreamScrubber::new(); + let first = s.feed("[TOOL_CALLS]a[ARGS]{}"); + assert!(first.calls.is_empty(), "must hold until disambiguated"); + let second = s.feed("b["); + assert!(second.calls.is_empty(), "must still hold: {second:?}"); + let third = s.feed("ARGS]{}"); + assert!( + third.calls.is_empty(), + "the block ends exactly at the fragment boundary, still ambiguous: {third:?}" + ); + let flushed = s.flush(); + assert_eq!(flushed.calls.len(), 2); + assert_eq!(flushed.calls[0].name, "a"); + assert_eq!(flushed.calls[1].name, "b"); +} + #[test] fn mistral_v11_second_call_split_across_fragments_is_not_lost() { // The v11 form lets a second call follow directly with no fresh diff --git a/crates/tinytools-agent/src/parse/test/tagged.rs b/crates/tinytools-agent/src/parse/test/tagged.rs index 8820fe8..403c6fc 100644 --- a/crates/tinytools-agent/src/parse/test/tagged.rs +++ b/crates/tinytools-agent/src/parse/test/tagged.rs @@ -114,6 +114,19 @@ fn fenced_tool_call_block_parses() { assert_eq!(calls.len(), 1); } +#[test] +fn fenced_tool_calls_plural_block_parses() { + // `protected::TOOL_CALL_LANGUAGES` already classifies `tool_calls` + // (plural) as a call language, not a protected code example; this + // grammar must recognize it as an opener too, not just decline to + // protect it. + let markdown = "before\n```tool_calls\n[{\"name\":\"a\",\"arguments\":{}}]\n```\nafter"; + let (text, calls) = parse(markdown); + assert_eq!(text, "before\nafter"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "a"); +} + #[test] fn fenced_block_closed_by_stray_tag_parses() { let hybrid = "```tool_call\n{\"name\":\"echo\",\"arguments\":{}}\n\nrest"; diff --git a/crates/tinytools-agent/src/repair/json.rs b/crates/tinytools-agent/src/repair/json.rs index 736aa2a..4f2045b 100644 --- a/crates/tinytools-agent/src/repair/json.rs +++ b/crates/tinytools-agent/src/repair/json.rs @@ -59,8 +59,31 @@ const MAX_EXCESS_CLOSERS: usize = 50; /// after `serde_json::from_str` has already failed on `raw`; a well-formed /// object is returned unchanged by the first rung anyway, but the ladder is not /// free. +/// +/// The final rung accepts a valid object followed by trailing noise — correct +/// when `raw` is already known to be *inside* a call (a marker-delimited +/// argument payload), where anything after the object is furniture, not data. +/// A whole-response candidate has no such delimiter and must use +/// [`recover_whole_object`] instead, which holds out for the entire candidate. #[must_use] pub fn recover_object(raw: &str) -> Option { + recover_ladder(raw, true) +} + +/// [`recover_object`]'s ladder, but without the trailing-noise rung: the +/// repaired object must account for the **entire** candidate. +/// +/// For a whole-response grammar (bare JSON), accepting a valid leading object +/// followed by unrelated trailing text would dispatch a call out of ordinary +/// prose that merely starts with one — `{"name":"shell","arguments":{}} +/// explanation follows` is not a call, it is prose that happens to start with +/// one. +#[must_use] +pub fn recover_whole_object(raw: &str) -> Option { + recover_ladder(raw, false) +} + +fn recover_ladder(raw: &str, allow_trailing_noise: bool) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { return None; @@ -103,8 +126,12 @@ pub fn recover_object(raw: &str) -> Option { } } - // Rung 5: a valid leading object followed by trailing noise. - leading_object(candidate.trim()) + // Rung 5: a valid leading object followed by trailing noise. Only when + // the caller has already established that trailing noise is expected. + if allow_trailing_noise { + return leading_object(candidate.trim()); + } + None } /// Parses `s` strictly and keeps it only when it is an object. @@ -169,14 +196,48 @@ pub fn strip_code_fence(raw: &str) -> &str { .map_or(trimmed, str::trim) } -/// Removes every [`TEMPLATE_MARKERS`] occurrence. +/// Removes every [`TEMPLATE_MARKERS`] occurrence **outside JSON string +/// literals**. +/// +/// A blind text-level replace would also strike a marker that is legitimate +/// string *data* — `{"text":"hi"}` is a call whose +/// argument happens to quote the marker, and stripping it there would +/// silently corrupt the value the tool receives. This walks the text +/// tracking whether it is inside a `"…"` span (honoring `\"` escapes) and +/// only strips a marker match while outside one. #[must_use] pub fn strip_template_markers(raw: &str) -> String { - let mut out = raw.to_string(); - for marker in TEMPLATE_MARKERS { - if out.contains(marker) { - out = out.replace(marker, ""); + let mut out = String::with_capacity(raw.len()); + let mut in_string = false; + let mut escaped = false; + let mut cursor = 0usize; + while cursor < raw.len() { + let rest = &raw[cursor..]; + if !in_string + && let Some(marker) = TEMPLATE_MARKERS + .iter() + .find(|marker| rest.starts_with(*marker)) + { + cursor += marker.len(); + continue; + } + // `cursor` only ever advances by a marker's byte length (ASCII, so + // always a char boundary) or by one full char below, so it is + // always a char boundary here too. + let ch = rest.chars().next().unwrap_or_default(); + out.push(ch); + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + } else if ch == '"' { + in_string = true; } + cursor += ch.len_utf8(); } out } diff --git a/crates/tinytools-agent/src/repair/test/json.rs b/crates/tinytools-agent/src/repair/test/json.rs index d15ff59..ac7ac0f 100644 --- a/crates/tinytools-agent/src/repair/test/json.rs +++ b/crates/tinytools-agent/src/repair/test/json.rs @@ -96,6 +96,18 @@ fn strips_leaked_template_markers() { ); } +#[test] +fn a_marker_that_is_legitimate_string_data_is_preserved() { + // The trailing comma forces the repair ladder to run; a blind + // text-level marker strip would corrupt the value from inside its own + // quotes instead of only removing marker text that leaked in as + // structure. + assert_eq!( + recover_object(r#"{"text":"hi",}"#), + Some(json!({ "text": "hi" })) + ); +} + #[test] fn strips_a_code_fence() { assert_eq!( diff --git a/crates/tinytools-agent/src/stream/test.rs b/crates/tinytools-agent/src/stream/test.rs index 874b7b8..c49f63f 100644 --- a/crates/tinytools-agent/src/stream/test.rs +++ b/crates/tinytools-agent/src/stream/test.rs @@ -151,6 +151,44 @@ fn a_fenced_example_split_across_fragments_never_leaks_a_call() { ); } +#[test] +fn a_namespaced_invoke_opener_split_before_its_closing_bracket_is_held() { + // The namespace prefix (`atem:`) is open-ended and not in any fixed + // opener list, so this can only be caught by recognizing the tag + // structurally rather than by literal prefix matching. + let mut s = StreamScrubber::new(); + let first = s.feed("a"); + assert_eq!(second.calls.len(), 1); + assert_eq!(second.calls[0].name, "read"); +} + +#[test] +fn a_sentinel_split_on_an_unlisted_bar_underscore_combination_is_held() { + // The bar style and word separator vary independently across the four + // sentinel spellings; a split right after the fullwidth-bar/ASCII- + // underscore combination must still be held even though it was not one + // of the two combinations the fixed opener list used to carry. + let mut s = StreamScrubber::new(); + let first = s.feed("<|tool_"); + assert_eq!( + first.text, "", + "an unlisted bar/separator split must be held" + ); + assert!(first.calls.is_empty()); + + let second = + s.feed("call_begin|>get_weather<|tool_sep|>{\"city\":\"Paris\"}<|tool_call_end|>"); + assert_eq!(second.calls.len(), 1); + assert_eq!(second.calls[0].name, "get_weather"); +} + #[test] fn a_harmony_channel_header_without_message_yet_is_held() { // The header names a target but `<|message|>` has not streamed in yet, diff --git a/crates/tinytools-agent/src/types.rs b/crates/tinytools-agent/src/types.rs index 2d2078a..d3c9a29 100644 --- a/crates/tinytools-agent/src/types.rs +++ b/crates/tinytools-agent/src/types.rs @@ -84,7 +84,7 @@ impl ParsedToolCall { /// safe, because the anti-phantom rules in [`crate::parse`] do not depend on /// any of it. Supplying `known_tools` is what unlocks name repair and the /// alias-tolerant bare-object path. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy)] pub struct ParseOptions<'a> { /// The tools the model was actually offered this turn. Enables name /// repair (`terminal" parameter=…` → `terminal`) and lets a bare JSON @@ -98,6 +98,16 @@ pub struct ParseOptions<'a> { pub allow_bare_json: bool, } +impl Default for ParseOptions<'_> { + /// Matches [`Self::new`]: a derived `Default` would leave `allow_bare_json` + /// at `bool`'s `false`, contradicting the documented default above and + /// silently disabling bare-JSON parsing for any caller that writes + /// `ParseOptions::default()` instead of `ParseOptions::new()`. + fn default() -> Self { + Self::new() + } +} + impl<'a> ParseOptions<'a> { /// The permissive default: bare JSON allowed, no known tools, no registry. #[must_use]