diff --git a/src/tool_parsing.py b/src/tool_parsing.py
index d1a3b7d6e..f9da586f1 100644
--- a/src/tool_parsing.py
+++ b/src/tool_parsing.py
@@ -166,6 +166,13 @@ _TOOL_CODE_RE = re.compile(
_TOOL_CODE_OPEN_RE = re.compile(r"\s*\{", re.IGNORECASE)
_TOOL_CODE_CLOSE_RE = re.compile(r"\}\s*", re.IGNORECASE)
+# Pattern 4b: Gemma-style <|tool_call|> call:tool_name{args}
+_GEMMA_TOOL_CALL_RE = re.compile(
+ r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*(\{[\s\S]*?\})\s*<\|?tool_call\|?>",
+ 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
# that round, or the API didn't parse them), they fall back to raw
@@ -832,6 +839,40 @@ def _parse_tool_code_block(raw: str) -> Optional[ToolBlock]:
return ToolBlock(tool_name, content.strip())
return None
+def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
+ """Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
+ tool_name = tool_name.strip().lower().replace("-", "_")
+ body = body.strip()
+ if not body:
+ return None
+
+ # Replace custom Gemma string delimiters with standard quotes
+ body = body.replace('<|"|>', '"').replace('<|"', '"').replace('"|>', '"')
+
+ # Try standard JSON parsing
+ params = {}
+ try:
+ params = json.loads(body)
+ if not isinstance(params, dict):
+ params = {}
+ except json.JSONDecodeError:
+ # Try unquoted keys repair: e.g. {query: "..."} -> {"query": "..."}
+ try:
+ repaired = re.sub(r'([{,]\s*)(\w+)\s*:', r'\1"\2":', body)
+ params = json.loads(repaired)
+ if not isinstance(params, dict):
+ params = {}
+ except Exception:
+ # Simple regex key-value extraction fallback
+ params = {}
+ for m in re.finditer(r'(\w+)\s*:\s*["\']?(.*?)["\']?(?=\s*,\s*\w+\s*:|\s*\})', body):
+ k = m.group(1)
+ v = m.group(2).strip()
+ params[k] = v
+
+ from src.tool_schemas import function_call_to_tool_block
+ return function_call_to_tool_block(tool_name, json.dumps(params))
+
def _iter_delimited(text, open_re, close_re):
"""Yield ``(match_start, inner_start, inner_end, match_end)`` for each
@@ -1075,6 +1116,15 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if block:
blocks.append(block)
+ # Pattern 4b: Gemma-style <|tool_call|> blocks
+ if not blocks:
+ for m in _GEMMA_TOOL_CALL_RE.finditer(text):
+ tool_name = m.group(1)
+ body = m.group(2)
+ block = _parse_gemma_tool_call(tool_name, body)
+ 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)
@@ -1112,6 +1162,7 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
cleaned = _strip_delimited(cleaned, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE)
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)
if not skip_fenced:
raw_web_json = _parse_raw_web_json_lookup(cleaned)
if raw_web_json:
diff --git a/tests/test_gemma_tool_call_parsing.py b/tests/test_gemma_tool_call_parsing.py
new file mode 100644
index 000000000..9b446cf46
--- /dev/null
+++ b/tests/test_gemma_tool_call_parsing.py
@@ -0,0 +1,39 @@
+from src.agent_tools import parse_tool_blocks, strip_tool_blocks
+
+
+def test_gemma_tool_call_json_args_parse_and_strip():
+ raw = '<|tool_call|>call:web_search{"query":"hello world"}<|tool_call|>'
+
+ blocks = parse_tool_blocks(raw)
+
+ assert len(blocks) == 1
+ assert blocks[0].tool_type == "web_search"
+ assert blocks[0].content == "hello world"
+ assert strip_tool_blocks(raw).strip() == ""
+
+
+def test_gemma_tool_call_unquoted_args_parse():
+ raw = '<|tool_call|>call:web_search{query: "hello world"}<|tool_call|>'
+
+ blocks = parse_tool_blocks(raw)
+
+ assert len(blocks) == 1
+ assert blocks[0].tool_type == "web_search"
+ assert blocks[0].content == "hello world"
+
+
+def test_gemma_tool_call_normalizes_dash_tool_name():
+ raw = '<|tool_call|>call:read-file{"path":"README.md"}<|tool_call|>'
+
+ blocks = parse_tool_blocks(raw)
+
+ assert len(blocks) == 1
+ assert blocks[0].tool_type == "read_file"
+ assert blocks[0].content == "README.md"
+
+
+def test_gemma_parser_does_not_strip_non_tool_fenced_metadata():
+ raw = '```python id="abc"\nprint("hello")\n```'
+
+ assert parse_tool_blocks(raw) == []
+ assert strip_tool_blocks(raw) == raw