diff --git a/evalharness/model/adapter.py b/evalharness/model/adapter.py
index d70eb49..b4f8925 100644
--- a/evalharness/model/adapter.py
+++ b/evalharness/model/adapter.py
@@ -108,9 +108,25 @@ def _key_for(api_base: str) -> str:
def _parse_text_tool_calls(text: str) -> list:
- """Extract [{"name":..,"arguments":{..}}] JSON from a text-protocol reply."""
+ """Extract tool calls from a text reply. Handles both shapes:
+ - JSON array: [{"name":..,"arguments":{..}}]
+ - Qwen3 native XML: {"name":..,"arguments":{..}}
+ (for vLLM builds whose qwen3_xml parser doesn't convert to tool_calls)
+ """
import re as _re
+ out = []
+ for m in _re.finditer(r'\s*(\{.*?\})\s*', text, _re.S):
+ try:
+ obj = json.loads(m.group(1))
+ if isinstance(obj, dict) and obj.get('name'):
+ out.append({'id': '', 'type': 'function',
+ 'function': {'name': obj['name'],
+ 'arguments': json.dumps(obj.get('arguments', {}))}})
+ except (ValueError, TypeError):
+ continue
+ if out:
+ return out
candidates = _re.findall(r'\[[\s\S]*?\]', text) or []
for cand in candidates:
try:
@@ -196,7 +212,7 @@ class OpenAICompatible(ModelAdapter):
msg = choice.get('message') or {}
calls = []
raw_calls = list(msg.get('tool_calls') or [])
- if not raw_calls and self.extra.get('tools_mode') == 'text':
+ if not raw_calls:
raw_calls = _parse_text_tool_calls(msg.get('content') or '')
for c in raw_calls:
fn = c.get('function') or {}