Tercet-R-1.0 / tiny_gdn /tools.py
kerzgrr's picture
Upload Tercet-R-1.0 (instruct EMA @ step 4500)
48eb149 verified
Raw
History Blame Contribute Delete
16.5 kB
"""Nemotron Cascade-2 tool protocol (XML in ChatML content).
Tercet-R is trained on `nvidia/Nemotron-Cascade-2-SFT-Data`, which inlines
available tools, calls, and results as `<tools>` / `<tool_call>` /
`<tool_response>` in message text. That is not OpenAI `tool_calls` JSON and
not this tokenizer's unused `<|tool_call|>` / `<|tool_response|>` specials.
This module matches NVIDIA's Cascade-2 chat template:
https://huggingface.co/nvidia/Nemotron-Cascade-2-30B-A3B/blob/main/chat_template.jinja
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
TOOL_CALL_OPEN = "<tool_call>"
TOOL_CALL_CLOSE = "</tool_call>"
TOOL_RESPONSE_OPEN = "<tool_response>"
TOOL_RESPONSE_CLOSE = "</tool_response>"
TOOLS_OPEN = "<tools>"
TOOLS_CLOSE = "</tools>"
THINK_OPEN = "<think>"
THINK_CLOSE = "</think>"
CHATML_START_RE = re.compile(r"^<\|im_start\|>[A-Za-z]+\n")
CHATML_END_RE = re.compile(r"\n?<\|im_end\|>\s*$")
TOOL_CALL_BLOCK_RE = re.compile(
rf"{re.escape(TOOL_CALL_OPEN)}(.*?){re.escape(TOOL_CALL_CLOSE)}",
re.DOTALL,
)
FUNCTION_BLOCK_RE = re.compile(
r"<function=([^>\s]+)>(.*?)</function>",
re.DOTALL,
)
PARAMETER_BLOCK_RE = re.compile(
r"<parameter=([^>\s]+)>\n?(.*?)\n?</parameter>",
re.DOTALL,
)
FUNCTION_NAME_RE = re.compile(r"<function=([^>\s]+)")
INFERENCE_ROLE_MAP = {
"system": "system",
"user": "user",
"human": "user",
"assistant": "assistant",
"gpt": "assistant",
"tool": "tool",
"function": "tool",
}
TOOLS_PREAMBLE = "# Tools\n\nYou have access to the following functions:\n\n"
TOOL_CALL_INSTRUCTIONS = (
"\n\nIf you choose to call a function ONLY reply in the following format "
"with NO suffix:\n\n"
"<tool_call>\n"
"<function=example_function_name>\n"
"<parameter=example_parameter_1>\n"
"value_1\n"
"</parameter>\n"
"<parameter=example_parameter_2>\n"
"This is the value for the second parameter\n"
"that can span\n"
"multiple lines\n"
"</parameter>\n"
"</function>\n"
"</tool_call>\n\n"
"<IMPORTANT>\n"
"Reminder:\n"
"- Function calls MUST follow the specified format: an inner "
"<function=...></function> block must be nested within "
"<tool_call></tool_call> 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"
"</IMPORTANT>"
)
@dataclass(frozen=True)
class ParsedToolCall:
name: str
arguments: dict[str, Any]
raw: str
def message_text(raw_content: Any) -> str:
if raw_content is None:
return ""
if isinstance(raw_content, str):
return raw_content
if isinstance(raw_content, list):
parts: list[str] = []
for item in raw_content:
if isinstance(item, str):
parts.append(item)
continue
if not isinstance(item, dict):
continue
part_type = item.get("type")
if part_type in {None, "text", "input_text", "output_text"}:
text = item.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
return str(raw_content)
def strip_leaked_chatml(content: str) -> str:
text = content.strip()
while True:
match = CHATML_START_RE.match(text)
if match is None:
break
text = text[match.end() :]
text = CHATML_END_RE.sub("", text)
return text.strip()
def has_tools_block(content: str) -> bool:
return TOOLS_OPEN in content
def wrap_tool_response(content: str) -> str:
text = strip_leaked_chatml(content)
if TOOL_RESPONSE_OPEN in text:
return text
return f"{TOOL_RESPONSE_OPEN}\n\n\n{text}\n{TOOL_RESPONSE_CLOSE}"
def _xml_value(value: Any) -> str:
if isinstance(value, dict) or (
isinstance(value, (list, tuple)) and not isinstance(value, (str, bytes))
):
return json.dumps(value, ensure_ascii=False)
if value is True or value is False or value is None:
return str(value)
return str(value)
def _render_extra_keys(payload: dict[str, Any], handled: set[str]) -> str:
chunks: list[str] = []
for key, value in payload.items():
if key in handled:
continue
chunks.append(f"\n<{key}>{_xml_value(value)}</{key}>")
return "".join(chunks)
def _unwrap_tool(raw_tool: Any) -> dict[str, Any]:
if not isinstance(raw_tool, dict):
raise ValueError("Each tool must be an object")
if isinstance(raw_tool.get("function"), dict):
tool = dict(raw_tool["function"])
else:
tool = dict(raw_tool)
name = tool.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("Tool is missing a function name")
tool["name"] = name.strip()
return tool
def coerce_tools(raw_tools: Any) -> list[dict[str, Any]]:
if raw_tools is None:
return []
if isinstance(raw_tools, str):
text = raw_tools.strip()
if not text:
return []
raw_tools = json.loads(text)
if isinstance(raw_tools, dict):
raw_tools = [raw_tools]
if not isinstance(raw_tools, list):
raise ValueError("tools must be a list of function specs")
return [_unwrap_tool(item) for item in raw_tools]
def render_function_schema(tool: dict[str, Any]) -> str:
chunks = [f"\n<function>\n<name>{tool['name']}</name>"]
description = tool.get("description")
if isinstance(description, str) and description.strip():
chunks.append(f"\n<description>{description.strip()}</description>")
chunks.append("\n<parameters>")
parameters = tool.get("parameters")
properties: dict[str, Any] = {}
if isinstance(parameters, dict):
raw_properties = parameters.get("properties")
if isinstance(raw_properties, dict):
properties = raw_properties
for param_name, raw_fields in properties.items():
fields = raw_fields if isinstance(raw_fields, dict) else {}
chunks.append("\n<parameter>")
chunks.append(f"\n<name>{param_name}</name>")
if "type" in fields:
chunks.append(f"\n<type>{_xml_value(fields['type'])}</type>")
if isinstance(fields.get("description"), str) and fields["description"].strip():
chunks.append(
f"\n<description>{fields['description'].strip()}</description>"
)
if "enum" in fields:
chunks.append(f"\n<enum>{_xml_value(fields['enum'])}</enum>")
chunks.append(
_render_extra_keys(
fields,
{"name", "type", "description", "enum"},
)
)
chunks.append("\n</parameter>")
chunks.append(
_render_extra_keys(parameters, {"type", "properties", "required"})
)
if "required" in parameters:
chunks.append(f"\n<required>{_xml_value(parameters['required'])}</required>")
chunks.append("\n</parameters>")
chunks.append(
_render_extra_keys(
tool,
{"type", "name", "description", "parameters"},
)
)
chunks.append("\n</function>")
return "".join(chunks)
def render_available_tools(raw_tools: Any) -> str:
tools = coerce_tools(raw_tools)
if not tools:
return ""
body = "".join(render_function_schema(tool) for tool in tools)
return (
f"{TOOLS_PREAMBLE}{TOOLS_OPEN}{body}\n{TOOLS_CLOSE}"
f"{TOOL_CALL_INSTRUCTIONS}"
)
def inject_available_tools(system_content: str, raw_tools: Any) -> str:
block = render_available_tools(raw_tools)
if not block:
return system_content
if has_tools_block(system_content):
return system_content
if not system_content.strip():
return block
return f"{system_content.rstrip()}\n\n{block}"
def json_ready(value: Any) -> Any:
"""Coerce Python literals (sets, tuples) into JSON-serialisable values.
Some SFT sources store tool arguments as Python literals. `ast.literal_eval`
turns `{1, 2}` into a `set`, which later `json.dumps` calls reject.
"""
if isinstance(value, dict):
return {str(key): json_ready(item) for key, item in value.items()}
if isinstance(value, (set, frozenset)):
items = [json_ready(item) for item in value]
try:
return sorted(
items,
key=lambda item: json.dumps(item, sort_keys=True, default=str),
)
except TypeError:
return items
if isinstance(value, tuple):
return [json_ready(item) for item in value]
if isinstance(value, list):
return [json_ready(item) for item in value]
return value
def parse_argument_value(raw: str) -> Any:
text = raw.strip()
if not text:
return ""
try:
return json.loads(text)
except json.JSONDecodeError:
return text
def parse_arguments_payload(raw: Any) -> dict[str, Any]:
if raw is None:
return {}
ready = json_ready(raw)
if isinstance(ready, dict):
return ready
if isinstance(ready, str):
text = ready.strip()
if not text:
return {}
try:
loaded = json.loads(text)
except json.JSONDecodeError:
return {"value": ready}
loaded = json_ready(loaded)
if isinstance(loaded, dict):
return loaded
return {"value": loaded}
return {"value": ready}
def format_tool_call_xml(name: str, arguments: dict[str, Any]) -> str:
chunks = [f"{TOOL_CALL_OPEN}\n<function={name}>\n"]
for key, value in arguments.items():
chunks.append(f"<parameter={key}>\n{_xml_value(value)}\n</parameter>\n")
chunks.append(f"</function>\n{TOOL_CALL_CLOSE}\n")
return "".join(chunks)
def format_tool_calls_xml(raw_tool_calls: Any) -> str:
if not raw_tool_calls:
return ""
if not isinstance(raw_tool_calls, list):
raise ValueError("tool_calls must be a list")
chunks: list[str] = []
for raw_call in raw_tool_calls:
if not isinstance(raw_call, dict):
raise ValueError("Each tool_call must be an object")
payload = raw_call.get("function") if isinstance(raw_call.get("function"), dict) else raw_call
if not isinstance(payload, dict):
raise ValueError("tool_call is missing a function object")
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("tool_call is missing a function name")
arguments = parse_arguments_payload(payload.get("arguments"))
chunks.append(format_tool_call_xml(name.strip(), arguments))
return "".join(chunks)
def parse_json_tool_inner(inner: str) -> ParsedToolCall | None:
text = inner.strip()
if not text:
return None
payload: Any
try:
payload = json.loads(text)
except json.JSONDecodeError:
try:
payload = json.loads(text.replace("'", '"'))
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
nested = payload.get("function")
source = nested if isinstance(nested, dict) else payload
name = source.get("name")
if not isinstance(name, str) or not name.strip():
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
return None
arguments = parse_arguments_payload(
source.get("arguments", source.get("parameters", payload.get("arguments")))
)
return ParsedToolCall(name=name.strip(), arguments=arguments, raw="")
def parse_tool_calls(text: str) -> list[ParsedToolCall]:
calls: list[ParsedToolCall] = []
for block in TOOL_CALL_BLOCK_RE.finditer(text):
inner = block.group(1)
raw = block.group(0).strip()
found_xml = False
for function in FUNCTION_BLOCK_RE.finditer(inner):
found_xml = True
name = function.group(1).strip()
arguments: dict[str, Any] = {}
for parameter in PARAMETER_BLOCK_RE.finditer(function.group(2)):
arguments[parameter.group(1).strip()] = parse_argument_value(
parameter.group(2)
)
calls.append(
ParsedToolCall(
name=name,
arguments=json_ready(arguments),
raw=raw,
)
)
if found_xml:
continue
parsed = parse_json_tool_inner(inner)
if parsed is not None:
calls.append(
ParsedToolCall(name=parsed.name, arguments=parsed.arguments, raw=raw)
)
return calls
def openai_tool_calls_from_text(text: str) -> list[dict[str, Any]]:
encoded: list[dict[str, Any]] = []
for index, call in enumerate(parse_tool_calls(text)):
encoded.append(
{
"id": f"call_{index}_{call.name}",
"type": "function",
"function": {
"name": call.name,
"arguments": json.dumps(call.arguments, ensure_ascii=False),
},
}
)
return encoded
def assistant_message_content(message: dict[str, Any]) -> str:
reasoning = message.get("reasoning_content")
content = message_text(message.get("content"))
if isinstance(reasoning, str) and reasoning.strip():
content = f"{THINK_OPEN}\n{reasoning.strip()}\n{THINK_CLOSE}\n{content}"
tool_xml = format_tool_calls_xml(message.get("tool_calls"))
if tool_xml:
if content.strip():
return f"{content.rstrip()}\n{tool_xml}"
return tool_xml
return content
def _flush_tool_group(
group: list[str],
messages: list[dict[str, str]],
) -> None:
if not group:
return
messages.append({"role": "user", "content": "\n".join(group)})
group.clear()
def prepare_inference_messages(
raw_messages: list[dict[str, Any]],
*,
tools: Any | None = None,
) -> list[dict[str, str]]:
if not raw_messages:
raise ValueError("Chat history cannot be empty")
prepared: list[dict[str, str]] = []
pending_tool_results: list[str] = []
for index, raw_message in enumerate(raw_messages):
if not isinstance(raw_message, dict):
raise ValueError(f"Unsupported chat message at index {index}")
raw_role = raw_message.get("role")
if not isinstance(raw_role, str):
raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
role = INFERENCE_ROLE_MAP.get(raw_role.strip().lower())
if role is None:
raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
if role == "assistant":
content = assistant_message_content(raw_message)
else:
content = message_text(raw_message.get("content"))
if role == "tool":
content = wrap_tool_response(content)
if not content.strip():
raise ValueError(f"Chat content at index {index} must be non-empty")
pending_tool_results.append(content)
continue
_flush_tool_group(pending_tool_results, prepared)
if not content.strip():
if role == "system":
continue
raise ValueError(f"Chat content at index {index} must be non-empty")
if role == "system":
content = strip_leaked_chatml(content)
prepared.append({"role": role, "content": content})
_flush_tool_group(pending_tool_results, prepared)
tools_block = render_available_tools(tools)
if tools_block:
if prepared and prepared[0]["role"] == "system":
prepared[0] = {
"role": "system",
"content": inject_available_tools(prepared[0]["content"], tools),
}
else:
prepared.insert(0, {"role": "system", "content": tools_block})
if not prepared:
raise ValueError("Chat history cannot be empty")
return prepared