Skip to content

Commit 0b779b2

Browse files
authored
Merge pull request #59 from tinyhumansai/fix-tool-call-errors
fix(repair): recover relaxed-JSON object args and drop null optionals
2 parents bd60b9b + c3d99e0 commit 0b779b2

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

‎crates/tinytools-agent/src/repair/args.rs‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ pub fn accepts_object(schema: &Value) -> bool {
114114
/// / `object`, and a bare scalar → `[scalar]` for `array`. A value that does
115115
/// not convert is left as it was, so the schema validator still reports it.
116116
/// Recurses into nested objects and array items with their own schemas.
117+
///
118+
/// An object-typed property given a string that is not strict JSON goes
119+
/// through the same lenient [`super::json::recover_object`] ladder as a whole
120+
/// argument blob (trailing commas, bare keys, a fence, leaked template
121+
/// markers). A `null` for an **optional** property whose declared `type` does
122+
/// not admit null is dropped: the model meant "not given" (`"tool": null`),
123+
/// and the key's absence is what the schema accepts. A required property is
124+
/// never dropped, so the validator still names it.
117125
#[must_use]
118126
pub fn coerce_to_schema(arguments: Value, schema: &Value) -> Value {
119127
let Value::Object(map) = arguments else {
@@ -122,9 +130,14 @@ pub fn coerce_to_schema(arguments: Value, schema: &Value) -> Value {
122130
let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
123131
return Value::Object(map);
124132
};
133+
let required = schema.get("required").and_then(Value::as_array);
134+
let is_required = |key: &str| required.is_some_and(|list| list.iter().any(|r| r == key));
125135
let mut out = Map::with_capacity(map.len());
126136
for (key, value) in map {
127137
let coerced = match properties.get(&key) {
138+
Some(property) if value.is_null() && !is_required(&key) && rejects_null(property) => {
139+
continue;
140+
}
128141
Some(property) => coerce_value(value, property),
129142
None => value,
130143
};
@@ -133,6 +146,16 @@ pub fn coerce_to_schema(arguments: Value, schema: &Value) -> Value {
133146
Value::Object(out)
134147
}
135148

149+
/// Whether `schema` declares a `type` that does not include `null`. An
150+
/// untyped property accepts null already, so it is never rewritten.
151+
fn rejects_null(schema: &Value) -> bool {
152+
match schema.get("type") {
153+
Some(Value::String(kind)) => kind != "null",
154+
Some(Value::Array(kinds)) => !kinds.iter().any(|kind| kind == "null"),
155+
_ => false,
156+
}
157+
}
158+
136159
fn schema_type(schema: &Value) -> Option<&str> {
137160
match schema.get("type") {
138161
Some(Value::String(s)) => Some(s.as_str()),
@@ -169,7 +192,11 @@ fn coerce_value(value: Value, schema: &Value) -> Value {
169192
(Some("array"), scalar @ (Value::Number(_) | Value::Bool(_))) => Value::Array(vec![scalar]),
170193
(Some("object"), Value::String(s)) => match serde_json::from_str::<Value>(s.trim()) {
171194
Ok(object @ Value::Object(_)) => coerce_to_schema(object, schema),
172-
_ => Value::String(s),
195+
Ok(_) => Value::String(s),
196+
Err(_) => match super::json::recover_object(super::json::strip_code_fence(&s)) {
197+
Some(object) => coerce_to_schema(object, schema),
198+
None => Value::String(s),
199+
},
173200
},
174201
(Some("object"), object @ Value::Object(_)) => coerce_to_schema(object, schema),
175202
(Some("string"), Value::Number(n)) => Value::String(n.to_string()),

‎crates/tinytools-agent/src/repair/test/args.rs‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,71 @@ fn unconvertible_scalars_are_left_for_the_validator() {
231231
json!({ "l": [3] })
232232
);
233233
}
234+
235+
/// `use_skill` captures: the nested `args` object arrives as a JSON string that
236+
/// is not strictly valid (single-quoted keys stripped by the model, a trailing
237+
/// comma, unquoted keys). Strict parsing fails; the lenient object recovery
238+
/// the rest of the repair path uses must still apply.
239+
#[test]
240+
fn an_object_typed_string_that_is_relaxed_json_is_recovered() {
241+
let schema = json!({
242+
"type": "object",
243+
"properties": {
244+
"skill": { "type": "string" },
245+
"args": { "type": "object", "additionalProperties": true }
246+
},
247+
"required": ["skill"]
248+
});
249+
for raw in [
250+
"{\"query\": \"inbox\",}",
251+
"{query: \"inbox\"}",
252+
"```json\n{\"query\":\"inbox\"}\n```",
253+
"{\"query\":\"inbox\"}<tool_call|>",
254+
] {
255+
assert_eq!(
256+
coerce_to_schema(json!({ "skill": "email", "args": raw }), &schema),
257+
json!({ "skill": "email", "args": { "query": "inbox" } }),
258+
"{raw}"
259+
);
260+
}
261+
}
262+
263+
/// `use_skill` captures: `"tool": null` / `"args": null` for optional
264+
/// properties whose declared type does not admit null. The model meant "not
265+
/// given"; dropping the key is what makes the call validate.
266+
#[test]
267+
fn null_for_an_optional_non_nullable_property_is_dropped() {
268+
let schema = json!({
269+
"type": "object",
270+
"properties": {
271+
"skill": { "type": "string" },
272+
"tool": { "type": "string" },
273+
"args": { "type": "object" },
274+
"maybe": { "type": ["string", "null"] },
275+
"untyped": {}
276+
},
277+
"required": ["skill"]
278+
});
279+
assert_eq!(
280+
coerce_to_schema(
281+
json!({ "skill": "email", "tool": null, "args": null, "maybe": null, "untyped": null }),
282+
&schema
283+
),
284+
json!({ "skill": "email", "maybe": null, "untyped": null })
285+
);
286+
}
287+
288+
/// A required property set to null is left for the validator: dropping it
289+
/// would only trade "must be string" for "is required".
290+
#[test]
291+
fn null_for_a_required_property_is_kept() {
292+
let schema = json!({
293+
"type": "object",
294+
"properties": { "skill": { "type": "string" } },
295+
"required": ["skill"]
296+
});
297+
assert_eq!(
298+
coerce_to_schema(json!({ "skill": null }), &schema),
299+
json!({ "skill": null })
300+
);
301+
}

0 commit comments

Comments
 (0)