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:
co-authored by
Xinyuan Tong
Xinyuan Tong
parent
3dfff6ae3c
commit
bc8b526eda
@@ -40,15 +40,27 @@ def get_argument_type(
|
|||||||
The type string (e.g., 'string', 'number', 'object') or None if not found
|
The type string (e.g., 'string', 'number', 'object') or None if not found
|
||||||
"""
|
"""
|
||||||
name2tool = {tool.function.name: tool for tool in defined_tools}
|
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
|
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):
|
if not isinstance(properties, dict):
|
||||||
properties = {}
|
|
||||||
if arg_key not in properties:
|
|
||||||
return None
|
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:
|
def _convert_to_number(value: str) -> Any:
|
||||||
@@ -143,6 +155,10 @@ class Glm47MoeDetector(BaseFormatDetector):
|
|||||||
self.current_tool_id = -1
|
self.current_tool_id = -1
|
||||||
self.current_tool_name_sent = False
|
self.current_tool_name_sent = False
|
||||||
self._streamed_raw_length = 0
|
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()
|
self._reset_streaming_state()
|
||||||
|
|
||||||
def _reset_streaming_state(self) -> None:
|
def _reset_streaming_state(self) -> None:
|
||||||
@@ -156,6 +172,8 @@ class Glm47MoeDetector(BaseFormatDetector):
|
|||||||
self._cached_value_type: Optional[str] = (
|
self._cached_value_type: Optional[str] = (
|
||||||
None # Cache the value type for consistency
|
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:
|
def has_tool_call(self, text: str) -> bool:
|
||||||
"""Check if the text contains a glm-4.5 / glm-4.6 format tool call."""
|
"""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.
|
:param tools: List of available tools.
|
||||||
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
|
: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:
|
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)
|
match_result_list = re.findall(self.func_call_regex, text, re.DOTALL)
|
||||||
calls = []
|
calls = []
|
||||||
try:
|
try:
|
||||||
for match_result in match_result_list:
|
for match_result in match_result_list:
|
||||||
# Get function name
|
# Get function name
|
||||||
func_detail = self.func_detail_regex.search(match_result)
|
func_detail = self.func_detail_regex.search(match_result)
|
||||||
func_name = func_detail.group(1)
|
if func_detail is None:
|
||||||
func_args = func_detail.group(2)
|
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 = {}
|
arguments = {}
|
||||||
if func_args:
|
if func_args:
|
||||||
pairs = self.func_arg_regex.findall(func_args)
|
pairs = self.func_arg_regex.findall(func_args)
|
||||||
@@ -212,7 +250,11 @@ class Glm47MoeDetector(BaseFormatDetector):
|
|||||||
return arg_type
|
return arg_type
|
||||||
|
|
||||||
# Auto-detect type from value (best effort)
|
# 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:
|
if first_chars:
|
||||||
first_char = first_chars[0]
|
first_char = first_chars[0]
|
||||||
if first_char.isdigit() or first_char in ["-", "."]:
|
if first_char.isdigit() or first_char in ["-", "."]:
|
||||||
@@ -237,14 +279,14 @@ class Glm47MoeDetector(BaseFormatDetector):
|
|||||||
return json.dumps(value, ensure_ascii=False)
|
return json.dumps(value, ensure_ascii=False)
|
||||||
elif value_type == "number":
|
elif value_type == "number":
|
||||||
try:
|
try:
|
||||||
num = _convert_to_number(value.strip())
|
num = _convert_to_number(value.strip() if value else "")
|
||||||
return str(num)
|
return str(num)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
# Fallback to string if not a valid number
|
# Fallback to string if not a valid number
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to parse '{value}' as number, treating as string"
|
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:
|
else:
|
||||||
# For object/array types, return as-is (should already be valid JSON)
|
# For object/array types, return as-is (should already be valid JSON)
|
||||||
return value
|
return value
|
||||||
@@ -369,6 +411,179 @@ class Glm47MoeDetector(BaseFormatDetector):
|
|||||||
|
|
||||||
return json_output
|
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(
|
def parse_streaming_increment(
|
||||||
self, new_text: str, tools: List[Tool]
|
self, new_text: str, tools: List[Tool]
|
||||||
) -> StreamingParseResult:
|
) -> StreamingParseResult:
|
||||||
@@ -405,140 +620,96 @@ class Glm47MoeDetector(BaseFormatDetector):
|
|||||||
# Could be start of tool call, keep buffering
|
# Could be start of tool call, keep buffering
|
||||||
return StreamingParseResult(normal_text="", calls=[])
|
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"):
|
if not hasattr(self, "_tool_indices"):
|
||||||
self._tool_indices = self._get_tool_indices(tools)
|
self._tool_indices = self._get_tool_indices(tools)
|
||||||
|
|
||||||
calls: list[ToolCallItem] = []
|
calls: list[ToolCallItem] = []
|
||||||
try:
|
try:
|
||||||
# Try to match a partial or complete tool call
|
# Try to match a partial or complete tool call
|
||||||
|
# Use a single flexible regex pattern that handles all cases
|
||||||
partial_match = re.search(
|
partial_match = re.search(
|
||||||
pattern=r"<tool_call>(.*?)(<arg_key>.*?)?(</tool_call>|$)",
|
r"<tool_call>(.*?)(?:(<arg_key.*?))?(?:(</tool_call>)|$)",
|
||||||
string=current_text,
|
current_text,
|
||||||
flags=re.DOTALL,
|
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 not partial_match:
|
||||||
if self.current_tool_id == -1:
|
return StreamingParseResult(normal_text=normal_text, calls=[])
|
||||||
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()
|
|
||||||
|
|
||||||
# Ensure we have enough entries in our tracking arrays
|
# Extract match groups using helper method
|
||||||
while len(self.prev_tool_call_arr) <= self.current_tool_id:
|
func_name, func_args_raw, is_tool_end = self._extract_match_groups(
|
||||||
self.prev_tool_call_arr.append({})
|
partial_match
|
||||||
while len(self.streamed_args_for_tool) <= self.current_tool_id:
|
)
|
||||||
self.streamed_args_for_tool.append("")
|
|
||||||
|
|
||||||
# Send tool name first if not sent yet
|
# Initialize tool call state if needed (keeping existing logic)
|
||||||
if not self.current_tool_name_sent:
|
if self.current_tool_id == -1:
|
||||||
assert func_name, "func_name should not be empty"
|
self.current_tool_id = 0
|
||||||
calls.append(
|
self.prev_tool_call_arr = []
|
||||||
ToolCallItem(
|
self.streamed_args_for_tool = [""]
|
||||||
tool_index=self.current_tool_id,
|
self._streamed_raw_length = 0
|
||||||
name=func_name,
|
self.current_tool_name_sent = False # Reset for new tool call
|
||||||
parameters="",
|
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
|
calls.extend(finalize_calls)
|
||||||
self._streamed_raw_length = 0
|
return StreamingParseResult(normal_text=normal_text, calls=calls)
|
||||||
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)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in parse_streaming_increment: {e}", exc_info=True)
|
logger.error(f"Error in parse_streaming_increment: {e}", exc_info=True)
|
||||||
return StreamingParseResult(normal_text=current_text)
|
return StreamingParseResult(normal_text=current_text)
|
||||||
|
|
||||||
|
return StreamingParseResult(normal_text=normal_text, calls=calls)
|
||||||
|
|
||||||
def _parse_argument_pairs(
|
def _parse_argument_pairs(
|
||||||
self, pairs: List[Tuple[str, str]], func_name: str, tools: List[Tool]
|
self, pairs: List[Tuple[str, str]], func_name: str, tools: List[Tool]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -189,8 +189,10 @@ class Glm4MoeDetector(BaseFormatDetector):
|
|||||||
for match_result in match_result_list:
|
for match_result in match_result_list:
|
||||||
# Get function name
|
# Get function name
|
||||||
func_detail = self.func_detail_regex.search(match_result)
|
func_detail = self.func_detail_regex.search(match_result)
|
||||||
func_name = func_detail.group(1)
|
if func_detail is None:
|
||||||
func_args = func_detail.group(2)
|
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)
|
pairs = self.func_arg_regex.findall(func_args)
|
||||||
|
|
||||||
# Parse arguments using shared method
|
# Parse arguments using shared method
|
||||||
@@ -426,10 +428,19 @@ class Glm4MoeDetector(BaseFormatDetector):
|
|||||||
flags=re.DOTALL,
|
flags=re.DOTALL,
|
||||||
)
|
)
|
||||||
if partial_match:
|
if partial_match:
|
||||||
func_name = partial_match.group(1).strip()
|
func_name_raw = partial_match.group(1)
|
||||||
func_args_raw = partial_match.group(2).strip()
|
func_args_raw = partial_match.group(2)
|
||||||
is_tool_end = partial_match.group(3)
|
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
|
# Initialize state if this is the first tool call
|
||||||
if self.current_tool_id == -1:
|
if self.current_tool_id == -1:
|
||||||
self.current_tool_id = 0
|
self.current_tool_id = 0
|
||||||
@@ -447,7 +458,6 @@ class Glm4MoeDetector(BaseFormatDetector):
|
|||||||
|
|
||||||
# Send tool name first if not sent yet
|
# Send tool name first if not sent yet
|
||||||
if not self.current_tool_name_sent:
|
if not self.current_tool_name_sent:
|
||||||
assert func_name, "func_name should not be empty"
|
|
||||||
calls.append(
|
calls.append(
|
||||||
ToolCallItem(
|
ToolCallItem(
|
||||||
tool_index=self.current_tool_id,
|
tool_index=self.current_tool_id,
|
||||||
@@ -476,6 +486,10 @@ class Glm4MoeDetector(BaseFormatDetector):
|
|||||||
raw_increment, func_name, tools
|
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:
|
if json_increment:
|
||||||
calls.append(
|
calls.append(
|
||||||
ToolCallItem(
|
ToolCallItem(
|
||||||
@@ -489,9 +503,6 @@ class Glm4MoeDetector(BaseFormatDetector):
|
|||||||
self.current_tool_id
|
self.current_tool_id
|
||||||
] += json_increment
|
] += json_increment
|
||||||
|
|
||||||
# Update the streamed length
|
|
||||||
self._streamed_raw_length = current_raw_length
|
|
||||||
|
|
||||||
if is_tool_end == self.eot_token:
|
if is_tool_end == self.eot_token:
|
||||||
if self._is_first_param:
|
if self._is_first_param:
|
||||||
empty_object = "{}"
|
empty_object = "{}"
|
||||||
|
|||||||
@@ -2257,6 +2257,21 @@ class TestGlm4MoeDetector(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
check_single_todos(result, expected_output)
|
check_single_todos(result, expected_output)
|
||||||
|
|
||||||
|
def test_empty_function_name_handling(self):
|
||||||
|
"""Test that empty function name is handled gracefully without assertion error."""
|
||||||
|
# This test simulates the issue where the model outputs only the start token without a function name
|
||||||
|
chunks = [
|
||||||
|
"<tool_call>", # Start token only, no function name yet
|
||||||
|
"\n", # More content without function name
|
||||||
|
]
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
# Should not raise AssertionError: func_name should not be empty
|
||||||
|
result = self.detector.parse_streaming_increment(chunk, self.tools)
|
||||||
|
# Should return empty calls without error
|
||||||
|
self.assertIsInstance(result, StreamingParseResult)
|
||||||
|
self.assertEqual(result.calls, [])
|
||||||
|
|
||||||
|
|
||||||
class TestGlm47MoeDetector(unittest.TestCase):
|
class TestGlm47MoeDetector(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user