Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/crates/execution/tool-call-jsonrepair/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Tool-call JSON repair

This crate is a local fork of
[`jsonrepair-rs` 0.2.1](https://github.com/majiayu000/jsonrepair-rs), licensed
[`jsonrepair-rs` 0.2.5](https://github.com/majiayu000/jsonrepair-rs), licensed
under MIT. The upstream license is retained in [`LICENSE`](LICENSE).

It retains the upstream generic repair API for compatibility, and adds
Expand All @@ -14,9 +14,15 @@ The profile still supports bounded syntax recovery needed for malformed model
tool arguments, including missing string quotes, commas, and closing
delimiters. The caller must parse and schema-validate the result before use.

The fork keeps the upstream 0.2.5 parser fixes and regression coverage, while
retaining the separate comment-free tool-call profile. Schema-guided correction
from upstream 0.2.5 is intentionally not included; tool arguments are parsed
and validated by the owning tool pipeline.

## Upstream regression coverage

The non-CLI regression tests and parity fixture from `jsonrepair-rs` 0.2.1 are
vendored under `tests/`. They differ only in the local crate import path. The
upstream CLI tests are intentionally excluded because this internal library
sets `autobins = false` and does not ship the upstream command-line program.
The non-CLI parser, streaming, and parity regression tests from
`jsonrepair-rs` 0.2.5 are vendored under `tests/`, with the local crate import
path adjusted. Schema-specific tests and the upstream CLI tests are omitted:
this internal library does not expose the schema helper, sets `autobins = false`,
and does not ship the upstream command-line program.
32 changes: 16 additions & 16 deletions src/crates/execution/tool-call-jsonrepair/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! # OpenBitFun tool-call JSON repair
//!
//! A local MIT-licensed fork of `jsonrepair-rs` 0.2.1 for streamed tool
//! A local MIT-licensed fork of `jsonrepair-rs` 0.2.5 for streamed tool
//! arguments. The tool-call profile preserves Markdown-like `#` content rather
//! than treating it as a configuration-file comment.
//!
Expand Down Expand Up @@ -304,6 +304,21 @@ where
serde_json::from_str(&repaired).map_err(JsonRepairParseError::from)
}

#[cfg(test)]
mod tests {
use super::repair_tool_call_json;

#[test]
fn tool_call_profile_preserves_hash_prefixed_unquoted_string_values() {
let repaired = repair_tool_call_json(r##"{"plan": # Markdown heading"}"##)
.expect("repair should succeed");
let value: serde_json::Value =
serde_json::from_str(&repaired).expect("repaired output should be JSON");

assert_eq!(value["plan"], "# Markdown heading");
}
}

fn reject_if_changed(input: &str, repaired: &str) -> Result<(), JsonRepairError> {
if input == repaired {
return Ok(());
Expand Down Expand Up @@ -347,18 +362,3 @@ fn line_column(input: &str, position: usize) -> (usize, usize) {

(line, column)
}

#[cfg(test)]
mod tests {
use super::repair_tool_call_json;

#[test]
fn tool_call_profile_preserves_hash_prefixed_unquoted_string_values() {
let repaired = repair_tool_call_json(r##"{"plan": # Markdown heading"}"##)
.expect("repair should succeed");
let value: serde_json::Value =
serde_json::from_str(&repaired).expect("repaired output should be JSON");

assert_eq!(value["plan"], "# Markdown heading");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ impl JsonRepairer {

self.enter_container()?;
self.output.push('[');
let frame_start = self.output.len();
self.pos += 1;
self.parse_whitespace_and_comments();

Expand Down Expand Up @@ -41,7 +42,7 @@ impl JsonRepairer {
let processed_value = self.parse_value()?;
if !processed_value {
// Trailing comma or truncated input.
self.strip_trailing_comma();
self.strip_trailing_comma(frame_start);
break;
}
}
Expand Down
30 changes: 7 additions & 23 deletions src/crates/execution/tool-call-jsonrepair/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,39 +284,23 @@ impl JsonRepairer {
true
}

/// Remove last occurrence of `c` from output.
pub(super) fn strip_last_occurrence(&mut self, c: char) {
if let Some(idx) = self.output.rfind(c) {
self.output.remove(idx);
}
}

/// Fast path for the common trailing-comma rollback case.
/// Removes a comma only when it's the last non-whitespace output char.
pub(super) fn strip_trailing_comma(&mut self) {
/// Never remove a separator before the current container's output.
pub(super) fn strip_trailing_comma(&mut self, min_index: usize) {
let bytes = self.output.as_bytes();
if let Some(&last) = bytes.last() {
if last == b',' {
self.output.pop();
return;
}
if !matches!(last, b' ' | b'\n' | b'\r' | b'\t') {
self.strip_last_occurrence(',');
return;
}
}

let mut idx = bytes.len();
while idx > 0 && matches!(bytes[idx - 1], b' ' | b'\n' | b'\r' | b'\t') {
while idx > min_index && matches!(bytes[idx - 1], b' ' | b'\n' | b'\r' | b'\t') {
idx -= 1;
}

if idx > 0 && bytes[idx - 1] == b',' {
if idx > min_index && bytes[idx - 1] == b',' {
self.output.remove(idx - 1);
return;
}

self.strip_last_occurrence(',');
if let Some(relative_index) = self.output[min_index..].rfind(',') {
self.output.remove(min_index + relative_index);
}
}

/// Insert `text` before any trailing whitespace in the output buffer.
Expand Down
14 changes: 13 additions & 1 deletion src/crates/execution/tool-call-jsonrepair/src/parser/number.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ impl JsonRepairer {
let mut append_trailing_zero = false;
let mut has_leading_dot = false;
let mut has_invalid_leading_zero = false;
// Exponent (`e`/`E`) is only valid after at least one integer or fraction
// digit. Without this guard, plus-prefixed forms like `+e` / `+e1` (after
// `parse_plus_number` strips `+`) would emit invalid JSON such as `e0`.
let mut has_digit = false;

if self.peek() == Some('-') {
self.pos += 1;
Expand All @@ -55,6 +59,7 @@ impl JsonRepairer {
return Ok(false);
}

has_digit = true;
while self.pos < len && chars::is_digit(self.chars[self.pos]) {
self.pos += 1;
}
Expand All @@ -75,6 +80,9 @@ impl JsonRepairer {
integer_digits += 1;
self.pos += 1;
}
if integer_digits > 0 {
has_digit = true;
}

if self.pos < len && self.chars[self.pos] == '.' {
self.pos += 1;
Expand All @@ -84,14 +92,18 @@ impl JsonRepairer {
self.pos = start;
return Ok(false);
} else {
has_digit = true;
while self.pos < len && chars::is_digit(self.chars[self.pos]) {
self.pos += 1;
}
}
}
}

if !append_trailing_zero && self.pos < len && matches!(self.chars[self.pos], 'e' | 'E')
if !append_trailing_zero
&& has_digit
&& self.pos < len
&& matches!(self.chars[self.pos], 'e' | 'E')
{
self.pos += 1;
if self.pos < len && matches!(self.chars[self.pos], '-' | '+') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ impl JsonRepairer {

self.enter_container()?;
self.output.push('{');
let frame_start = self.output.len();
self.pos += 1;
self.parse_whitespace_and_comments();

Expand Down Expand Up @@ -43,7 +44,7 @@ impl JsonRepairer {
|| matches!(self.peek(), Some('}') | Some('{') | Some(']') | Some('['));
if near_end {
// Trailing comma.
self.strip_trailing_comma();
self.strip_trailing_comma(frame_start);
} else {
return Err(self.error_kind(
"Object key expected",
Expand Down
81 changes: 76 additions & 5 deletions src/crates/execution/tool-call-jsonrepair/src/parser/string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ impl JsonRepairer {
.and_then(|idx| self.prev_non_whitespace_index(idx));
let prev_char = prev_non_ws.and_then(|idx| self.peek_at(idx));

if prev_char == Some(',') {
if prev_char == Some(',') && stop_at_index != prev_non_ws {
// {"a":"b,c,"d":"e"} -> stop at comma before quote.
self.pos = input_start;
self.output.truncate(output_start);
Expand All @@ -116,9 +116,12 @@ impl JsonRepairer {
self.pos = quote_pos + 1;
} else if stop_at_delimiter && chars::is_unquoted_string_delimiter(c) {
// URL like "https://..." should not stop at '/'.
if self.pos > input_start + 1
&& self.peek_at(self.pos.saturating_sub(1)) == Some(':')
&& self.looks_like_url_start(input_start + 1, self.pos)
if c == '/'
&& self.pos > input_start + 1
&& self.peek_at(self.pos - 1) == Some(':')
&& (self.looks_like_url_start(input_start + 1, self.pos)
|| (self.pos + 1 == self.chars.len()
&& self.ends_with_url_scheme(input_start + 1, self.pos)))
{
while self.peek().is_some_and(chars::is_url_char) {
self.output.push(self.chars[self.pos]);
Expand Down Expand Up @@ -173,6 +176,47 @@ impl JsonRepairer {
}

if digits == 4 {
let code_unit = self.hex_quad(self.pos + 1).ok_or_else(|| {
self.error_at_kind(
"Invalid unicode escape",
backslash_pos,
JsonRepairErrorKind::InvalidUnicode,
)
})?;
if (0xD800..=0xDBFF).contains(&code_unit) {
let next = self.pos + 5;
let low = if self.peek_at(next) == Some('\\')
&& self.peek_at(next + 1) == Some('u')
{
self.hex_quad(next + 2)
} else {
None
};
if !low.is_some_and(|unit| (0xDC00..=0xDFFF).contains(&unit)) {
return Err(self.error_at_kind(
"Invalid unicode surrogate pair",
backslash_pos,
JsonRepairErrorKind::InvalidUnicode,
));
}
self.output.push_str("\\u");
for i in 0..4 {
self.output.push(self.chars[self.pos + 1 + i]);
}
self.output.push_str("\\u");
for i in 0..4 {
self.output.push(self.chars[next + 2 + i]);
}
self.pos += 11;
return Ok(());
}
if (0xDC00..=0xDFFF).contains(&code_unit) {
return Err(self.error_at_kind(
"Invalid unicode surrogate pair",
backslash_pos,
JsonRepairErrorKind::InvalidUnicode,
));
}
self.output.push_str("\\u");
for i in 0..4 {
self.output.push(self.chars[self.pos + 1 + i]);
Expand Down Expand Up @@ -202,13 +246,22 @@ impl JsonRepairer {
}
_ => {
// Invalid escape: drop '\' and keep char.
self.output.push(esc);
self.push_string_char(esc);
self.pos += 1;
}
}
Ok(())
}

fn hex_quad(&self, start: usize) -> Option<u16> {
let mut value = 0u16;
for offset in 0..4 {
let digit = self.peek_at(start + offset)?.to_digit(16)? as u16;
value = value * 16 + digit;
}
Some(value)
}

fn parse_string_char(&mut self, c: char) -> Result<()> {
if c >= '\u{0020}' && c != '"' && c != '\\' {
self.output.push(c);
Expand Down Expand Up @@ -324,6 +377,13 @@ impl JsonRepairer {
self.pos -= 1;
}

// A whitespace-only token has no value. Form feed is trimmed here but
// is not consumed by parse_whitespace_and_comments. Returning false
// prevents the array parser from looping at an unchanged position.
if self.pos == start {
return Ok(false);
}

// Compare directly on char slice — no String allocation.
if !is_key && self.slice_eq(start, self.pos, "undefined") {
self.output.push_str("null");
Expand Down Expand Up @@ -395,6 +455,17 @@ impl JsonRepairer {
|| self.matches_at(start, "irc://")
}

fn ends_with_url_scheme(&self, start: usize, slash_idx: usize) -> bool {
[
"http:", "https:", "ftp:", "mailto:", "file:", "data:", "irc:",
]
.iter()
.any(|scheme| {
slash_idx >= start + scheme.len()
&& self.slice_eq(slash_idx - scheme.len(), slash_idx, scheme)
})
}

fn is_known_wrapper_function(&self, start: usize, end: usize) -> bool {
self.slice_starts_with_ignore_ascii_case(start, end, "callback")
|| self.slice_eq_ignore_ascii_case(start, end, "cb")
Expand Down
10 changes: 8 additions & 2 deletions src/crates/execution/tool-call-jsonrepair/src/parser/toplevel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ impl JsonRepairer {
self.parse_ndjson()?;
} else if processed_comma {
// Remove trailing comma after a single root value.
self.strip_trailing_comma();
self.strip_trailing_comma(0);
}

// Repair redundant closing brackets at the root level.
Expand Down Expand Up @@ -67,12 +67,16 @@ impl JsonRepairer {
fn parse_ndjson(&mut self) -> Result<()> {
let mut initial = true;
let mut processed_value = true;
// repair() has already inserted or consumed the separator before the
// first additional value.
let mut pending_comma = self.output.rfind(',');
while processed_value {
if !initial {
let processed_comma = self.parse_char(',');
if !processed_comma {
self.insert_before_last_whitespace(",");
}
pending_comma = self.output.rfind(',');
} else {
initial = false;
}
Expand All @@ -81,7 +85,9 @@ impl JsonRepairer {
}

if !processed_value {
self.strip_trailing_comma();
if let Some(index) = pending_comma {
self.output.remove(index);
}
}

self.output.insert_str(0, "[\n");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ fn plus_prefixed_leading_dot_numbers_remain_parseable() {
assert_repaired_json("[+.5]");
assert_repaired_json("{value:+.5}");
}

#[test]
fn plus_prefixed_exponent_only_tokens_are_rejected() {
for input in ["+e", "+e1", "+E", "+e+1", "[+e]", "{v:+e1}"] {
assert!(
jsonrepair(input).is_err(),
"plus-prefixed exponent-only token {input:?} must not repair to invalid JSON"
);
}
}
Loading
Loading