Skip to main content

gglib_core/domain/
dialect.rs

1//! Tool-call dialect specification.
2//!
3//! A [`DialectSpec`] describes, as plain data, how a model wraps tool calls
4//! inside its text output: the envelope markers, the body encodings to try,
5//! and the whitespace layout it uses when emitting a call. It is the single
6//! source of truth consumed by every layer that must agree on a dialect:
7//!
8//! - the delimited stream parser (`normalize::parsers::delimited`) scans for
9//!   `tool_open`/`tool_close` and decodes bodies via `body_codecs`;
10//! - the decode-time GBNF grammar (`request_pipeline::constrain`) is
11//!   generated from the same markers and [`EmissionProfile`];
12//! - detection (`gglib-gguf`) produces specs — either derived from the
13//!   model's own chat template or the [`DialectSpec::qwen_xml`] builtin.
14//!
15//! Because parser, grammar, and detection all read one value, they cannot
16//! drift: anything the grammar permits, the parser can parse, provable via
17//! [`DialectSpec::render_call`].
18//!
19//! Specs are persisted per model (JSON in the `dialect_spec` column), so the
20//! serde shape is forward-compatible: later-added fields must carry
21//! `#[serde(default)]`.
22
23use serde::{Deserialize, Serialize};
24
25/// Spec `id` of the built-in Qwen/Hermes `<tool_call>` dialect.
26pub const QWEN_XML_DIALECT_ID: &str = "qwen-xml";
27
28/// Spec `id` for dialects derived from a model's chat template.
29pub const DERIVED_DIALECT_ID: &str = "derived";
30
31/// Synthetic tool-call ID prefix used by template-derived specs.
32pub const DERIVED_ID_PREFIX: &str = "call_dialect_";
33
34/// Synthetic tool-call ID prefix used by the built-in Qwen dialect.
35///
36/// Kept Qwen-branded for continuity with pre-spec releases.
37pub const QWEN_ID_PREFIX: &str = "call_qwen_";
38
39/// How a tool-call body is encoded between the envelope markers.
40///
41/// Codec *internals* (key names, inner XML markers) are properties of the
42/// codec itself, invariant across models that use it, and live in the parser
43/// — a spec only selects which codecs apply and in what order.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum BodyCodec {
47    /// A `{"name": ..., "arguments": {...}}` JSON object (Qwen 2/2.5,
48    /// Hermes, and most template-derived dialects).
49    Json,
50    /// One or more `<function=NAME><parameter=KEY>VALUE</parameter>...`
51    /// blocks (Qwen 3 under `--jinja`, Hermes-style).
52    FunctionXml,
53}
54
55/// Whitespace layout a dialect uses when emitting a call.
56///
57/// Consumed by the GBNF grammar generator and by
58/// [`DialectSpec::render_call`], so enforcement and tests emit exactly what
59/// the model was trained to produce.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub struct EmissionProfile {
62    /// Whether a newline separates the open marker from the body.
63    pub newline_after_open: bool,
64    /// Whether a newline separates the body from the close marker.
65    pub newline_before_close: bool,
66}
67
68impl Default for EmissionProfile {
69    /// Newlines on both sides — the layout shared by every dialect observed
70    /// so far (`<tool_call>\n{...}\n</tool_call>`).
71    fn default() -> Self {
72        Self {
73            newline_after_open: true,
74            newline_before_close: true,
75        }
76    }
77}
78
79fn default_id_prefix() -> String {
80    DERIVED_ID_PREFIX.to_owned()
81}
82
83/// A model's tool-call dialect, described entirely as data.
84///
85/// See the module docs for the consumer contract. `tool_open == tool_close`
86/// is a valid spec (fenced dialects); an empty marker is not.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct DialectSpec {
89    /// Stable identifier — [`QWEN_XML_DIALECT_ID`] for the builtin,
90    /// [`DERIVED_DIALECT_ID`] for template-derived specs.
91    pub id: String,
92    /// Marker that opens a tool-call envelope, e.g. `<tool_call>`.
93    pub tool_open: String,
94    /// Marker that closes a tool-call envelope, e.g. `</tool_call>`.
95    pub tool_close: String,
96    /// Body encodings to try, in order, against the envelope contents.
97    pub body_codecs: Vec<BodyCodec>,
98    /// Whitespace layout for emitted calls (grammar + [`Self::render_call`]).
99    #[serde(default)]
100    pub emission: EmissionProfile,
101    /// Prefix for synthesized tool-call IDs, e.g. `call_qwen_`.
102    #[serde(default = "default_id_prefix")]
103    pub id_prefix: String,
104}
105
106impl DialectSpec {
107    /// The built-in Qwen 2 / 2.5 / 3 (and Hermes-family) dialect:
108    /// `<tool_call>` envelope with a JSON body, falling back to the
109    /// `<function=...>` inner-XML body Qwen 3 emits under `--jinja`.
110    #[must_use]
111    pub fn qwen_xml() -> Self {
112        Self {
113            id: QWEN_XML_DIALECT_ID.to_owned(),
114            tool_open: "<tool_call>".to_owned(),
115            tool_close: "</tool_call>".to_owned(),
116            body_codecs: vec![BodyCodec::Json, BodyCodec::FunctionXml],
117            emission: EmissionProfile::default(),
118            id_prefix: QWEN_ID_PREFIX.to_owned(),
119        }
120    }
121
122    /// Whether the JSON body codec applies — the precondition for GBNF
123    /// grammar enforcement, which can only originate JSON-shaped bodies.
124    #[must_use]
125    pub fn supports_json_body(&self) -> bool {
126        self.body_codecs.contains(&BodyCodec::Json)
127    }
128
129    /// Render one canonical tool call exactly as the grammar would enforce
130    /// it: envelope markers, [`EmissionProfile`] newlines, and a JSON body
131    /// with `name` before `arguments`.
132    ///
133    /// This is the bridge that proves grammar and parser share one source:
134    /// tests feed `render_call` output through the parser and require the
135    /// call to round-trip.
136    #[must_use]
137    pub fn render_call(&self, name: &str, arguments: &serde_json::Value) -> String {
138        let after_open = if self.emission.newline_after_open {
139            "\n"
140        } else {
141            ""
142        };
143        let before_close = if self.emission.newline_before_close {
144            "\n"
145        } else {
146            ""
147        };
148        let name_json = serde_json::Value::String(name.to_owned());
149        format!(
150            "{}{}{{\"name\": {}, \"arguments\": {}}}{}{}",
151            self.tool_open, after_open, name_json, arguments, before_close, self.tool_close
152        )
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use serde_json::json;
160
161    #[test]
162    fn builtin_qwen_spec_shape() {
163        let spec = DialectSpec::qwen_xml();
164        assert_eq!(spec.id, QWEN_XML_DIALECT_ID);
165        assert_eq!(spec.tool_open, "<tool_call>");
166        assert_eq!(spec.tool_close, "</tool_call>");
167        assert_eq!(
168            spec.body_codecs,
169            vec![BodyCodec::Json, BodyCodec::FunctionXml]
170        );
171        assert_eq!(spec.id_prefix, QWEN_ID_PREFIX);
172        assert!(spec.supports_json_body());
173    }
174
175    #[test]
176    fn function_xml_only_spec_has_no_json_body() {
177        let spec = DialectSpec {
178            body_codecs: vec![BodyCodec::FunctionXml],
179            ..DialectSpec::qwen_xml()
180        };
181        assert!(!spec.supports_json_body());
182    }
183
184    #[test]
185    fn render_call_matches_the_qwen_emission_shape() {
186        let spec = DialectSpec::qwen_xml();
187        let emission = spec.render_call("read_file", &json!({"path": "a.rs"}));
188        assert_eq!(
189            emission,
190            "<tool_call>\n{\"name\": \"read_file\", \"arguments\": {\"path\":\"a.rs\"}}\n</tool_call>"
191        );
192    }
193
194    #[test]
195    fn render_call_honors_the_emission_profile() {
196        let spec = DialectSpec {
197            emission: EmissionProfile {
198                newline_after_open: false,
199                newline_before_close: false,
200            },
201            ..DialectSpec::qwen_xml()
202        };
203        let emission = spec.render_call("f", &json!({}));
204        assert_eq!(
205            emission,
206            "<tool_call>{\"name\": \"f\", \"arguments\": {}}</tool_call>"
207        );
208    }
209
210    #[test]
211    fn render_call_json_escapes_the_name() {
212        let spec = DialectSpec::qwen_xml();
213        let emission = spec.render_call("we\"ird", &json!({}));
214        assert!(emission.contains(r#""we\"ird""#));
215    }
216
217    #[test]
218    fn serde_round_trip_preserves_every_field() {
219        let spec = DialectSpec {
220            id: DERIVED_DIALECT_ID.to_owned(),
221            tool_open: "«TC»".to_owned(),
222            tool_close: "«/TC»".to_owned(),
223            body_codecs: vec![BodyCodec::Json],
224            emission: EmissionProfile {
225                newline_after_open: false,
226                newline_before_close: true,
227            },
228            id_prefix: DERIVED_ID_PREFIX.to_owned(),
229        };
230        let json = serde_json::to_string(&spec).unwrap();
231        let back: DialectSpec = serde_json::from_str(&json).unwrap();
232        assert_eq!(back, spec);
233    }
234
235    #[test]
236    fn deserialize_tolerates_missing_defaulted_fields() {
237        // A row persisted by an older build that predates `emission` /
238        // `id_prefix` must still deserialize.
239        let json = r#"{
240            "id": "qwen-xml",
241            "tool_open": "<tool_call>",
242            "tool_close": "</tool_call>",
243            "body_codecs": ["json", "function_xml"]
244        }"#;
245        let spec: DialectSpec = serde_json::from_str(json).unwrap();
246        assert_eq!(spec.emission, EmissionProfile::default());
247        assert_eq!(spec.id_prefix, DERIVED_ID_PREFIX);
248    }
249
250    #[test]
251    fn deserialize_tolerates_unknown_fields() {
252        // A row persisted by a *newer* build with extra fields must not
253        // fail on an older reader.
254        let json = r#"{
255            "id": "derived",
256            "tool_open": "A",
257            "tool_close": "B",
258            "body_codecs": ["json"],
259            "reasoning_open": "<think>"
260        }"#;
261        let spec: DialectSpec = serde_json::from_str(json).unwrap();
262        assert_eq!(spec.tool_open, "A");
263    }
264}