Fix: Handle empty func_name and None values in GLM MoE detectors (#15754)

Signed-off-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
This commit is contained in:
Leoyzen
2025-12-31 06:32:35 +08:00
committed by GitHub
parent 3dfff6ae3c
commit bc8b526eda
4 changed files with 1512 additions and 139 deletions

View File

@@ -40,15 +40,27 @@ def get_argument_type(
The type string (e.g., 'string', 'number', 'object') or None if not found
"""
name2tool = {tool.function.name: tool for tool in defined_tools}
if func_name not in name2tool:
# Check if function exists
tool = name2tool.get(func_name)
if not tool:
return None
tool = name2tool[func_name]
properties = (tool.function.parameters or {}).get("properties", {})
# Get parameters safely using getattr
params = getattr(tool.function, "parameters", None)
if not isinstance(params, dict):
return None
# Navigate to the type using dict.get() for safe access
properties = params.get("properties")
if not isinstance(properties, dict):
properties = {}
if arg_key not in properties:
return None
return properties[arg_key].get("type", None)
arg_spec = properties.get(arg_key)
if isinstance(arg_spec, dict):
return arg_spec.get("type")
return None
def _convert_to_number(value: str) -> Any:
@@ -143,6 +155,10 @@ class Glm47MoeDetector(BaseFormatDetector):
self.current_tool_id = -1
self.current_tool_name_sent = False
self._streamed_raw_length = 0
self._tool_call_completed = False # Track if tool call has been completed
self._sent_empty_object = (
False # Track if empty object has been sent for no-arg functions
)
self._reset_streaming_state()
def _reset_streaming_state(self) -> None:
@@ -156,6 +172,8 @@ class Glm47MoeDetector(BaseFormatDetector):
self._cached_value_type: Optional[str] = (
None # Cache the value type for consistency
)
self._tool_call_completed = False # Reset tool call completion status
self._sent_empty_object = False # Reset empty object sent status
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a glm-4.5 / glm-4.6 format tool call."""
@@ -169,18 +187,38 @@ class Glm47MoeDetector(BaseFormatDetector):
:param tools: List of available tools.
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
return StreamingParseResult(normal_text=text, calls=[])
# Extract all normal text (before, between, and after tool calls)
normal_text_parts = []
last_end = 0
# Find all tool call matches
for match in re.finditer(self.func_call_regex, text, re.DOTALL):
# Add text before this tool call
if match.start() > last_end:
normal_text_parts.append(text[last_end : match.start()])
last_end = match.end()
# Add any remaining text after the last tool call
if last_end < len(text):
normal_text_parts.append(text[last_end:])
# Combine all normal text parts
normal_text = "".join(normal_text_parts).strip()
# Parse tool calls
match_result_list = re.findall(self.func_call_regex, text, re.DOTALL)
calls = []
try:
for match_result in match_result_list:
# Get function name
func_detail = self.func_detail_regex.search(match_result)
func_name = func_detail.group(1)
func_args = func_detail.group(2)
if func_detail is None:
continue
func_name = func_detail.group(1) if func_detail.group(1) else ""
func_args = func_detail.group(2) if func_detail.group(2) else ""
arguments = {}
if func_args:
pairs = self.func_arg_regex.findall(func_args)
@@ -212,7 +250,11 @@ class Glm47MoeDetector(BaseFormatDetector):
return arg_type
# Auto-detect type from value (best effort)
first_chars = self._current_value.strip()[:10] if self._current_value else ""
first_chars = (
self._current_value.strip()[:10]
if self._current_value and self._current_value.strip()
else ""
)
if first_chars:
first_char = first_chars[0]
if first_char.isdigit() or first_char in ["-", "."]:
@@ -237,14 +279,14 @@ class Glm47MoeDetector(BaseFormatDetector):
return json.dumps(value, ensure_ascii=False)
elif value_type == "number":
try:
num = _convert_to_number(value.strip())
num = _convert_to_number(value.strip() if value else "")
return str(num)
except (ValueError, AttributeError):
# Fallback to string if not a valid number
logger.warning(
f"Failed to parse '{value}' as number, treating as string"
)
return json.dumps(str(value), ensure_ascii=False)
return json.dumps(str(value) if value else "", ensure_ascii=False)
else:
# For object/array types, return as-is (should already be valid JSON)
return value
@@ -369,6 +411,179 @@ class Glm47MoeDetector(BaseFormatDetector):
return json_output
def _extract_match_groups(self, match: re.Match) -> tuple[str, str, str]:
"""Extract function name, arguments and end marker from regex match.
Args:
match: Regex match object
Returns:
(func_name, func_args_raw, is_tool_end)
"""
func_name = match.group(1).strip()
func_args_raw = match.group(2).strip() if match.group(2) else ""
is_tool_end = match.group(3) or ""
return func_name, func_args_raw, is_tool_end
def _send_tool_name_if_needed(
self, func_name: str, has_arg_key: bool, is_tool_end: str
) -> Optional[ToolCallItem]:
"""Send tool name if needed.
Args:
func_name: Function name
has_arg_key: Whether current text contains <arg_key
is_tool_end: Whether end marker is encountered
Returns:
Tool call item or None
"""
if self.current_tool_name_sent:
return None
# Function name completeness check
is_func_name_complete = has_arg_key or is_tool_end == self.eot_token
if not is_func_name_complete:
return None
if not func_name:
logger.warning("Empty function name detected, skipping tool call")
return None
# Send tool name
self.current_tool_name_sent = True
self._streamed_raw_length = 0
self._reset_streaming_state()
# Record tool info
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
return ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
def _process_arguments_streaming(
self, func_name: str, func_args_raw: str, tools: List[Tool]
) -> Optional[ToolCallItem]:
"""Process streaming arguments.
Args:
func_name: Function name
func_args_raw: Raw argument string
tools: List of available tools
Returns:
Tool call item with parameter updates or None
"""
current_raw_length = len(func_args_raw)
if current_raw_length <= self._streamed_raw_length:
return None
# Get new raw XML content
raw_increment = func_args_raw[self._streamed_raw_length :]
# Convert XML to JSON using state machine
json_increment = self._process_xml_to_json_streaming(
raw_increment, func_name, tools
)
# CRITICAL: Update streamed length BEFORE early return
# Even if json_increment is empty, the input has been consumed by the state machine
self._streamed_raw_length = current_raw_length
if not json_increment:
return None
# Update state
self._last_arguments += json_increment
self.streamed_args_for_tool[self.current_tool_id] += json_increment
return ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=json_increment,
)
def _finalize_tool_call(
self,
func_name: str,
func_args_raw: str,
tools: List[Tool],
match_end_pos: int,
current_text: str,
) -> List[ToolCallItem]:
"""Complete tool call processing.
Args:
func_name: Function name
func_args_raw: Raw argument string
tools: List of available tools
match_end_pos: Match end position
current_text: Current text
Returns:
List of tool call items to add
"""
calls = []
# Handle no-arg function or need to close braces
if self._is_first_param and not self._sent_empty_object:
# No-arg function
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters="{}",
)
)
self._last_arguments += "{}"
self.streamed_args_for_tool[self.current_tool_id] += "{}"
self._sent_empty_object = True
elif not self._last_arguments.endswith("}") and not self._sent_empty_object:
# Need to close brace
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters="}",
)
)
self._last_arguments += "}"
self.streamed_args_for_tool[self.current_tool_id] += "}"
self._sent_empty_object = True
# Parse final arguments
if func_args_raw:
try:
pairs = self.func_arg_regex.findall(func_args_raw)
if pairs:
arguments = self._parse_argument_pairs(pairs, func_name, tools)
self.prev_tool_call_arr[self.current_tool_id][
"arguments"
] = arguments
except Exception as e:
logger.debug(f"Failed to parse arguments: {e}", exc_info=True)
# Clean buffer
self._buffer = current_text[match_end_pos:]
# Reset state for next tool call
self._tool_call_completed = True
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
self._streamed_raw_length = 0
self._reset_streaming_state()
return calls
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
@@ -405,140 +620,96 @@ class Glm47MoeDetector(BaseFormatDetector):
# Could be start of tool call, keep buffering
return StreamingParseResult(normal_text="", calls=[])
# Extract any text before the first bot_token and return it as normal_text
normal_text = ""
first_bot_token_idx = current_text.find(self.bot_token)
if first_bot_token_idx > 0:
normal_text = current_text[:first_bot_token_idx]
current_text = current_text[first_bot_token_idx:]
# Update buffer to only include from the bot token onwards
self._buffer = current_text
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
calls: list[ToolCallItem] = []
try:
# Try to match a partial or complete tool call
# Use a single flexible regex pattern that handles all cases
partial_match = re.search(
pattern=r"<tool_call>(.*?)(<arg_key>.*?)?(</tool_call>|$)",
string=current_text,
flags=re.DOTALL,
r"<tool_call>(.*?)(?:(<arg_key.*?))?(?:(</tool_call>)|$)",
current_text,
re.DOTALL,
)
if partial_match:
func_name = partial_match.group(1).strip()
func_args_raw = partial_match.group(2).strip()
is_tool_end = partial_match.group(3)
# Initialize state if this is the first tool call
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
self._streamed_raw_length = 0
self.current_tool_name_sent = False
self._reset_streaming_state()
if not partial_match:
return StreamingParseResult(normal_text=normal_text, calls=[])
# Ensure we have enough entries in our tracking arrays
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Extract match groups using helper method
func_name, func_args_raw, is_tool_end = self._extract_match_groups(
partial_match
)
# Send tool name first if not sent yet
if not self.current_tool_name_sent:
assert func_name, "func_name should not be empty"
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
# Initialize tool call state if needed (keeping existing logic)
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
self._streamed_raw_length = 0
self.current_tool_name_sent = False # Reset for new tool call
self._reset_streaming_state()
# Check if this is a continuation of an existing tool call or a new one
elif not self.current_tool_name_sent:
# Only increment tool_id if we're truly starting a NEW tool call
# Don't increment if this is just the first time we're processing
# a tool call that was received in the buffer
# The key insight: only increment when we've COMPLETED a previous tool call
# and now see another bot_token in new_text
pass # Remove the problematic auto-increment logic
# Ensure tracking arrays are large enough (keeping existing logic)
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Determine if function name is complete by checking for <arg_key> in the full text
# This is important for streaming scenarios where args come in later chunks
has_arg_key = "<arg_key" in current_text
# Send tool name if needed
tool_name_item = self._send_tool_name_if_needed(
func_name, has_arg_key, is_tool_end
)
if tool_name_item:
calls.append(tool_name_item)
# Process streaming arguments if tool name has been sent
if self.current_tool_name_sent:
arg_item = self._process_arguments_streaming(
func_name, func_args_raw, tools
)
if arg_item:
calls.append(arg_item)
# Finalize tool call if end token is encountered
if is_tool_end == self.eot_token and not self._tool_call_completed:
finalize_calls = self._finalize_tool_call(
func_name,
func_args_raw,
tools,
partial_match.end(),
current_text,
)
self.current_tool_name_sent = True
self._streamed_raw_length = 0
self._reset_streaming_state()
# Store the tool call info
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
else:
# Process XML to JSON streaming
current_raw_length = len(func_args_raw)
if current_raw_length > self._streamed_raw_length:
# Get the new raw XML content
raw_increment = func_args_raw[self._streamed_raw_length :]
# Convert XML increment to JSON increment using state machine
json_increment = self._process_xml_to_json_streaming(
raw_increment, func_name, tools
)
if json_increment:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=json_increment,
)
)
self._last_arguments += json_increment
self.streamed_args_for_tool[
self.current_tool_id
] += json_increment
# Update the streamed length
self._streamed_raw_length = current_raw_length
if is_tool_end == self.eot_token:
if self._is_first_param:
empty_object = "{}"
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=empty_object,
)
)
self._last_arguments += empty_object
elif not self._last_arguments.endswith("}"):
closing_brace = "}"
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=closing_brace,
)
)
self._last_arguments += closing_brace
self.streamed_args_for_tool[
self.current_tool_id
] += closing_brace
try:
pairs = self.func_arg_regex.findall(func_args_raw)
if pairs:
arguments = self._parse_argument_pairs(
pairs, func_name, tools
)
self.prev_tool_call_arr[self.current_tool_id][
"arguments"
] = arguments
except Exception as e:
logger.debug(
f"Failed to parse arguments: {e}", exc_info=True
)
# Remove the completed tool call from buffer
self._buffer = current_text[partial_match.end(3) :]
result = StreamingParseResult(normal_text="", calls=calls)
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
self._streamed_raw_length = 0
self._reset_streaming_state()
return result
return StreamingParseResult(normal_text="", calls=calls)
calls.extend(finalize_calls)
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}", exc_info=True)
return StreamingParseResult(normal_text=current_text)
return StreamingParseResult(normal_text=normal_text, calls=calls)
def _parse_argument_pairs(
self, pairs: List[Tuple[str, str]], func_name: str, tools: List[Tool]
) -> Dict[str, Any]:

