From ac059e57cbee6d1c42540f73a95207bc29016bf9 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Fri, 28 Aug 2026 18:29:17 +0800 Subject: [PATCH] End a reasoning block when the model starts writing a tool call, not when the response drains --- ms_agent/agent/llm_agent.py | 22 ++++++++++++++++++++++ tests/ui/test_agent_seam.py | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 542f61f77..4500b67d2 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -1187,6 +1187,18 @@ def _emit_content_end(self) -> None: #: second, large enough that a short call emits once and stops. _COMPOSING_STEP = 256 + @staticmethod + def _is_writing_tool_call(message) -> bool: + """Whether this streamed message has begun a tool call. The name arrives + before the arguments, so a NAMED call is the first sign the response + moved from thinking to calling (same test ``_emit_tool_composing`` uses). + """ + for call in getattr(message, 'tool_calls', None) or []: + if isinstance(call, dict) and (call.get('tool_name') + or call.get('name')): + return True + return False + def _emit_tool_composing(self, message, announced: Dict[int, int]) -> None: """Report tool calls the model is still writing. @@ -2145,6 +2157,16 @@ def _next_chunk(_g=_gen): _printed_reasoning_footer = True self._emit_content(new_content) _content = _response_message.content + # Thinking ends when the model starts writing a tool call, + # not when the response drains — for a call carrying a + # whole file those are a minute apart, and both the UI + # timer and the persisted `reasoning_duration` read this + # boundary. Mirrors the content branch above. + if (_printed_reasoning_header + and not _printed_reasoning_footer + and self._is_writing_tool_call(_response_message)): + self._emit_reasoning_end() + _printed_reasoning_footer = True self._emit_tool_composing(_response_message, _composing) if not _reported_images: # After the first chunk, not before it: the payload's diff --git a/tests/ui/test_agent_seam.py b/tests/ui/test_agent_seam.py index a6e4917ce..26c1e8f23 100644 --- a/tests/ui/test_agent_seam.py +++ b/tests/ui/test_agent_seam.py @@ -60,3 +60,24 @@ def test_no_sink_reasoning_goes_to_stdout(capsys): a._emit_reasoning_end() out = capsys.readouterr().out assert 'thinking' in out and 'mulling' in out + + +# ── where thinking ends ─────────────────────────────────────────────────── +# Both the UI timer and the persisted `reasoning_duration` are measured to this +# boundary, so it has to be "the model started calling", not "the stream ended". + + +class _Msg: + def __init__(self, tool_calls=None): + self.tool_calls = tool_calls + + +def test_named_tool_call_marks_the_end_of_thinking(): + started = LLMAgent._is_writing_tool_call + assert started(_Msg()) is False + assert started(_Msg([])) is False + # Arguments without a name yet: not the boundary (nor what composing waits for). + assert started(_Msg([{'arguments': '{"path": "a.md"'}])) is False + assert started(_Msg([{'tool_name': 'file_system---write_file'}])) is True + assert started(_Msg([{'name': 'write_file', 'arguments': ''}])) is True + assert started(_Msg(['nonsense'])) is False # never raise mid-stream