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}