Skip to main content

dynamo_renderer/deepseek/
common.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared DeepSeek native prompt-formatting helpers.
5
6use anyhow::{Context, Result};
7use serde_json::Value as JsonValue;
8
9/// Special tokens for DeepSeek prompt formatting.
10pub mod tokens {
11    pub const BOS: &str = "<|begin▁of▁sentence|>";
12    pub const EOS: &str = "<|end▁of▁sentence|>";
13    pub const THINKING_START: &str = "<think>";
14    pub const THINKING_END: &str = "</think>";
15    pub const DSML_TOKEN: &str = "|DSML|";
16    pub const USER_START: &str = "<|User|>";
17    pub const ASSISTANT_START: &str = "<|Assistant|>";
18    pub const LATEST_REMINDER: &str = "<|latest_reminder|>";
19
20    // Quick-instruction task tokens
21    pub const TASK_ACTION: &str = "<|action|>";
22    pub const TASK_QUERY: &str = "<|query|>";
23    pub const TASK_AUTHORITY: &str = "<|authority|>";
24    pub const TASK_DOMAIN: &str = "<|domain|>";
25    pub const TASK_TITLE: &str = "<|title|>";
26    pub const TASK_READ_URL: &str = "<|read_url|>";
27}
28
29pub(crate) const TOOL_CALLS_BLOCK_NAME: &str = "tool_calls";
30
31pub(crate) const RESPONSE_FORMAT_TEMPLATE: &str =
32    "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}";
33
34pub(crate) const TOOLS_TEMPLATE: &str = r#"## Tools
35
36You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
37
38<{dsml_token}tool_calls>
39<{dsml_token}invoke name="$TOOL_NAME">
40<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
41...
42</{dsml_token}invoke>
43<{dsml_token}invoke name="$TOOL_NAME2">
44...
45</{dsml_token}invoke>
46</{dsml_token}tool_calls>
47
48String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
49
50If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
51
52Otherwise, output directly after {thinking_end_token} with tool calls or final response.
53
54### Available Tool Schemas
55
56{tool_schemas}
57
58You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
59"#;
60
61/// System message template for tools.
62pub(crate) const TOOLS_SYSTEM_TEMPLATE: &str = r#"## Tools
63
64You have access to a set of tools you can use to answer the user's question.
65You can invoke functions by writing a "<{dsml_token}function_calls>" block like the following as part of your reply to the user:
66<{dsml_token}function_calls>
67<{dsml_token}invoke name="$FUNCTION_NAME">
68<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
69...
70</{dsml_token}invoke>
71<{dsml_token}invoke name="$FUNCTION_NAME2">
72...
73</{dsml_token}invoke>
74</{dsml_token}function_calls>
75
76String and scalar parameters should be specified as is without any escaping or quotes, while lists and objects should use JSON format. The "string" attribute should be set to "true" for string type parameters and "false" for other types (numbers, booleans, arrays, objects).
77
78If the thinking_mode is enabled, then after function results you should strongly consider outputting a thinking block. Here is an example:
79
80<{dsml_token}function_calls>
81...
82</{dsml_token}function_calls>
83
84<function_results>
85...
86</function_results>
87
88{thinking_start_token}...thinking about results{thinking_end_token}
89
90Here are the functions available in JSONSchema format:
91<functions>
92{tool_schemas}
93</functions>
94"#;
95
96pub(crate) const REASONING_EFFORT_HIGH: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\nYou MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\nExplicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n";
97
98pub(crate) const REASONING_EFFORT_MAX: &str = "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\nYou MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\nDo not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n";
99
100/// Thinking mode for the model.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ThinkingMode {
103    Chat,
104    Thinking,
105}
106
107impl ThinkingMode {
108    pub fn as_str(&self) -> &'static str {
109        match self {
110            ThinkingMode::Chat => "chat",
111            ThinkingMode::Thinking => "thinking",
112        }
113    }
114}
115
116/// Reasoning effort level. `None` conveyed as `Option<ReasoningEffort>`.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum ReasoningEffort {
119    Max,
120    High,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(crate) enum NormalizeNonText {
125    SerializeJson,
126    LeaveUntouched,
127}
128
129// Serialize a JSON value to match Python's `json.dumps(ensure_ascii=False)` spacing.
130// Python's default separators are `(', ', ': ')`; we use a custom `Formatter`
131// so escape sequences inside strings can't confuse state tracking.
132pub(crate) fn to_json(value: &JsonValue) -> String {
133    use serde::Serialize;
134    use serde_json::ser::Formatter;
135    use std::io;
136
137    struct PythonFormatter;
138
139    impl Formatter for PythonFormatter {
140        fn begin_array_value<W: ?Sized + io::Write>(
141            &mut self,
142            writer: &mut W,
143            first: bool,
144        ) -> io::Result<()> {
145            if first {
146                Ok(())
147            } else {
148                writer.write_all(b", ")
149            }
150        }
151
152        fn begin_object_key<W: ?Sized + io::Write>(
153            &mut self,
154            writer: &mut W,
155            first: bool,
156        ) -> io::Result<()> {
157            if first {
158                Ok(())
159            } else {
160                writer.write_all(b", ")
161            }
162        }
163
164        fn begin_object_value<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
165            writer.write_all(b": ")
166        }
167    }
168
169    // Serializing a JsonValue into Vec<u8> is infallible; the output is always UTF-8.
170    let mut buf = Vec::with_capacity(64);
171    let mut ser = serde_json::Serializer::with_formatter(&mut buf, PythonFormatter);
172    value
173        .serialize(&mut ser)
174        .expect("JsonValue serialization to Vec<u8> is infallible");
175    String::from_utf8(buf).expect("serde_json output is always valid UTF-8")
176}
177
178pub(crate) fn render_tools(template: &str, tools: &[JsonValue]) -> String {
179    let tools_json: Vec<String> = tools
180        .iter()
181        .filter_map(|tool| tool.get("function"))
182        .map(to_json)
183        .collect();
184
185    // Always do the tool_schemas last because they are user controlled.
186    // See test_render_tools_preserves_placeholder_text_inside_tool_schema.
187    template
188        .replace("{dsml_token}", tokens::DSML_TOKEN)
189        .replace("{thinking_start_token}", tokens::THINKING_START)
190        .replace("{thinking_end_token}", tokens::THINKING_END)
191        .replace("{tool_schemas}", &tools_json.join("\n"))
192}
193
194pub(crate) fn find_last_user_index(messages: &[JsonValue]) -> Option<usize> {
195    messages
196        .iter()
197        .enumerate()
198        .rev()
199        .find(|(_, msg)| {
200            msg.get("role")
201                .and_then(|r| r.as_str())
202                .map(|r| r == "user" || r == "developer")
203                .unwrap_or(false)
204        })
205        .map(|(idx, _)| idx)
206}
207
208pub(crate) fn extract_visible_text(content: &JsonValue) -> String {
209    match content {
210        JsonValue::String(text) => text.clone(),
211        JsonValue::Array(items) => items
212            .iter()
213            .filter_map(|item| {
214                if let Some(text) = item.as_str() {
215                    return Some(text.to_string());
216                }
217                let item_type = item.get("type").and_then(|v| v.as_str());
218                if item_type == Some("text") {
219                    return item
220                        .get("text")
221                        .and_then(|v| v.as_str())
222                        .map(|text| text.to_string());
223                }
224                tracing::warn!(
225                    chunk_type = item_type.unwrap_or("unknown"),
226                    "DeepSeek formatter dropped non-text content chunk while normalizing message content",
227                );
228                None
229            })
230            .collect::<String>(),
231        _ => to_json(content),
232    }
233}
234
235pub(crate) fn normalize_message_contents(messages: &mut [JsonValue], non_text: NormalizeNonText) {
236    for msg in messages {
237        let Some(content) = msg.get("content") else {
238            continue;
239        };
240        // Text content is already normalized. Keep its allocation in place.
241        if content.is_string() {
242            continue;
243        }
244        if !content.is_array() && non_text == NormalizeNonText::LeaveUntouched {
245            continue;
246        }
247        let normalized = extract_visible_text(content);
248        if let Some(obj) = msg.as_object_mut() {
249            obj.insert("content".to_string(), JsonValue::String(normalized));
250        }
251    }
252}
253
254pub(crate) fn encode_arguments_to_dsml(tool_call: &JsonValue) -> Result<String> {
255    let arguments_str = tool_call
256        .get("arguments")
257        .and_then(|a| a.as_str())
258        .context("Missing or invalid 'arguments' field")?;
259
260    // Python falls back to `{"arguments": raw_string}` on parse failure.
261    let arguments: JsonValue = match serde_json::from_str(arguments_str) {
262        Ok(v) => v,
263        Err(_) => serde_json::json!({ "arguments": arguments_str }),
264    };
265
266    let arguments_obj = arguments
267        .as_object()
268        .context("Arguments must be a JSON object")?;
269
270    let mut params = Vec::new();
271    for (key, value) in arguments_obj {
272        let value_str = if let Some(vs) = value.as_str() {
273            vs.to_string()
274        } else {
275            to_json(value)
276        };
277        params.push(format!(
278            "<{}parameter name=\"{}\" string=\"{}\">{}</{}parameter>",
279            tokens::DSML_TOKEN,
280            key,
281            if value.is_string() { "true" } else { "false" },
282            value_str,
283            tokens::DSML_TOKEN
284        ));
285    }
286
287    Ok(params.join("\n"))
288}
289
290pub(crate) fn task_token(task: &str) -> Option<&'static str> {
291    match task {
292        "action" => Some(tokens::TASK_ACTION),
293        "query" => Some(tokens::TASK_QUERY),
294        "authority" => Some(tokens::TASK_AUTHORITY),
295        "domain" => Some(tokens::TASK_DOMAIN),
296        "title" => Some(tokens::TASK_TITLE),
297        "read_url" => Some(tokens::TASK_READ_URL),
298        _ => None,
299    }
300}
301
302const USER_FIELDS_TO_PRESERVE: [&str; 3] = ["task", "wo_eos", "mask"];
303
304fn preserve_user_fields(target: &mut JsonValue, source: &JsonValue) {
305    if let Some(obj) = target.as_object_mut() {
306        for key in USER_FIELDS_TO_PRESERVE {
307            if let Some(v) = source.get(key) {
308                obj.insert(key.to_string(), v.clone());
309            }
310        }
311    }
312}
313
314// Merge `tool` role messages into preceding user `content_blocks` and collapse
315// consecutive user turns with Python-compatible rendering. The internal user
316// representation keeps text only in content_blocks, without Python's duplicate
317// content field.
318pub(crate) fn merge_tool_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
319    let mut merged: Vec<JsonValue> = Vec::with_capacity(messages.len());
320
321    for mut msg in messages {
322        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
323
324        if role == "tool" {
325            let obj = msg
326                .as_object_mut()
327                .expect("a message with a role is an object");
328            let tool_block = JsonValue::Object(serde_json::Map::from_iter([
329                ("type".into(), JsonValue::String("tool_result".into())),
330                (
331                    "tool_use_id".into(),
332                    obj.remove("tool_call_id")
333                        .unwrap_or_else(|| JsonValue::String(String::new())),
334                ),
335                (
336                    "content".into(),
337                    obj.remove("content")
338                        .unwrap_or_else(|| JsonValue::String(String::new())),
339                ),
340            ]));
341
342            let can_merge = merged
343                .last()
344                .map(|m| {
345                    m.get("role").and_then(|r| r.as_str()) == Some("user")
346                        && m.get("content_blocks").is_some()
347                })
348                .unwrap_or(false);
349
350            if can_merge {
351                let last = merged.last_mut().unwrap();
352                if let Some(blocks) = last
353                    .as_object_mut()
354                    .and_then(|o| o.get_mut("content_blocks"))
355                    .and_then(|v| v.as_array_mut())
356                {
357                    blocks.push(tool_block);
358                }
359            } else {
360                merged.push(JsonValue::Object(serde_json::Map::from_iter([
361                    ("role".into(), JsonValue::String("user".into())),
362                    ("content_blocks".into(), JsonValue::Array(vec![tool_block])),
363                ])));
364            }
365        } else if role == "user" {
366            let text = msg
367                .as_object_mut()
368                .expect("a message with a role is an object")
369                .remove("content")
370                .filter(JsonValue::is_string)
371                .unwrap_or_else(|| JsonValue::String(String::new()));
372            let text_block = JsonValue::Object(serde_json::Map::from_iter([
373                ("type".into(), JsonValue::String("text".into())),
374                ("text".into(), text),
375            ]));
376
377            let can_merge = merged
378                .last()
379                .map(|m| {
380                    m.get("role").and_then(|r| r.as_str()) == Some("user")
381                        && m.get("content_blocks").is_some()
382                        && m.get("task").map(|v| v.is_null()).unwrap_or(true)
383                })
384                .unwrap_or(false);
385
386            if can_merge {
387                let last = merged.last_mut().unwrap();
388                let appended = last
389                    .as_object_mut()
390                    .and_then(|o| o.get_mut("content_blocks"))
391                    .and_then(|v| v.as_array_mut())
392                    .map(|blocks| {
393                        blocks.push(text_block);
394                    })
395                    .is_some();
396                if appended {
397                    preserve_user_fields(last, &msg);
398                }
399            } else {
400                // Rendering reads content_blocks; retaining content would copy
401                // the entire user message for an unused second representation.
402                let mut new_msg = JsonValue::Object(serde_json::Map::from_iter([
403                    ("role".into(), JsonValue::String("user".into())),
404                    ("content_blocks".into(), JsonValue::Array(vec![text_block])),
405                ]));
406                preserve_user_fields(&mut new_msg, &msg);
407                merged.push(new_msg);
408            }
409        } else {
410            merged.push(msg);
411        }
412    }
413
414    merged
415}
416
417// Sort `tool_result` blocks within user messages by the `tool_calls[].id` order
418// of the preceding assistant message.
419pub(crate) fn sort_tool_results_by_call_order(mut messages: Vec<JsonValue>) -> Vec<JsonValue> {
420    use std::collections::HashMap;
421    let mut last_order: HashMap<String, usize> = HashMap::new();
422
423    for msg in &mut messages {
424        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
425        if role == "assistant" {
426            if let Some(tcs) = msg.get("tool_calls").and_then(|t| t.as_array()) {
427                last_order.clear();
428                for (idx, tc) in tcs.iter().enumerate() {
429                    let id = tc
430                        .get("id")
431                        .and_then(|v| v.as_str())
432                        .or_else(|| {
433                            tc.get("function")
434                                .and_then(|f| f.get("id"))
435                                .and_then(|v| v.as_str())
436                        })
437                        .unwrap_or("");
438                    if !id.is_empty() {
439                        last_order.insert(id.to_string(), idx);
440                    }
441                }
442            }
443        } else if role == "user" && !last_order.is_empty() {
444            let Some(blocks) = msg
445                .as_object_mut()
446                .and_then(|o| o.get_mut("content_blocks"))
447                .and_then(|v| v.as_array_mut())
448            else {
449                continue;
450            };
451
452            // Collect tool_result blocks with their positions.
453            let tool_positions: Vec<usize> = blocks
454                .iter()
455                .enumerate()
456                .filter(|(_, b)| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
457                .map(|(i, _)| i)
458                .collect();
459
460            if tool_positions.len() > 1 {
461                let start = *tool_positions
462                    .first()
463                    .expect("tool_positions has length > 1");
464                let end = *tool_positions
465                    .last()
466                    .expect("tool_positions has length > 1");
467                let is_contiguous = end - start + 1 == tool_positions.len();
468
469                if is_contiguous {
470                    // Fast path: sort the contiguous slice in place
471                    blocks[start..=end].sort_by_key(|b| {
472                        let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
473                        *last_order.get(id).unwrap_or(&0)
474                    });
475                } else {
476                    // Fallback: extract, sort, and replace for non-contiguous blocks
477                    let mut tool_blocks: Vec<JsonValue> = tool_positions
478                        .iter()
479                        .map(|&i| std::mem::take(&mut blocks[i]))
480                        .collect();
481
482                    tool_blocks.sort_by_key(|b| {
483                        let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
484                        *last_order.get(id).unwrap_or(&0)
485                    });
486
487                    for (sorted_idx, &pos) in tool_positions.iter().enumerate() {
488                        blocks[pos] = std::mem::take(&mut tool_blocks[sorted_idx]);
489                    }
490                }
491            }
492        }
493    }
494
495    messages
496}
497
498// Drop reasoning and non-essential messages before the last user message.
499pub(crate) fn drop_thinking_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
500    let last_user_idx = find_last_user_index(&messages);
501    let mut out = Vec::with_capacity(messages.len());
502    const KEEP: &[&str] = &[
503        "user",
504        "system",
505        "tool",
506        "latest_reminder",
507        "direct_search_results",
508    ];
509
510    for (idx, mut msg) in messages.into_iter().enumerate() {
511        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
512        if KEEP.contains(&role) || last_user_idx.is_none_or(|u| idx >= u) {
513            out.push(msg);
514        } else if role == "assistant" {
515            if let Some(obj) = msg.as_object_mut() {
516                obj.remove("reasoning_content");
517            }
518            out.push(msg);
519        }
520        // developer and other roles before last_user_idx are dropped.
521    }
522    out
523}
524
525pub(crate) fn resolve_thinking_mode(
526    args: Option<&std::collections::HashMap<String, serde_json::Value>>,
527    default_mode: ThinkingMode,
528) -> ThinkingMode {
529    if let Some(enabled) = crate::thinking_bool_from_args(args) {
530        return if enabled {
531            ThinkingMode::Thinking
532        } else {
533            ThinkingMode::Chat
534        };
535    }
536    if let Some(args) = args
537        && let Some(mode) = args.get("thinking_mode").and_then(|v| v.as_str())
538    {
539        match mode {
540            "chat" => return ThinkingMode::Chat,
541            "thinking" => return ThinkingMode::Thinking,
542            _ => {}
543        }
544    }
545    default_mode
546}
547
548pub(crate) fn inject_tools_and_response_format(
549    messages_array: &mut Vec<JsonValue>,
550    req: &dyn crate::OAIChatLikeRequest,
551) -> Result<()> {
552    let tools_json = req
553        .tools()
554        .map(|t| serde_json::to_value(&t))
555        .transpose()
556        .context("Failed to convert tools to JSON")?;
557
558    // OpenAI semantics for `tool_choice: "none"`: the model must not call
559    // tools. Strip the tool definitions from the prompt so the model never
560    // sees the DSML tool instructions and cannot emit raw tool markup.
561    // response_format is kept intact. Mirrors the jinja path's
562    // exclude_tools_when_tool_choice_none handling (template/oai.rs).
563    let tools_json = match req.tool_choice() {
564        Some(ref tc) if tc.as_str() == Some("none") => None,
565        _ => tools_json,
566    };
567
568    let response_format_json = req
569        .response_format()
570        .map(|rf| serde_json::to_value(&rf))
571        .transpose()
572        .context("Failed to convert response_format to JSON")?;
573
574    if tools_json.is_some() || response_format_json.is_some() {
575        let system_idx = messages_array
576            .iter()
577            .position(|msg| msg.get("role").and_then(|r| r.as_str()) == Some("system"));
578
579        if let Some(idx) = system_idx {
580            if let Some(msg) = messages_array.get_mut(idx)
581                && let Some(obj) = msg.as_object_mut()
582            {
583                if let Some(tools) = tools_json {
584                    obj.insert("tools".to_string(), tools);
585                }
586                if let Some(rf) = response_format_json {
587                    obj.insert("response_format".to_string(), rf);
588                }
589            }
590        } else {
591            let mut system_msg = serde_json::json!({
592                "role": "system",
593                "content": ""
594            });
595            if let Some(obj) = system_msg.as_object_mut() {
596                if let Some(tools) = tools_json {
597                    obj.insert("tools".to_string(), tools);
598                }
599                if let Some(rf) = response_format_json {
600                    obj.insert("response_format".to_string(), rf);
601                }
602            }
603            messages_array.insert(0, system_msg);
604        }
605    }
606    Ok(())
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use serde_json::json;
613
614    #[test]
615    fn merged_user_blocks_preserve_metadata_and_order() {
616        let merged = merge_tool_messages(vec![
617            serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "result"}),
618            serde_json::json!({"role": "user", "content": "one", "wo_eos": true, "mask": [1, 0]}),
619            serde_json::json!({"role": "user", "content": "two", "task": "action"}),
620            serde_json::json!({"role": "user", "content": "separate"}),
621        ]);
622        assert_eq!(merged.len(), 2);
623        assert!(
624            merged
625                .iter()
626                .all(|message| message.get("content").is_none())
627        );
628        assert_eq!(
629            merged[0]["content_blocks"],
630            serde_json::json!([
631                {"type": "tool_result", "tool_use_id": "c1", "content": "result"},
632                {"type": "text", "text": "one"},
633                {"type": "text", "text": "two"}
634            ])
635        );
636        assert_eq!(merged[0]["task"], "action");
637        assert_eq!(merged[0]["wo_eos"], true);
638        assert_eq!(merged[0]["mask"], serde_json::json!([1, 0]));
639        assert_eq!(merged[1]["content_blocks"][0]["text"], "separate");
640    }
641
642    #[test]
643    fn test_extract_visible_text_from_content_array() {
644        let content = json!([
645            {"type": "text", "text": "who "},
646            {"type": "text", "text": "are "},
647            {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
648            {"type": "text", "text": "you?"}
649        ]);
650        assert_eq!(extract_visible_text(&content), "who are you?");
651    }
652
653    #[test]
654    fn test_render_tools_preserves_placeholder_text_inside_tool_schema() {
655        let tools = json!([{
656            "type": "function",
657            "function": {
658                "name": "placeholder_tool",
659                "description": "literal {dsml_token} {thinking_start_token} {thinking_end_token}",
660                "parameters": {"type": "object", "properties": {}}
661            }
662        }]);
663        let rendered = render_tools(
664            "static {dsml_token} {thinking_start_token} {thinking_end_token}\n{tool_schemas}",
665            tools.as_array().unwrap(),
666        );
667
668        assert!(rendered.starts_with(&format!(
669            "static {} {} {}\n",
670            tokens::DSML_TOKEN,
671            tokens::THINKING_START,
672            tokens::THINKING_END
673        )));
674        assert!(
675            rendered.contains("literal {dsml_token} {thinking_start_token} {thinking_end_token}")
676        );
677    }
678}