mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
LM Studio and other local servers accept our tools: boolean sub-schemas are sent as objects (#25)
schemars writes `"items": true` for a list of any JSON (app_call_path_find's `functions`), which
JSON Schema allows but LM Studio refuses ("JSON schema error at #/properties/functions/items:
schema must be an object"), so every chat with it stopped. Tool schemas sent to a model now write
`true` as `{}` and `false` as `{"not": {}}` wherever a schema sits (to a bounded depth, since
run-time tools bring their own); `additionalProperties` keeps its boolean.
Fixes #17
This commit is contained in:
+81
-1
@@ -48,7 +48,7 @@ pub trait ToolHost: Send + Sync {
|
||||
/// The real host: the engine.
|
||||
pub struct EngineHost(pub Arc<Engine>);
|
||||
|
||||
/// Remove keys model APIs dislike at the schema root.
|
||||
/// Remove keys model APIs dislike at the schema root, and write boolean sub-schemas as objects.
|
||||
fn clean_schema(mut s: Value) -> Value {
|
||||
if let Some(o) = s.as_object_mut() {
|
||||
o.remove("$schema");
|
||||
@@ -60,9 +60,35 @@ fn clean_schema(mut s: Value) -> Value {
|
||||
o.insert("properties".into(), json!({}));
|
||||
}
|
||||
}
|
||||
object_subschemas(&mut s, 0);
|
||||
s
|
||||
}
|
||||
|
||||
/// Deepest schema nesting rewritten (run-time tools bring their own schemas).
|
||||
const MAX_SCHEMA_DEPTH: usize = 64;
|
||||
|
||||
/// `true` and `false` as sub-schemas (schemars writes `"items": true` for a list of any JSON) become
|
||||
/// `{}` and `{"not": {}}`: local servers such as LM Studio refuse anything but an object there.
|
||||
/// `additionalProperties` stays as it is: a boolean is what every API expects in it.
|
||||
fn object_subschemas(schema: &mut Value, depth: usize) {
|
||||
let Some(o) = schema.as_object_mut().filter(|_| depth < MAX_SCHEMA_DEPTH) else { return };
|
||||
for (key, sub) in o.iter_mut() {
|
||||
let subs: Vec<&mut Value> = match (key.as_str(), sub) {
|
||||
("items" | "additionalItems" | "contains" | "not" | "if" | "then" | "else", s) => vec![s],
|
||||
("properties" | "patternProperties" | "$defs" | "definitions", Value::Object(m)) => m.values_mut().collect(),
|
||||
("anyOf" | "oneOf" | "allOf" | "prefixItems", Value::Array(a)) => a.iter_mut().collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for s in subs {
|
||||
match s {
|
||||
Value::Bool(true) => *s = json!({}),
|
||||
Value::Bool(false) => *s = json!({"not": {}}),
|
||||
_ => object_subschemas(s, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn registry_tools(reg: &Registry, enabled: &BTreeSet<Toolset>) -> Vec<ToolDef> {
|
||||
reg.all()
|
||||
.iter()
|
||||
@@ -713,6 +739,60 @@ pub(crate) mod tests {
|
||||
use crate::model::TurnResult;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Paths of boolean sub-schemas in `v`.
|
||||
fn boolean_subschemas(v: &Value, path: &str, out: &mut Vec<String>) {
|
||||
let Value::Object(o) = v else { return };
|
||||
for (k, sub) in o {
|
||||
let subs: Vec<(String, &Value)> = match (k.as_str(), sub) {
|
||||
("properties" | "$defs" | "definitions", Value::Object(m)) => m.iter().map(|(n, s)| (format!("{path}/{k}/{n}"), s)).collect(),
|
||||
("anyOf" | "oneOf" | "allOf" | "prefixItems", Value::Array(a)) => a.iter().enumerate().map(|(i, s)| (format!("{path}/{k}/{i}"), s)).collect(),
|
||||
("items" | "not", s) => vec![(format!("{path}/{k}"), s)],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (p, s) in subs {
|
||||
if s.is_boolean() {
|
||||
out.push(p);
|
||||
} else {
|
||||
boolean_subschemas(s, &p, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every place a schema can sit holds an object, as LM Studio requires (#17).
|
||||
#[test]
|
||||
fn tool_schemas_have_only_object_subschemas() {
|
||||
let reg = Registry::with_all_tools();
|
||||
let all: BTreeSet<Toolset> = reg.toolsets().into_iter().collect();
|
||||
let raw = reg.get("app_call_path_find").map(|s| reg.input_schema(s)).unwrap_or_default();
|
||||
assert!(raw.to_string().contains("\"items\":true"), "the case from #17 is still covered");
|
||||
let mut bad = Vec::new();
|
||||
for t in registry_tools(®, &all) {
|
||||
boolean_subschemas(&t.schema, &t.name, &mut bad);
|
||||
}
|
||||
assert!(bad.is_empty(), "boolean sub-schemas left: {bad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_subschemas_become_objects() {
|
||||
let s = clean_schema(json!({
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": {"functions": {"type": "array", "items": true}, "never": false, "pick": {"anyOf": [true, {"items": false}]}}
|
||||
}));
|
||||
assert_eq!(s["properties"]["functions"]["items"], json!({}));
|
||||
assert_eq!(s["properties"]["never"], json!({"not": {}}));
|
||||
assert_eq!(s["properties"]["pick"]["anyOf"], json!([{}, {"items": {"not": {}}}]));
|
||||
assert_eq!(s["additionalProperties"], json!(false), "left as APIs expect");
|
||||
// Deeper than the limit: the rest is left as it is.
|
||||
let mut deep = json!(true);
|
||||
for _ in 0..(MAX_SCHEMA_DEPTH + 10) {
|
||||
deep = json!({"items": deep});
|
||||
}
|
||||
let mut bad = Vec::new();
|
||||
boolean_subschemas(&clean_schema(deep), "", &mut bad);
|
||||
assert_eq!(bad.len(), 1);
|
||||
}
|
||||
|
||||
/// A scripted model: returns canned turns and records requests.
|
||||
pub struct Scripted {
|
||||
pub turns: VecDeque<Result<TurnResult, ToolError>>,
|
||||
|
||||
Reference in New Issue
Block a user