oxicode_ai/dialect/mod.rs
1//! Owned (in-band) tool-calling dialects.
2//!
3//! This module is the Rust port of omp's `@oh-my-pi/pi-ai/dialect` package. It
4//! lets a model that has **no native tool-calling support** still drive the
5//! agent loop: the tool catalog is injected into the system prompt as text,
6//! prior tool calls/results are re-encoded as text in the history, and the
7//! model's text output is parsed back into canonical [`ToolCall`] blocks.
8//!
9//! # Architecture (omp three-piece contract)
10//!
11//! 1. **Prompt injection** — `render_inband_tool_prompt` appends the tool
12//! catalog plus the dialect's format guide to the system prompt.
13//! 2. **History encoding** — `encode_inband_tool_history` rewrites prior
14//! assistant tool calls and tool results into the dialect's text form so the
15//! model sees a coherent transcript (and prefix caching stays stable).
16//! 3. **Output parsing** — `Dialect::parse` turns the model's text back into
17//! `ScanSegment`s (text / thinking / tool calls) the loop can execute.
18//!
19//! # Dialects
20//!
21//! `Dialect` enumerates the 11 wire dialects omp knows. This first delivery
22//! implements the **XML** dialect fully (the documented fallback,
23//! `FALLBACK_DIALECT` in omp). Dialects without a dedicated implementation
24//! fall back to XML — this mirrors omp's fallback semantics rather than being a
25//! placeholder: XML's `<invoke>/<parameter>` grammar is the generic envelope.
26//! Streaming scanners and agent-loop wiring land in a follow-up.
27//!
28//! [`ToolCall`]: crate::ToolCall
29
30mod coercion;
31mod history;
32mod render;
33mod xml;
34
35pub use coercion::{ToolArgShape, build_arg_shapes, is_string_only_schema};
36pub use history::encode_inband_tool_history;
37pub use render::{render_inband_tool_prompt, render_tool_catalog};
38
39use crate::messages::{AssistantMessage, ToolCall};
40use crate::tools::Tool;
41
42/// An owned tool-calling dialect.
43///
44/// Mirrors omp's `Dialect` union (`@oh-my-pi/pi-catalog/identity`). The variant
45/// selects the text grammar used to render and parse in-band tool calls.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Dialect {
48 /// GLM (ChatGLM) XML dialect.
49 Glm,
50 /// Hermes / Qwen JSON-fence dialect.
51 Hermes,
52 /// Kimi (Moonshot) section dialect.
53 Kimi,
54 /// Generic XML dialect — the fallback (`<invoke>/<parameter>`).
55 Xml,
56 /// Anthropic `<function_calls>` / `<antml:invoke>` dialect.
57 Anthropic,
58 /// DeepSeek DSML dialect.
59 Deepseek,
60 /// OpenAI / GPT-OSS Harmony control-token dialect.
61 Harmony,
62 /// Qwen3 dialect.
63 Qwen3,
64 /// Google Gemini `<start_of_turn>` dialect.
65 Gemini,
66 /// Google Gemma `<|turn>` dialect.
67 Gemma,
68 /// MiniMax `<minimax:tool_call>` dialect.
69 Minimax,
70}
71
72/// The dialect used when a model family has no dedicated grammar.
73pub const FALLBACK_DIALECT: Dialect = Dialect::Xml;
74
75impl Dialect {
76 /// Parse a dialect from its kebab/short name (e.g. `"xml"`, `"qwen3"`).
77 ///
78 /// Returns `None` for unknown names. Accepts the canonical lower-case
79 /// identifier omp uses on the wire and in `PI_DIALECT`.
80 pub fn from_name(name: &str) -> Option<Self> {
81 Some(match name.trim().to_ascii_lowercase().as_str() {
82 "glm" => Dialect::Glm,
83 "hermes" => Dialect::Hermes,
84 "kimi" => Dialect::Kimi,
85 "xml" => Dialect::Xml,
86 "anthropic" => Dialect::Anthropic,
87 "deepseek" => Dialect::Deepseek,
88 "harmony" => Dialect::Harmony,
89 "qwen3" | "qwen" => Dialect::Qwen3,
90 "gemini" => Dialect::Gemini,
91 "gemma" => Dialect::Gemma,
92 "minimax" => Dialect::Minimax,
93 _ => return None,
94 })
95 }
96
97 /// The canonical wire identifier (matches omp's `Dialect` string values).
98 pub fn as_str(self) -> &'static str {
99 match self {
100 Dialect::Glm => "glm",
101 Dialect::Hermes => "hermes",
102 Dialect::Kimi => "kimi",
103 Dialect::Xml => "xml",
104 Dialect::Anthropic => "anthropic",
105 Dialect::Deepseek => "deepseek",
106 Dialect::Harmony => "harmony",
107 Dialect::Qwen3 => "qwen3",
108 Dialect::Gemini => "gemini",
109 Dialect::Gemma => "gemma",
110 Dialect::Minimax => "minimax",
111 }
112 }
113
114 /// Resolve the dialect a model id prefers, by family token.
115 ///
116 /// Mirrors omp's `preferredDialect(modelId)`. The match is a substring
117 /// scan over the lower-cased model id; unknown families fall back to
118 /// [`FALLBACK_DIALECT`].
119 pub fn preferred_for_model(model_id: &str) -> Self {
120 let id = model_id.to_ascii_lowercase();
121 // Order matters: more specific families first (gemma before gemini).
122 if id.contains("anthropic") || id.contains("claude") {
123 Dialect::Anthropic
124 } else if id.contains("glm") {
125 Dialect::Glm
126 } else if id.contains("gemma") {
127 Dialect::Gemma
128 } else if id.contains("gemini") {
129 Dialect::Gemini
130 } else if id.contains("kimi") || id.contains("moonshot") {
131 Dialect::Kimi
132 } else if id.contains("qwen") {
133 Dialect::Qwen3
134 } else if id.contains("deepseek") {
135 Dialect::Deepseek
136 } else if id.contains("minimax") {
137 Dialect::Minimax
138 } else if id.contains("gpt-oss") || id.contains("openai") || id.contains("gpt-") {
139 Dialect::Harmony
140 } else {
141 FALLBACK_DIALECT
142 }
143 }
144
145 /// The dialect's format-guide prompt fragment (injected after the catalog).
146 pub fn prompt(self) -> String {
147 // Only XML carries a dedicated prompt in this delivery; every dialect
148 // falls back to the XML guide, matching omp's fallback semantics.
149 xml::xml_prompt()
150 }
151
152 /// Render a batch of (parallel) tool calls as one text block.
153 pub fn render_tool_calls(self, calls: &[ToolCall], tools: &[Tool]) -> String {
154 xml::render_tool_calls(calls, tools)
155 }
156
157 /// Render a run of tool results as one text block.
158 pub fn render_tool_results(self, results: &[RenderedToolResult]) -> String {
159 xml::render_tool_results(results)
160 }
161
162 /// Render a thinking/reasoning block in the dialect's envelope.
163 pub fn render_thinking(self, text: &str) -> String {
164 xml::render_thinking(text)
165 }
166
167 /// Parse completed model text into segments (text / thinking / tool calls).
168 ///
169 /// This is the batch entry point. Streaming (incremental) parsing lands in
170 /// a follow-up; the agent loop will call this on the accumulated assistant
171 /// text once the turn completes.
172 pub fn parse(self, text: &str, tools: &[Tool]) -> Vec<ScanSegment> {
173 xml::parse(text, tools)
174 }
175
176 /// Re-materialize in-band tool-call text on an assistant message as native
177 /// [`ToolCall`] content blocks, leaving any prose as text blocks.
178 ///
179 /// Returns the rewritten message. When the text contains no tool calls the
180 /// message is returned unchanged.
181 pub fn parse_assistant_message(
182 self,
183 message: &AssistantMessage,
184 tools: &[Tool],
185 ) -> AssistantMessage {
186 xml::parse_assistant_message(message, tools)
187 }
188}
189
190/// A tool result flattened to text for dialect rendering.
191///
192/// Mirrors omp's `DialectToolResult`.
193#[derive(Debug, Clone)]
194pub struct RenderedToolResult {
195 /// The tool call id this result answers.
196 pub id: String,
197 /// The tool name.
198 pub name: String,
199 /// Position within a parallel result run (0-based).
200 pub index: usize,
201 /// Flattened text content.
202 pub text: String,
203 /// Whether the tool reported an error.
204 pub is_error: bool,
205}
206
207/// A parsed segment of in-band model output.
208///
209/// Mirrors the terminal events of omp's `InbandScanEvent` stream, collapsed to
210/// the three segment kinds the agent loop consumes.
211#[derive(Debug, Clone, PartialEq)]
212pub enum ScanSegment {
213 /// Visible prose text.
214 Text(String),
215 /// Reasoning / chain-of-thought.
216 Thinking(String),
217 /// A re-materialized tool call.
218 ToolCall(ToolCall),
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn dialect_round_trips_through_str() {
227 for d in [
228 Dialect::Glm,
229 Dialect::Hermes,
230 Dialect::Kimi,
231 Dialect::Xml,
232 Dialect::Anthropic,
233 Dialect::Deepseek,
234 Dialect::Harmony,
235 Dialect::Qwen3,
236 Dialect::Gemini,
237 Dialect::Gemma,
238 Dialect::Minimax,
239 ] {
240 assert_eq!(Dialect::from_name(d.as_str()), Some(d), "{d:?}");
241 }
242 }
243
244 #[test]
245 fn dialect_from_name_is_case_insensitive_and_trims() {
246 assert_eq!(Dialect::from_name(" XML "), Some(Dialect::Xml));
247 assert_eq!(Dialect::from_name("Qwen"), Some(Dialect::Qwen3));
248 assert_eq!(Dialect::from_name("nope"), None);
249 }
250
251 #[test]
252 fn preferred_dialect_matches_families() {
253 assert_eq!(
254 Dialect::preferred_for_model("claude-sonnet-4"),
255 Dialect::Anthropic
256 );
257 assert_eq!(
258 Dialect::preferred_for_model("anthropic/claude-opus"),
259 Dialect::Anthropic
260 );
261 assert_eq!(
262 Dialect::preferred_for_model("gemini-2.5-pro"),
263 Dialect::Gemini
264 );
265 assert_eq!(Dialect::preferred_for_model("gemma-3-27b"), Dialect::Gemma);
266 assert_eq!(Dialect::preferred_for_model("qwen3-coder"), Dialect::Qwen3);
267 assert_eq!(
268 Dialect::preferred_for_model("deepseek-v3"),
269 Dialect::Deepseek
270 );
271 assert_eq!(
272 Dialect::preferred_for_model("gpt-oss-120b"),
273 Dialect::Harmony
274 );
275 assert_eq!(Dialect::preferred_for_model("glm-4.6"), Dialect::Glm);
276 assert_eq!(Dialect::preferred_for_model("kimi-k2"), Dialect::Kimi);
277 assert_eq!(Dialect::preferred_for_model("minimax-m2"), Dialect::Minimax);
278 // Unknown family falls back.
279 assert_eq!(
280 Dialect::preferred_for_model("llama-3.3-70b"),
281 FALLBACK_DIALECT
282 );
283 }
284
285 #[test]
286 fn gemma_wins_over_gemini_prefix() {
287 // "gemma" must be checked before "gemini" — both contain "gem".
288 assert_eq!(Dialect::preferred_for_model("gemma-2-9b"), Dialect::Gemma);
289 }
290}