Skip to main content

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}