View File

@@ -189,8 +189,10 @@ class Glm4MoeDetector(BaseFormatDetector):
for match_result in match_result_list:
# Get function name
func_detail = self.func_detail_regex.search(match_result)
func_name = func_detail.group(1)
func_args = func_detail.group(2)
if func_detail is None:
continue
func_name = func_detail.group(1) if func_detail.group(1) else ""
func_args = func_detail.group(2) if func_detail.group(2) else ""
pairs = self.func_arg_regex.findall(func_args)
# Parse arguments using shared method
@@ -426,10 +428,19 @@ class Glm4MoeDetector(BaseFormatDetector):
flags=re.DOTALL,
)
if partial_match:
func_name = partial_match.group(1).strip()
func_args_raw = partial_match.group(2).strip()
func_name_raw = partial_match.group(1)
func_args_raw = partial_match.group(2)
is_tool_end = partial_match.group(3)
# Only proceed if we have a non-empty function name
if func_name_raw is None or not func_name_raw.strip():
# If we only have the start token without a function name,
# continue buffering until we get more content
return StreamingParseResult(normal_text="", calls=[])
func_name = func_name_raw.strip()
func_args_raw = func_args_raw.strip() if func_args_raw else ""
# Initialize state if this is the first tool call
if self.current_tool_id == -1:
self.current_tool_id = 0
@@ -447,7 +458,6 @@ class Glm4MoeDetector(BaseFormatDetector):
# Send tool name first if not sent yet
if not self.current_tool_name_sent:
assert func_name, "func_name should not be empty"
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
@@ -476,6 +486,10 @@ class Glm4MoeDetector(BaseFormatDetector):
raw_increment, func_name, tools
)
# CRITICAL: Update streamed length BEFORE checking json_increment
# Even if json_increment is empty, the input has been consumed by the state machine
self._streamed_raw_length = current_raw_length
if json_increment:
calls.append(
ToolCallItem(
@@ -489,9 +503,6 @@ class Glm4MoeDetector(BaseFormatDetector):
self.current_tool_id
] += json_increment
# Update the streamed length
self._streamed_raw_length = current_raw_length
if is_tool_end == self.eot_token:
if self._is_first_param:
empty_object = "{}"