diff --git a/src/tool_parsing.py b/src/tool_parsing.py index 4b63b1f70..95d742678 100644 --- a/src/tool_parsing.py +++ b/src/tool_parsing.py @@ -172,6 +172,21 @@ _GEMMA_TOOL_CALL_RE = re.compile( re.IGNORECASE, ) +# Pattern 4c: Open-function wrapper emitted by some local MLX/Exo models. +# Example: +# +# web_search +# {"query":"Sweden news today"} +# +_FUNCTION_MODEL_OPEN_RE = re.compile(r"\s*", re.IGNORECASE) +_FUNCTION_MODEL_CLOSE_RE = re.compile(r"", re.IGNORECASE) +_FUNCTION_MODEL_NAME_RE = re.compile( + r"\s*([A-Za-z_][\w-]*)\s*", + re.IGNORECASE, +) +_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"\s*", re.IGNORECASE) +_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"", re.IGNORECASE) + # Pattern 5: DeepSeek DSML markup leaking into content. When deepseek # models can't emit structured tool_calls (e.g. we sent no tool schemas @@ -885,6 +900,24 @@ def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]: return function_call_to_tool_block(tool_name, json.dumps(params)) +def _parse_function_model_call(body: str) -> Optional[ToolBlock]: + """Parse tool....""" + name_match = _FUNCTION_MODEL_NAME_RE.search(body or "") + if not name_match: + return None + tool_name = name_match.group(1).strip().lower().replace("-", "_") + params = "{}" + for _ms, inner_start, inner_end, _me in _iter_delimited( + body, + _FUNCTION_MODEL_PARAMS_OPEN_RE, + _FUNCTION_MODEL_PARAMS_CLOSE_RE, + ): + params = body[inner_start:inner_end].strip() or "{}" + break + from src.tool_schemas import function_call_to_tool_block + return function_call_to_tool_block(tool_name, params) + + def _iter_delimited(text, open_re, close_re): """Yield ``(match_start, inner_start, inner_end, match_end)`` for each non-overlapping ``open_re ... close_re`` pair, scanning strictly forward. @@ -1136,6 +1169,15 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]: if block: blocks.append(block) + # Pattern 4c: wrapper from local MLX/Exo models. + if not blocks: + for _ms, inner_start, inner_end, _me in _iter_delimited( + text, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE + ): + block = _parse_function_model_call(text[inner_start:inner_end]) + if block: + blocks.append(block) + # Pattern 6: local text-model web_search call leaked as prose + bare JSON. if not blocks and not skip_fenced: raw_web_json = _parse_raw_web_json_lookup(text) @@ -1182,6 +1224,7 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str: cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned) cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE) cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned) + cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE) if not skip_fenced: raw_web_json = _parse_raw_web_json_lookup(cleaned) if raw_web_json: diff --git a/tests/test_function_model_tool_call.py b/tests/test_function_model_tool_call.py new file mode 100644 index 000000000..123fd8bfd --- /dev/null +++ b/tests/test_function_model_tool_call.py @@ -0,0 +1,29 @@ +import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle) +from src.tool_parsing import parse_tool_blocks, strip_tool_blocks + + +def test_function_model_wrapper_runs_web_search_and_strips_markup(): + raw = """Sure, let me check what's making headlines in Sweden today. + + +web_search +{"query": "Sweden news today July 2026"} +""" + + blocks = parse_tool_blocks(raw, skip_fenced=True) + + assert len(blocks) == 1 + assert blocks[0].tool_type == "web_search" + assert blocks[0].content == "Sweden news today July 2026" + assert strip_tool_blocks(raw, skip_fenced=True) == "Sure, let me check what's making headlines in Sweden today." + + +def test_function_model_wrapper_with_unknown_tool_is_stripped_but_not_executed(): + raw = """Nope. + +launch_missiles +{"target": "moon"} +""" + + assert parse_tool_blocks(raw, skip_fenced=True) == [] + assert strip_tool_blocks(raw, skip_fenced=True) == "Nope."