Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions ms_agent/agent/llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,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.

Expand Down Expand Up @@ -2162,6 +2174,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
Expand Down
21 changes: 21 additions & 0 deletions tests/ui/test_agent_seam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading