diff --git a/common/chat.cpp b/common/chat.cpp
index f863e4490c..c3f476c934 100644
--- a/common/chat.cpp
+++ b/common/chat.cpp
@@ -1460,8 +1460,6 @@ static common_chat_params common_chat_params_init_nemotron_v3(const common_chat_
auto reasoning_content = p.reasoning(p.until("")) + ("" | p.end());
if (data.thinking_forced_open) {
reasoning = reasoning_content;
- } else {
- reasoning = p.optional("" + reasoning_content);
}
}
diff --git a/models/templates/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.jinja b/models/templates/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.jinja
new file mode 100644
index 0000000000..a01e0861c6
--- /dev/null
+++ b/models/templates/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.jinja
@@ -0,0 +1,204 @@
+{% macro render_extra_keys(json_dict, handled_keys) %}
+ {%- if json_dict is mapping %}
+ {%- for json_key in json_dict if json_key not in handled_keys %}
+ {%- if json_dict[json_key] is mapping or (json_dict[json_key] is sequence and json_dict[json_key] is not string) %}
+ {{- '\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | tojson | safe) ~ '' ~ json_key ~ '>' }}
+ {%- else %}
+ {{-'\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | string) ~ '' ~ json_key ~ '>' }}
+ {%- endif %}
+ {%- endfor %}
+ {%- endif %}
+{% endmacro %}
+{%- set enable_thinking = enable_thinking if enable_thinking is defined else True %}
+{%- set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
+
+{%- set ns = namespace(last_user_idx = -1) %}
+{%- set loop_messages = messages %}
+{%- for m in loop_messages %}
+ {%- if m["role"] == "user" %}
+ {%- set ns.last_user_idx = loop.index0 %}
+ {%- endif %}
+{%- endfor %}
+
+{%- if messages[0]["role"] == "system" %}
+ {%- set system_message = messages[0]["content"] %}
+ {%- set loop_messages = messages[1:] %}
+{%- else %}
+ {%- set system_message = "" %}
+ {%- set loop_messages = messages %}
+{%- endif %}
+{%- if not tools is defined %}
+ {%- set tools = [] %}
+{%- endif %}
+{# Recompute last_user_idx relative to loop_messages after handling system #}
+{%- set ns = namespace(last_user_idx = -1) %}
+{%- for m in loop_messages %}
+ {%- if m["role"] == "user" %}
+ {%- set ns.last_user_idx = loop.index0 %}
+ {%- endif %}
+{%- endfor %}
+{%- if system_message is defined %}
+ {{- "<|im_start|>system\n" + system_message }}
+{%- else %}
+ {%- if tools is iterable and tools | length > 0 %}
+ {{- "<|im_start|>system\n" }}
+ {%- endif %}
+{%- endif %}
+{%- if tools is iterable and tools | length > 0 %}
+ {%- if system_message is defined and system_message | length > 0 %}
+ {{- "\n\n" }}
+ {%- endif %}
+ {{- "# Tools\n\nYou have access to the following functions:\n\n" }}
+ {{- "" }}
+ {%- for tool in tools %}
+ {%- if tool.function is defined %}
+ {%- set tool = tool.function %}
+ {%- endif %}
+ {{- "\n\n" ~ tool.name ~ "" }}
+ {%- if tool.description is defined %}
+ {{- '\n' ~ (tool.description | trim) ~ '' }}
+ {%- endif %}
+ {{- '\n' }}
+ {%- if tool.parameters is defined and tool.parameters is mapping and tool.parameters.properties is defined and tool.parameters.properties is mapping %}
+ {%- for param_name, param_fields in tool.parameters.properties|items %}
+ {{- '\n' }}
+ {{- '\n' ~ param_name ~ '' }}
+ {%- if param_fields.type is defined %}
+ {{- '\n' ~ (param_fields.type | string) ~ '' }}
+ {%- endif %}
+ {%- if param_fields.description is defined %}
+ {{- '\n' ~ (param_fields.description | trim) ~ '' }}
+ {%- endif %}
+ {%- if param_fields.enum is defined %}
+ {{- '\n' ~ (param_fields.enum | tojson | safe) ~ '' }}
+ {%- endif %}
+ {%- set handled_keys = ['name', 'type', 'description', 'enum'] %}
+ {{- render_extra_keys(param_fields, handled_keys) }}
+ {{- '\n' }}
+ {%- endfor %}
+ {%- endif %}
+ {% set handled_keys = ['type', 'properties', 'required'] %}
+ {{- render_extra_keys(tool.parameters, handled_keys) }}
+ {%- if tool.parameters is defined and tool.parameters.required is defined %}
+ {{- '\n' ~ (tool.parameters.required | tojson | safe) ~ '' }}
+ {%- endif %}
+ {{- '\n' }}
+ {%- set handled_keys = ['type', 'name', 'description', 'parameters'] %}
+ {{- render_extra_keys(tool, handled_keys) }}
+ {{- '\n' }}
+ {%- endfor %}
+ {{- "\n" }}
+
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n\n\n\nvalue_1\n\n\nThis is the value for the second parameter\nthat can span\nmultiple lines\n\n\n\n\n\nReminder:\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n' }}
+{%- endif %}
+
+
+{%- if system_message is defined %}
+ {{- '<|im_end|>\n' }}
+{%- else %}
+ {%- if tools is iterable and tools | length > 0 %}
+ {{- '<|im_end|>\n' }}
+ {%- endif %}
+{%- endif %}
+
+{%- for message in loop_messages %}
+ {%- if message.role == "assistant" %}
+ {# Add reasoning content in to content field for unified processing below. #}
+ {%- if message.reasoning_content is defined and message.reasoning_content is string and message.reasoning_content | trim | length > 0 %}
+ {%- set content = "\n" ~ message.reasoning_content ~ "\n\n" ~ (message.content | default('', true)) %}
+ {%- else %}
+ {%- set content = message.content | default('', true) %}
+ {%- if content is string -%}
+ {# Allow downstream logic to to take care of broken thought, only handle coherent reasoning here. #}
+ {%- if '' not in content and '' not in content -%}
+ {%- set content = "" ~ content -%}
+ {%- endif -%}
+ {%- else -%}
+ {%- set content = content -%}
+ {%- endif -%}
+ {%- endif %}
+ {%- if message.tool_calls is defined and message.tool_calls is iterable and message.tool_calls | length > 0 %}
+ {# Assistant message has tool calls. #}
+ {{- '<|im_start|>assistant\n' }}
+ {%- set include_content = not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %}
+ {%- if content is string and content | trim | length > 0 %}
+ {%- if include_content %}
+ {{- (content | trim) ~ '\n' -}}
+ {%- else %}
+ {%- set c = (content | string) %}
+ {%- if '' in c %}
+ {# Keep only content after the last closing think. Also generation prompt causes this. #}
+ {%- set c = c.split('')[-1] %}
+ {%- elif '' in c %}
+ {# If was opened but never closed, drop the trailing think segment #}
+ {%- set c = c.split('')[0] %}
+ {%- endif %}
+ {%- set c = "" ~ c | trim %}
+ {%- if c | length > 0 %}
+ {{- c ~ '\n' -}}
+ {%- endif %}
+ {%- endif %}
+ {%- else %}
+ {{- "" -}}
+ {%- endif %}
+ {%- for tool_call in message.tool_calls %}
+ {%- if tool_call.function is defined %}
+ {%- set tool_call = tool_call.function %}
+ {%- endif %}
+ {{- '\n\n' -}}
+ {%- if tool_call.arguments is defined %}
+ {%- for args_name, args_value in tool_call.arguments|items %}
+ {{- '\n' -}}
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
+ {{- args_value ~ '\n\n' -}}
+ {%- endfor %}
+ {%- endif %}
+ {{- '\n\n' -}}
+ {%- endfor %}
+ {{- '<|im_end|>\n' }}
+ {%- else %}
+ {# Assistant message doesn't have tool calls. #}
+ {%- if not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %}
+ {{- '<|im_start|>assistant\n' ~ (content | default('', true) | string | trim) ~ '<|im_end|>\n' }}
+ {%- else %}
+ {%- set c = (content | default('', true) | string) %}
+ {%- if '' in c and '' in c %}
+ {%- set c = "" ~ c.split('')[-1] %}
+ {%- endif %}
+ {%- set c = c | trim %}
+ {%- if c | length > 0 %}
+ {{- '<|im_start|>assistant\n' ~ c ~ '<|im_end|>\n' }}
+ {%- else %}
+ {{- '<|im_start|>assistant\n<|im_end|>\n' }}
+ {%- endif %}
+ {%- endif %}
+ {%- endif %}
+ {%- elif message.role == "user" or message.role == "system" %}
+ {{- '<|im_start|>' + message.role + '\n' }}
+ {%- set content = message.content | string %}
+ {{- content }}
+ {{- '<|im_end|>\n' }}
+ {%- elif message.role == "tool" %}
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
+ {{- '<|im_start|>user\n' }}
+ {%- endif %}
+ {{- '\n' }}
+ {{- message.content }}
+ {{- '\n\n' }}
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
+ {{- '<|im_end|>\n' }}
+ {%- elif loop.last %}
+ {{- '<|im_end|>\n' }}
+ {%- endif %}
+ {%- else %}
+ {{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>\n' }}
+ {%- endif %}
+{%- endfor %}
+
+{%- if add_generation_prompt %}
+ {%- if enable_thinking %}
+ {{- '<|im_start|>assistant\n\n' }}
+ {%- else %}
+ {{- '<|im_start|>assistant\n' }}
+ {%- endif %}
+{%- endif %}
diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp
index 007929f517..02af5251cc 100644
--- a/tests/test-chat.cpp
+++ b/tests/test-chat.cpp
@@ -3588,6 +3588,163 @@ static void test_template_output_peg_parsers() {
t.expect.content =R"({"amount": 123.45, "date": "2025-12-03"})";
});
}
+
+ {
+ // NVIDIA Nemotron-3 Nano
+ auto tmpls = read_templates("models/templates/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.jinja");
+
+ // Test basic message
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input = "Hello, world!\nWhat's up?";
+ t.expect = message_assist;
+ });
+
+ // Test basic message and reasoning with reasoning_format = none
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input = "I'm\nthinking\n\nHello, world!\nWhat's up?";
+ t.expect.content = "I'm\nthinking\n\nHello, world!\nWhat's up?";
+ });
+
+ // Test basic message and reasoning with reasoning_format = auto
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input = "I'm\nthinking\n\nHello, world!\nWhat's up?";
+ t.params.enable_thinking = true;
+ t.params.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
+
+ t.expect = message_assist_thoughts;
+ });
+
+ // Test tool call
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input =
+ "\n"
+ "\n"
+ "\n"
+ "1\n"
+ "\n"
+ "\n"
+ "";
+ t.params.enable_thinking = false;
+ t.params.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
+ t.params.tools = {special_function_tool};
+
+ t.expect = message_assist_call;
+ });
+
+ // Test tool call with reasoning
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input =
+ "I'm\nthinking\n\n"
+ "\n"
+ "\n"
+ "\n"
+ "1\n"
+ "\n"
+ "\n"
+ "";
+ t.params.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
+ t.params.tools = {special_function_tool};
+
+ t.expect = message_assist_call_thoughts;
+ });
+
+ // Test parallel tool calls
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input =
+ "\n"
+ "\n"
+ "\n"
+ "1\n"
+ "\n"
+ "\n"
+ "\n"
+ "\n"
+ "\n"
+ "\n"
+ "1\n"
+ "\n"
+ "\n"
+ "2\n"
+ "\n"
+ "\n"
+ "";
+ t.params.enable_thinking = false;
+ t.params.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
+ t.params.parallel_tool_calls = true;
+ t.params.tools = {special_function_tool, special_function_tool_with_optional_param};
+
+ t.expect.tool_calls = {{
+ /* .name = */ "special_function",
+ /* .arguments = */ R"({"arg1": 1})",
+ /* .id = */ {},
+ }, {
+ /* .name = */ "special_function_with_opt",
+ /* .arguments = */ R"({"arg1": 1, "arg2": 2})",
+ /* .id = */ {},
+ }};
+ });
+
+ // Test tool call with string parameter
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input =
+ "\n"
+ "\n"
+ "\n"
+ "def hello():\n"
+ " print(\"Hello, world!\")\n"
+ "\n"
+ "hello()\n"
+ "\n"
+ "\n"
+ "";
+ t.params.enable_thinking = false;
+ t.params.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
+ t.params.tools = {python_tool};
+
+ t.expect.tool_calls = {{
+ /* .name = */ "python",
+ /* .arguments = */ "{\"code\": \"def hello():\\n print(\\\"Hello, world!\\\")\\n\\nhello()\"}",
+ /* .id = */ {},
+ }};
+ });
+
+ // Test tool call with string parameter and no closing tag
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input =
+ "\n"
+ "\n"
+ "\n"
+ "def hello():\n"
+ " print(\"Hello, world!\")\n"
+ "\n"
+ "hello()\n"
+ "\n"
+ "";
+ t.params.enable_thinking = false;
+ t.params.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
+ t.params.tools = {python_tool};
+
+ t.expect.tool_calls = {{
+ /* .name = */ "python",
+ /* .arguments = */ "{\"code\": \"def hello():\\n print(\\\"Hello, world!\\\")\\n\\nhello()\"}",
+ /* .id = */ {},
+ }};
+ });
+
+ // Test response format
+ test_peg_parser(tmpls.get(), [&](auto & t) {
+ t.input =
+ "I need to output the invoice details in JSON\n"
+ "\n"
+ R"({"amount": 123.45, "date": "2025-12-03"})";
+ t.params.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
+ t.params.json_schema = invoice_schema;
+
+ t.expect.reasoning_content = "I need to output the invoice details in JSON";
+ t.expect.content = R"({"amount": 123.45, "date": "2025-12-03"})";
+ });
+ }
+
}
static void test_msg_diffs_compute() {