diff --git a/hermes_cli/cli_stream_mixin.py b/hermes_cli/cli_stream_mixin.py index 9e847a6375..2a63ddefa3 100644 --- a/hermes_cli/cli_stream_mixin.py +++ b/hermes_cli/cli_stream_mixin.py @@ -621,12 +621,20 @@ class CLIStreamMixin: def _on_tool_gen_start(self, tool_name: str) -> None: """Model began generating tool-call arguments: close open boxes once, then print a status - line so a large payload (e.g. 45 KB write_file) doesn't look like a frozen screen.""" + line so a large payload (e.g. 45 KB write_file) doesn't look like a frozen screen. + + Fires once per tool CALL, so a batch of parallel calls to the same tool printed the same + line N times (#10478); repeats within one generation batch are coalesced. The set is + cleared when a tool actually starts (``tool.started``), i.e. on the next batch.""" from cli import _cprint - if getattr(self, "_stream_box_opened", False): + if getattr(self, '_stream_box_opened', False): self._flush_stream() self._stream_box_opened = False self._close_reasoning_box() + announced = self.__dict__.setdefault("_tool_gen_announced", set()) + if tool_name in announced: + return + announced.add(tool_name) from agent.display import get_tool_emoji _cprint(f" ┊ {get_tool_emoji(tool_name, default='⚡')} preparing {tool_name}…") @@ -666,6 +674,7 @@ class CLIStreamMixin: # Feed the pet: tools mean "running"; a failed tool latches the turn to end on a sulk. if event_type == "tool.started": self._pet_reasoning = False + self.__dict__.pop("_tool_gen_announced", None) elif event_type == "tool.completed" and kwargs.get("is_error"): self._pet_turn_error = True elif event_type and event_type.startswith("reasoning"): diff --git a/tests/cli/test_tool_gen_start_dedupe.py b/tests/cli/test_tool_gen_start_dedupe.py new file mode 100644 index 0000000000..5bea4d8a5e --- /dev/null +++ b/tests/cli/test_tool_gen_start_dedupe.py @@ -0,0 +1,25 @@ +"""A batch of parallel tool calls announces "preparing …" once per tool, not once per call (#10478).""" + +from unittest.mock import patch + +from tests.cli.test_tool_progress_scrollback import _make_cli +import tests.cli.test_tool_progress_scrollback as _scrollback + + +def _announce(cli, names): + printed = [] + with patch.object(_scrollback._cli_mod, "_cprint", lambda line: printed.append(line)): + for n in names: + cli._on_tool_gen_start(n) + return printed + + +def test_repeated_tool_in_one_batch_prints_once(): + cli = _make_cli(tool_progress="off") + printed = _announce(cli, ["terminal", "terminal", "terminal", "read_file"]) + assert sum("preparing terminal" in p for p in printed) == 1 + assert sum("preparing read_file" in p for p in printed) == 1 + # A tool actually starting closes the batch; the next generation announces again. + with patch.object(_scrollback._cli_mod, "_cprint", lambda line: None): + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + assert sum("preparing terminal" in p for p in _announce(cli, ["terminal"])) == 1