From 948c089a51307423aac8bd8a3542410519ad07c6 Mon Sep 17 00:00:00 2001 From: Simo Lin <25425177+slin1237@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:50:38 -0700 Subject: [PATCH 1/4] refactor(symphony): the argument shapes a schema pins move out of the Kimi K3 module Kimi K3's tag reads each property's schema for the type it pins and the grammar of a value of that type, with the tool's definitions carried along so a `$ref` still resolves; the keyed syntaxes, which also write arguments one by one, will read the same. So `shape` and `Definitions` move to `grammar/schema.rs`, and `shape` takes the grammar of a string written as it is from its caller, the one thing that differs between the syntaxes. The Kimi K3 module keeps its own tags, escaping and shapes without a type. Nothing an engine sees changes. Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com> Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com> Co-authored-by: Keyang Ru Co-authored-by: key4ng Co-authored-by: Chen Hongtao <56470055+chenht2022@users.noreply.github.com> Co-authored-by: LightSeek Foundation <243258330+lightseek-bot@users.noreply.github.com> --- crates/symphony/src/grammar.rs | 1 + crates/symphony/src/grammar/schema.rs | 165 ++++++++++++++++++++++++++ crates/symphony/src/grammar/xtml.rs | 158 +----------------------- 3 files changed, 172 insertions(+), 152 deletions(-) create mode 100644 crates/symphony/src/grammar/schema.rs diff --git a/crates/symphony/src/grammar.rs b/crates/symphony/src/grammar.rs index b80f1f3d11..9dfb27a0f5 100644 --- a/crates/symphony/src/grammar.rs +++ b/crates/symphony/src/grammar.rs @@ -48,6 +48,7 @@ use crate::{ tagged::{dsml, keyed}, }; +mod schema; mod xtml; /// A structural-tag grammar, as the engines take it. diff --git a/crates/symphony/src/grammar/schema.rs b/crates/symphony/src/grammar/schema.rs new file mode 100644 index 0000000000..5889afa26a --- /dev/null +++ b/crates/symphony/src/grammar/schema.rs @@ -0,0 +1,165 @@ +//! What a tool's JSON schema says about each argument, for the syntaxes that write arguments one +//! by one with a type of their own (Kimi K3's XTML, the keyed tags): the type a property's schema +//! pins and the grammar of a value of that type, and the definitions a property's schema carries +//! along so a `$ref` inside it still resolves when the engine compiles it on its own. + +use serde_json::{json, Value}; + +use super::Grammar; + +/// The type a property's schema pins, by the name the templates write (`integer` as `number`), +/// and the grammar of a value of that type: a string as `text`, the syntax's own spelling of a +/// value written as it is; a string `enum` as one of its values; a number, an object or an array +/// as JSON the property's schema accepts, the tool's definitions attached; a boolean as JSON; +/// `null` as the word. `None` when the schema pins no type the syntaxes have, or a `$ref` in it +/// points at nothing (a grammar with such a pointer would not compile, and the whole call with +/// it). A `$ref` lends its target's keywords, and the property's own win, as in JSON Schema +/// 2020-12. +pub(super) fn shape( + schema: &Value, + definitions: Definitions<'_>, + text: impl Fn() -> Grammar, +) -> Option<(&'static str, Grammar)> { + if !definitions.pointers_resolve(schema) { + return None; + } + let target = definitions.target_of(schema); + let keyword = |name: &str| { + schema + .get(name) + .or_else(|| target.and_then(|target| target.get(name))) + }; + if let Some(values) = keyword("enum").and_then(Value::as_array) { + if !values.is_empty() && values.iter().all(Value::is_string) { + let options = values + .iter() + .filter_map(Value::as_str) + .map(|value| Grammar::ConstString(value.to_string())) + .collect(); + return Some(("string", Grammar::Or(options))); + } + } + let json = |schema: Value| Grammar::JsonSchema { + schema, + style: None, + }; + match keyword("type").and_then(Value::as_str)? { + "string" => Some(("string", text())), + "integer" | "number" => Some(("number", json(definitions.attached_to(schema)))), + "boolean" => Some(("boolean", json(json!({"type": "boolean"})))), + "object" => Some(("object", json(definitions.attached_to(schema)))), + "array" => Some(("array", json(definitions.attached_to(schema)))), + "null" => Some(("null", Grammar::ConstString("null".to_string()))), + _ => None, + } +} + +/// The definitions at the root of a tool's schema, `$defs` and `definitions`, and the root itself. +/// Each property's schema goes to the engine as a document of its own, and a `$ref` such as +/// `#/$defs/Node` points from the root of the document it stands in, so the definitions travel +/// with the property's schema. +#[derive(Clone, Copy)] +pub(super) struct Definitions<'a> { + root: &'a Value, + defs: Option<&'a Value>, + definitions: Option<&'a Value>, +} + +impl<'a> Definitions<'a> { + pub(super) fn of(root: &'a Value) -> Self { + Self { + root, + defs: root.get("$defs"), + definitions: root.get("definitions"), + } + } + + /// The schema a local pointer names: `#` the root, `#/$defs/Name` or `#/definitions/Name` an + /// entry, with the pointer's escapes (`~1` for `/`, `~0` for `~`) undone. + fn resolve(self, pointer: &str) -> Option<&'a Value> { + if pointer == "#" || pointer == "#/" { + return Some(self.root); + } + let mut segments = pointer.strip_prefix("#/")?.split('/'); + let mut node = match segments.next()? { + "$defs" => self.defs?, + "definitions" => self.definitions?, + _ => return None, + }; + for segment in segments { + node = node.get(segment.replace("~1", "/").replace("~0", "~"))?; + } + Some(node) + } + + /// The schema the property's own `$ref` names, one hop: its keywords stand beside the + /// property's, and a cycle of definitions is the engine's to expand, not this function's. + fn target_of(self, schema: &'a Value) -> Option<&'a Value> { + self.resolve(schema.get("$ref")?.as_str()?) + } + + /// Whether every local pointer in `schema` names something. + fn pointers_resolve(self, schema: &Value) -> bool { + match schema { + Value::Object(map) => map.iter().all(|(key, value)| { + let points_at_nothing = is_reference(key) + && value.as_str().is_some_and(|pointer| { + pointer.starts_with('#') && self.resolve(pointer).is_none() + }); + !points_at_nothing && self.pointers_resolve(value) + }), + Value::Array(items) => items.iter().all(|item| self.pointers_resolve(item)), + _ => true, + } + } + + /// The property's schema with the root's definitions attached, so a local pointer in it still + /// resolves when the engine compiles it on its own. A definition the schema declares itself + /// stays; the root's fill in the names it lacks. A schema with no local pointer goes as it is. + fn attached_to(self, schema: &Value) -> Value { + let mut attached = schema.clone(); + if self.defs.is_none() && self.definitions.is_none() || !has_local_reference(schema) { + return attached; + } + let Some(object) = attached.as_object_mut() else { + return attached; + }; + for (name, block) in [("$defs", self.defs), ("definitions", self.definitions)] { + let Some(Value::Object(entries)) = block else { + continue; + }; + match object.get_mut(name) { + Some(Value::Object(own)) => { + for (key, value) in entries { + own.entry(key.clone()).or_insert_with(|| value.clone()); + } + } + Some(_) => {} + None => { + object.insert(name.to_string(), Value::Object(entries.clone())); + } + } + } + attached + } +} + +/// Whether `key` is one of the keywords that point at another schema. +fn is_reference(key: &str) -> bool { + matches!(key, "$ref" | "$dynamicRef" | "$recursiveRef") +} + +/// Whether `schema` points anywhere inside its own document. +fn has_local_reference(schema: &Value) -> bool { + match schema { + Value::Object(map) => map.iter().any(|(key, value)| { + is_reference(key) + && value + .as_str() + .is_some_and(|pointer| pointer.starts_with('#')) + || has_local_reference(value) + }), + Value::Array(items) => items.iter().any(has_local_reference), + _ => false, + } +} diff --git a/crates/symphony/src/grammar/xtml.rs b/crates/symphony/src/grammar/xtml.rs index 47312911d5..e551d8b4d9 100644 --- a/crates/symphony/src/grammar/xtml.rs +++ b/crates/symphony/src/grammar/xtml.rs @@ -22,9 +22,12 @@ //! the XTML assembler reads ([`tagged::xtml`](crate::tagged::xtml)). use openai_protocol::common::Tool; -use serde_json::{json, Value}; +use serde_json::Value; -use super::{CallMarkers, Grammar, Tag, WayIn}; +use super::{ + schema::{self, Definitions}, + CallMarkers, Grammar, Tag, WayIn, +}; const OPEN: &str = "<|open|>"; const CLOSE: &str = "<|close|>"; @@ -111,7 +114,7 @@ fn arguments(parameters: &Value) -> Grammar { /// the key with any type and any value when the schema pins none. fn argument(key: &str, schema: &Value, definitions: Definitions<'_>) -> Grammar { let key = escape(key); - match shape(schema, definitions) { + match schema::shape(schema, definitions, any_value) { Some((type_name, value)) => Grammar::Sequence(vec![ Grammar::ConstString(format!( "{OPEN}argument key=\"{key}\" type=\"{type_name}\"{SEP}" @@ -156,160 +159,11 @@ fn any_value() -> Grammar { } } -/// The `type` attribute and the value grammar a property's schema pins, or `None` when it pins -/// no type the syntax has, or a `$ref` in it points at nothing (a grammar with such a pointer -/// would not compile, and the whole call with it). A `$ref` lends its target's keywords, and the -/// property's own win, as in JSON Schema 2020-12. The template writes `integer` as `number`. -fn shape(schema: &Value, definitions: Definitions<'_>) -> Option<(&'static str, Grammar)> { - if !definitions.pointers_resolve(schema) { - return None; - } - let target = definitions.target_of(schema); - let keyword = |name: &str| { - schema - .get(name) - .or_else(|| target.and_then(|target| target.get(name))) - }; - if let Some(values) = keyword("enum").and_then(Value::as_array) { - if !values.is_empty() && values.iter().all(Value::is_string) { - let options = values - .iter() - .filter_map(Value::as_str) - .map(|value| Grammar::ConstString(value.to_string())) - .collect(); - return Some(("string", Grammar::Or(options))); - } - } - let json = |schema: Value| Grammar::JsonSchema { - schema, - style: None, - }; - match keyword("type").and_then(Value::as_str)? { - "string" => Some(("string", any_value())), - "integer" | "number" => Some(("number", json(definitions.attached_to(schema)))), - "boolean" => Some(("boolean", json(json!({"type": "boolean"})))), - "object" => Some(("object", json(definitions.attached_to(schema)))), - "array" => Some(("array", json(definitions.attached_to(schema)))), - "null" => Some(("null", Grammar::ConstString("null".to_string()))), - _ => None, - } -} - /// What the template escapes in an attribute: `&` first, then `"`. fn escape(value: &str) -> String { value.replace('&', "&").replace('"', """) } -/// The definitions at the root of a tool's schema, `$defs` and `definitions`, and the root itself. -/// Each property's schema goes to the engine as a document of its own, and a `$ref` such as -/// `#/$defs/Node` points from the root of the document it stands in, so the definitions travel -/// with the property's schema. -#[derive(Clone, Copy)] -struct Definitions<'a> { - root: &'a Value, - defs: Option<&'a Value>, - definitions: Option<&'a Value>, -} - -impl<'a> Definitions<'a> { - fn of(root: &'a Value) -> Self { - Self { - root, - defs: root.get("$defs"), - definitions: root.get("definitions"), - } - } - - /// The schema a local pointer names: `#` the root, `#/$defs/Name` or `#/definitions/Name` an - /// entry, with the pointer's escapes (`~1` for `/`, `~0` for `~`) undone. - fn resolve(self, pointer: &str) -> Option<&'a Value> { - if pointer == "#" || pointer == "#/" { - return Some(self.root); - } - let mut segments = pointer.strip_prefix("#/")?.split('/'); - let mut node = match segments.next()? { - "$defs" => self.defs?, - "definitions" => self.definitions?, - _ => return None, - }; - for segment in segments { - node = node.get(segment.replace("~1", "/").replace("~0", "~"))?; - } - Some(node) - } - - /// The schema the property's own `$ref` names, one hop: its keywords stand beside the - /// property's, and a cycle of definitions is the engine's to expand, not this function's. - fn target_of(self, schema: &'a Value) -> Option<&'a Value> { - self.resolve(schema.get("$ref")?.as_str()?) - } - - /// Whether every local pointer in `schema` names something. - fn pointers_resolve(self, schema: &Value) -> bool { - match schema { - Value::Object(map) => map.iter().all(|(key, value)| { - let points_at_nothing = is_reference(key) - && value.as_str().is_some_and(|pointer| { - pointer.starts_with('#') && self.resolve(pointer).is_none() - }); - !points_at_nothing && self.pointers_resolve(value) - }), - Value::Array(items) => items.iter().all(|item| self.pointers_resolve(item)), - _ => true, - } - } - - /// The property's schema with the root's definitions attached, so a local pointer in it still - /// resolves when the engine compiles it on its own. A definition the schema declares itself - /// stays; the root's fill in the names it lacks. A schema with no local pointer goes as it is. - fn attached_to(self, schema: &Value) -> Value { - let mut attached = schema.clone(); - if self.defs.is_none() && self.definitions.is_none() || !has_local_reference(schema) { - return attached; - } - let Some(object) = attached.as_object_mut() else { - return attached; - }; - for (name, block) in [("$defs", self.defs), ("definitions", self.definitions)] { - let Some(Value::Object(entries)) = block else { - continue; - }; - match object.get_mut(name) { - Some(Value::Object(own)) => { - for (key, value) in entries { - own.entry(key.clone()).or_insert_with(|| value.clone()); - } - } - Some(_) => {} - None => { - object.insert(name.to_string(), Value::Object(entries.clone())); - } - } - } - attached - } -} - -/// Whether `key` is one of the keywords that point at another schema. -fn is_reference(key: &str) -> bool { - matches!(key, "$ref" | "$dynamicRef" | "$recursiveRef") -} - -/// Whether `schema` points anywhere inside its own document. -fn has_local_reference(schema: &Value) -> bool { - match schema { - Value::Object(map) => map.iter().any(|(key, value)| { - is_reference(key) - && value - .as_str() - .is_some_and(|pointer| pointer.starts_with('#')) - || has_local_reference(value) - }), - Value::Array(items) => items.iter().any(has_local_reference), - _ => false, - } -} - #[cfg(test)] mod tests { use openai_protocol::common::Function; From 99c17ccc44da577c54affd577db0bc248ec3e672 Mon Sep 17 00:00:00 2001 From: Simo Lin <25425177+slin1237@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:54:45 -0700 Subject: [PATCH 2/4] refactor(symphony): name the shape by itself, and take the text grammar once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `schema::shape(schema, …)` used one word for the module and the value; the function is imported by name now, and `text` is an `FnOnce`, which is all a single call needs. Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com> Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com> --- crates/symphony/src/grammar/schema.rs | 2 +- crates/symphony/src/grammar/xtml.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/symphony/src/grammar/schema.rs b/crates/symphony/src/grammar/schema.rs index 5889afa26a..ba3dc1fab2 100644 --- a/crates/symphony/src/grammar/schema.rs +++ b/crates/symphony/src/grammar/schema.rs @@ -18,7 +18,7 @@ use super::Grammar; pub(super) fn shape( schema: &Value, definitions: Definitions<'_>, - text: impl Fn() -> Grammar, + text: impl FnOnce() -> Grammar, ) -> Option<(&'static str, Grammar)> { if !definitions.pointers_resolve(schema) { return None; diff --git a/crates/symphony/src/grammar/xtml.rs b/crates/symphony/src/grammar/xtml.rs index e551d8b4d9..db18857ff6 100644 --- a/crates/symphony/src/grammar/xtml.rs +++ b/crates/symphony/src/grammar/xtml.rs @@ -25,7 +25,7 @@ use openai_protocol::common::Tool; use serde_json::Value; use super::{ - schema::{self, Definitions}, + schema::{shape, Definitions}, CallMarkers, Grammar, Tag, WayIn, }; @@ -114,7 +114,7 @@ fn arguments(parameters: &Value) -> Grammar { /// the key with any type and any value when the schema pins none. fn argument(key: &str, schema: &Value, definitions: Definitions<'_>) -> Grammar { let key = escape(key); - match schema::shape(schema, definitions, any_value) { + match shape(schema, definitions, any_value) { Some((type_name, value)) => Grammar::Sequence(vec![ Grammar::ConstString(format!( "{OPEN}argument key=\"{key}\" type=\"{type_name}\"{SEP}" From d173b5ea16099b15b3a4bb4701761b5da9053f2f Mon Sep 17 00:00:00 2001 From: Simo Lin <25425177+slin1237@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:24:29 -0700 Subject: [PATCH 3/4] fix(symphony): a pointer to the root names nothing in a property's own document Each property's schema goes to the engine as a document of its own, so a `$ref` of `#` in it names the property, not the tool's schema it stood in, and the engine would be given a schema that only refers to itself. The definitions no longer keep the root, `#` resolves to nothing, and such a property takes any type and any value, like one whose pointer names nothing. The old Kimi K3 builder resolved `#` to the root and sent the self-reference on. Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com> Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com> Co-authored-by: Keyang Ru Co-authored-by: key4ng Co-authored-by: Chen Hongtao <56470055+chenht2022@users.noreply.github.com> Co-authored-by: LightSeek Foundation <243258330+lightseek-bot@users.noreply.github.com> --- crates/symphony/src/grammar/schema.rs | 19 ++++++++----------- crates/symphony/src/grammar/xtml.rs | 14 ++++++++++---- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/crates/symphony/src/grammar/schema.rs b/crates/symphony/src/grammar/schema.rs index ba3dc1fab2..9f6309a21b 100644 --- a/crates/symphony/src/grammar/schema.rs +++ b/crates/symphony/src/grammar/schema.rs @@ -54,13 +54,13 @@ pub(super) fn shape( } } -/// The definitions at the root of a tool's schema, `$defs` and `definitions`, and the root itself. -/// Each property's schema goes to the engine as a document of its own, and a `$ref` such as -/// `#/$defs/Node` points from the root of the document it stands in, so the definitions travel -/// with the property's schema. +/// The definitions at the root of a tool's schema, `$defs` and `definitions`. Each property's +/// schema goes to the engine as a document of its own, and a `$ref` such as `#/$defs/Node` points +/// from the root of the document it stands in, so the definitions travel with the property's +/// schema. A pointer to the root itself, `#`, names the tool's schema in the tool and the property +/// in the property's document, so it resolves to nothing here, and the property takes any value. #[derive(Clone, Copy)] pub(super) struct Definitions<'a> { - root: &'a Value, defs: Option<&'a Value>, definitions: Option<&'a Value>, } @@ -68,18 +68,15 @@ pub(super) struct Definitions<'a> { impl<'a> Definitions<'a> { pub(super) fn of(root: &'a Value) -> Self { Self { - root, defs: root.get("$defs"), definitions: root.get("definitions"), } } - /// The schema a local pointer names: `#` the root, `#/$defs/Name` or `#/definitions/Name` an - /// entry, with the pointer's escapes (`~1` for `/`, `~0` for `~`) undone. + /// The schema a local pointer names: `#/$defs/Name` or `#/definitions/Name` an entry, with the + /// pointer's escapes (`~1` for `/`, `~0` for `~`) undone; `#` itself nothing, since it would + /// name the property's own document once the engine compiles it alone. fn resolve(self, pointer: &str) -> Option<&'a Value> { - if pointer == "#" || pointer == "#/" { - return Some(self.root); - } let mut segments = pointer.strip_prefix("#/")?.split('/'); let mut node = match segments.next()? { "$defs" => self.defs?, diff --git a/crates/symphony/src/grammar/xtml.rs b/crates/symphony/src/grammar/xtml.rs index db18857ff6..4dbf2e7f06 100644 --- a/crates/symphony/src/grammar/xtml.rs +++ b/crates/symphony/src/grammar/xtml.rs @@ -333,13 +333,14 @@ mod tests { "count": {"$ref": "#/$defs/Count"}, "lost": {"$ref": "#/$defs/Missing"}, "untyped": {"description": "anything"}, + "whole": {"$ref": "#"}, }, - "required": ["count", "lost", "untyped"], + "required": ["count", "lost", "untyped", "whole"], }), )]); let slots = call["elements"][3]["elements"] .as_array() - .expect("three slots"); + .expect("four slots"); // The `$ref` lends its target's type, and the definitions travel with the schema. assert_eq!( slots[0]["elements"][0]["value"], @@ -352,8 +353,13 @@ mod tests { "$defs": {"Count": {"type": "integer"}, "Unused": {"type": "string"}}, }) ); - // A pointer at nothing, or no type: the key stays, the type and the value are the model's. - for (slot, key) in [(&slots[1], "lost"), (&slots[2], "untyped")] { + // A pointer at nothing, no type, or a pointer to the root (the property's own document once + // the engine compiles it alone): the key stays, the type and the value are the model's. + for (slot, key) in [ + (&slots[1], "lost"), + (&slots[2], "untyped"), + (&slots[3], "whole"), + ] { let begin = format!("<|open|>argument key=\"{key}\" type=\""); assert_eq!( *slot, From ad493530118ad114ef7f94ec6b7da034141b4c36 Mon Sep 17 00:00:00 2001 From: Simo Lin <25425177+slin1237@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:42:31 -0700 Subject: [PATCH 4/4] fix(symphony): a pointer inside a definition the property names counts as the property's own The definitions travel with a property's schema to the engine, so a pointer at nothing, or to the root, inside a definition the property names reaches the engine as surely as one in the property itself, and the check that keeps such a property untyped now follows every pointer it resolves into what it names, as far as the pointers reach. A definition that names itself is followed once, so a recursive tree keeps its schema. Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com> Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com> Co-authored-by: Keyang Ru Co-authored-by: key4ng Co-authored-by: Chen Hongtao <56470055+chenht2022@users.noreply.github.com> Co-authored-by: LightSeek Foundation <243258330+lightseek-bot@users.noreply.github.com> --- crates/symphony/src/grammar/schema.rs | 30 ++++++++++++++----- crates/symphony/src/grammar/xtml.rs | 43 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/symphony/src/grammar/schema.rs b/crates/symphony/src/grammar/schema.rs index 9f6309a21b..8faa3145df 100644 --- a/crates/symphony/src/grammar/schema.rs +++ b/crates/symphony/src/grammar/schema.rs @@ -95,17 +95,33 @@ impl<'a> Definitions<'a> { self.resolve(schema.get("$ref")?.as_str()?) } - /// Whether every local pointer in `schema` names something. + /// Whether every local pointer in `schema` names something, and every pointer in what it + /// names, as far as the pointers reach: the definitions travel with the property, so a pointer + /// at nothing inside one of them reaches the engine too. A definition that names itself is + /// followed once. fn pointers_resolve(self, schema: &Value) -> bool { + self.pointers_resolve_from(schema, &mut Vec::new()) + } + + fn pointers_resolve_from(self, schema: &Value, followed: &mut Vec) -> bool { match schema { Value::Object(map) => map.iter().all(|(key, value)| { - let points_at_nothing = is_reference(key) - && value.as_str().is_some_and(|pointer| { - pointer.starts_with('#') && self.resolve(pointer).is_none() - }); - !points_at_nothing && self.pointers_resolve(value) + let local = value + .as_str() + .filter(|pointer| is_reference(key) && pointer.starts_with('#')); + let named_resolves = local.is_none_or(|pointer| match self.resolve(pointer) { + None => false, + Some(_) if followed.iter().any(|seen| seen == pointer) => true, + Some(target) => { + followed.push(pointer.to_string()); + self.pointers_resolve_from(target, followed) + } + }); + named_resolves && self.pointers_resolve_from(value, followed) }), - Value::Array(items) => items.iter().all(|item| self.pointers_resolve(item)), + Value::Array(items) => items + .iter() + .all(|item| self.pointers_resolve_from(item, followed)), _ => true, } } diff --git a/crates/symphony/src/grammar/xtml.rs b/crates/symphony/src/grammar/xtml.rs index 4dbf2e7f06..3d975e5723 100644 --- a/crates/symphony/src/grammar/xtml.rs +++ b/crates/symphony/src/grammar/xtml.rs @@ -374,6 +374,49 @@ mod tests { } } + #[test] + fn a_pointer_inside_a_definition_counts_as_the_propertys_own() { + // The definitions travel with the property's schema, so a pointer at nothing, or to the + // root, inside a definition the property names reaches the engine as surely as one in the + // property itself: that property pins nothing. A definition that names itself is followed + // once and resolves, so a recursive tree keeps its schema, the definitions attached. + let call = first_call(&[tool( + "t", + value!({ + "type": "object", + "$defs": { + "Node": {"type": "object", "properties": {"child": {"$ref": "#/$defs/Node"}}}, + "Broken": {"type": "object", "properties": {"x": {"$ref": "#/$defs/Gone"}}}, + "Rooted": {"type": "object", "properties": {"up": {"$ref": "#"}}}, + }, + "properties": { + "tree": {"$ref": "#/$defs/Node"}, + "broken": {"$ref": "#/$defs/Broken"}, + "rooted": {"$ref": "#/$defs/Rooted"}, + }, + "required": ["tree", "broken", "rooted"], + }), + )]); + let slots = call["elements"][3]["elements"] + .as_array() + .expect("three slots"); + assert_eq!( + slots[0]["elements"][0]["value"], + argument_open("tree", "object") + ); + assert_eq!( + slots[0]["elements"][1]["json_schema"]["$ref"], + "#/$defs/Node" + ); + for (slot, key) in [(&slots[1], "broken"), (&slots[2], "rooted")] { + assert_eq!( + slot["elements"][0]["value"], + format!("<|open|>argument key=\"{key}\" type=\""), + "{key} pins nothing" + ); + } + } + #[test] fn attribute_values_are_escaped_as_the_template_writes_them() { let parameters = value!({