@@ -213,6 +213,108 @@ def extract_tool_result_text(result) -> str:
213213 return str (result )
214214
215215
216+ def _maybe_warn_no_think (
217+ no_think : bool ,
218+ no_think_warned : bool ,
219+ message ,
220+ reasoning_this_turn : int ,
221+ model : str ,
222+ ) -> bool :
223+ """Warn once if --no-think was requested but the model still reasoned.
224+
225+ Returns the updated ``no_think_warned`` flag.
226+ """
227+ # If --no-think was requested but the model still reasoned, the backend
228+ # didn't honor it (e.g. LM Studio can't map enable_thinking to some
229+ # Qwen3.6 GGUFs). Warn once so the no-op is visible instead of silently
230+ # paying full reasoning-decode cost. reasoning_tokens is the structured
231+ # signal; reasoning_content and an inline <think> block in content cover
232+ # servers that emit reasoning without a separate token detail.
233+ if no_think and not no_think_warned :
234+ still_reasoning = (
235+ reasoning_this_turn
236+ or getattr (message , "reasoning_content" , None )
237+ or "<think>" in (message .content or "" ).lower ()
238+ )
239+ if still_reasoning :
240+ detail = (
241+ f"{ reasoning_this_turn } reasoning tokens"
242+ if reasoning_this_turn
243+ else "reasoning in output, token count unavailable"
244+ )
245+ logger .warning (
246+ "--no-think requested but model %s still produced reasoning "
247+ "(%s); backend may not honor enable_thinking" ,
248+ model ,
249+ detail ,
250+ )
251+ no_think_warned = True
252+ return no_think_warned
253+
254+
255+ async def _dispatch_tool_calls (
256+ message ,
257+ messages : list [dict ],
258+ mcp_client : MCPClient ,
259+ tool_trace_sink : list [str ] | None ,
260+ total_calls : int ,
261+ total_success : int ,
262+ total_fail : int ,
263+ ) -> tuple [int , int , int ]:
264+ """Execute one turn's tool calls, appending each result to ``messages``.
265+
266+ Returns the updated ``(total_calls, total_success, total_fail)`` counters.
267+ """
268+ for tc in message .tool_calls :
269+ total_calls += 1
270+ tool_name = tc .function .name
271+ try :
272+ tool_args = json .loads (tc .function .arguments )
273+ except json .JSONDecodeError as e :
274+ malformed_line = (
275+ f" [tool] { tool_name } : malformed arguments: { tc .function .arguments !r} "
276+ )
277+ logger .info (malformed_line )
278+ if tool_trace_sink is not None :
279+ tool_trace_sink .append (malformed_line .strip ())
280+ total_fail += 1
281+ messages .append (
282+ {
283+ "role" : "tool" ,
284+ "tool_call_id" : tc .id ,
285+ "content" : f"Error: Invalid JSON in tool arguments: { e } " ,
286+ }
287+ )
288+ continue
289+
290+ call_line = f" [tool] { tool_name } ({ tool_args } )"
291+ logger .info (call_line )
292+ if tool_trace_sink is not None :
293+ tool_trace_sink .append (call_line .strip ())
294+
295+ try :
296+ result = await mcp_client .call_tool (tool_name , tool_args )
297+ result_text = extract_tool_result_text (result )
298+ total_success += 1
299+ except Exception as e :
300+ err_text = _strip_pydantic_url (str (e ))
301+ result_text = f"Error: { err_text } "
302+ total_fail += 1
303+ # Server-side WARNING log already shows the failure details;
304+ # only record to the trace sink for test artifacts.
305+ if tool_trace_sink is not None :
306+ tool_trace_sink .append (f"[tool] { tool_name } failed: { err_text } " )
307+
308+ messages .append (
309+ {
310+ "role" : "tool" ,
311+ "tool_call_id" : tc .id ,
312+ "content" : result_text ,
313+ }
314+ )
315+ return total_calls , total_success , total_fail
316+
317+
216318async def tool_call_loop (
217319 client : openai .AsyncOpenAI ,
218320 model : str ,
@@ -292,31 +394,9 @@ async def tool_call_loop(
292394 choice = response .choices [0 ]
293395 message = choice .message
294396
295- # If --no-think was requested but the model still reasoned, the backend
296- # didn't honor it (e.g. LM Studio can't map enable_thinking to some
297- # Qwen3.6 GGUFs). Warn once so the no-op is visible instead of silently
298- # paying full reasoning-decode cost. reasoning_tokens is the structured
299- # signal; reasoning_content and an inline <think> block in content cover
300- # servers that emit reasoning without a separate token detail.
301- if no_think and not no_think_warned :
302- still_reasoning = (
303- reasoning_this_turn
304- or getattr (message , "reasoning_content" , None )
305- or "<think>" in (message .content or "" ).lower ()
306- )
307- if still_reasoning :
308- detail = (
309- f"{ reasoning_this_turn } reasoning tokens"
310- if reasoning_this_turn
311- else "reasoning in output, token count unavailable"
312- )
313- logger .warning (
314- "--no-think requested but model %s still produced reasoning "
315- "(%s); backend may not honor enable_thinking" ,
316- model ,
317- detail ,
318- )
319- no_think_warned = True
397+ no_think_warned = _maybe_warn_no_think (
398+ no_think , no_think_warned , message , reasoning_this_turn , model
399+ )
320400
321401 # No tool calls — we have a final response
322402 if not message .tool_calls :
@@ -354,54 +434,15 @@ async def tool_call_loop(
354434 }
355435 )
356436
357- for tc in message .tool_calls :
358- total_calls += 1
359- tool_name = tc .function .name
360- try :
361- tool_args = json .loads (tc .function .arguments )
362- except json .JSONDecodeError as e :
363- malformed_line = (
364- f" [tool] { tool_name } : malformed arguments: "
365- f"{ tc .function .arguments !r} "
366- )
367- logger .info (malformed_line )
368- if tool_trace_sink is not None :
369- tool_trace_sink .append (malformed_line .strip ())
370- total_fail += 1
371- messages .append (
372- {
373- "role" : "tool" ,
374- "tool_call_id" : tc .id ,
375- "content" : f"Error: Invalid JSON in tool arguments: { e } " ,
376- }
377- )
378- continue
379-
380- call_line = f" [tool] { tool_name } ({ tool_args } )"
381- logger .info (call_line )
382- if tool_trace_sink is not None :
383- tool_trace_sink .append (call_line .strip ())
384-
385- try :
386- result = await mcp_client .call_tool (tool_name , tool_args )
387- result_text = extract_tool_result_text (result )
388- total_success += 1
389- except Exception as e :
390- err_text = _strip_pydantic_url (str (e ))
391- result_text = f"Error: { err_text } "
392- total_fail += 1
393- # Server-side WARNING log already shows the failure details;
394- # only record to the trace sink for test artifacts.
395- if tool_trace_sink is not None :
396- tool_trace_sink .append (f"[tool] { tool_name } failed: { err_text } " )
397-
398- messages .append (
399- {
400- "role" : "tool" ,
401- "tool_call_id" : tc .id ,
402- "content" : result_text ,
403- }
404- )
437+ total_calls , total_success , total_fail = await _dispatch_tool_calls (
438+ message ,
439+ messages ,
440+ mcp_client ,
441+ tool_trace_sink ,
442+ total_calls ,
443+ total_success ,
444+ total_fail ,
445+ )
405446
406447 # Max iterations reached without a final message. Flag it so callers can
407448 # surface it as a test failure — otherwise a model stuck in a tool-call
0 commit comments