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:
@@ -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]:
|
||||
|
||||
@@ -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 = "{}"
|
||||
|
||||
Reference in New Issue
Block a user