{"repo":"NousResearch/hermes-agent","public_id":"16588","display_label":"Chat completions SSE tool progress emission bug fix","summary":"A bug was identified where chat completions via SSE did not emit tool completion progress events. Related changes address emitting tool completion lifecycle SSE events for chat completions.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17505,"merged_at":"2026-04-29T15:08:17+00:00"},"resolution":{"state":"resolved","by":"pr-17505"},"freshness":{"state":"stale","last_verified_at":"2026-04-29T15:08:17+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":16588,"pr_number":17505,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-29T15:08:17+00:00"}],"members":[{"kind":"issue","number":16588,"title":"[Bug]: chat completions SSE does not emit tool completion progress","url":null,"state":"closed"},{"kind":"pr","number":16591,"title":"fix(api-server): emit chat tool completion progress","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":17505,"title":"fix(api-server): emit tool.completed lifecycle SSE for chat completions (#16588)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","tests/gateway/test_api_server.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -981,39 +981,62 @@ def _on_delta(delta):\n                 if delta is not None:\n                     _stream_q.put(delta)\n \n-            def _on_tool_progress(event_type, name, preview, args, **kwargs):\n-                \"\"\"Send tool progress as a separate SSE event.\n-\n-                Previously, progress markers like ``⏰ list`` were injected\n-                directly into ``delta.content``.  OpenAI-compatible frontends\n-                (Open WebUI, LobeChat, …) store ``delta.content`` verbatim as\n-                the assistant message and send it back on subsequent requests.\n-                After enough turns the model learns to *emit* the markers as\n-                plain text instead of issuing real tool calls — silently\n-                hallucinating tool results.  See #6972.\n-\n-                The fix: push a tagged tuple ``(\"__tool_progress__\", payload)``\n-                onto the stream queue.  The SSE writer emits it as a custom\n-                ``event: hermes.tool.progress`` line that compliant frontends\n-                can render for UX but will *not* persist into conversation\n-                history.  Clients that don't understand the custom event type\n-                silently ignore it per the SSE specification.\n+            # Track which tool_call_ids we've emitted a \"running\" lifecycle\n+            # event for, so a \"completed\" event without a matching \"running\"\n+            # (e.g. internal/filtered tools) is silently dropped instead of\n+            # producing an orphaned event clients can't correlate.\n+            _started_tool_call_ids: set[str] = set()\n+\n+            def _on_tool_start(tool_call_id, function_name, function_args):\n+                \"\"\"Emit ``hermes.tool.progress`` with ``status: running``.\n+\n+                Replaces the old ``tool_progress_callback(\"tool.started\",\n+                ...)`` emit so SSE consumers receive a single event per\n+                tool start, carrying both the legacy ``tool``/``emoji``/\n+                ``label`` payload (for #6972 frontends) and the new\n+                ``toolCallId``/``status`` correlation fields (#16588).\n+\n+                Skips tools whose names start with ``_`` so internal\n+                events (``_thinking``, …) stay off the wire — matching\n+                the prior ``_on_tool_progress`` filter exactly.\n                 \"\"\"\n-                if event_type != \"tool.started\":\n+                if not tool_call_id or function_name.startswith(\"_\"):\n                     return\n-                if name.startswith(\"_\"):\n-                    return\n-                from agent.display import get_tool_emoji\n-                emoji = get_tool_emoji(name)\n-                label = preview or name\n+                _started_tool_call_ids.add(tool_call_id)\n+                from agent.display import build_tool_preview, get_tool_emoji\n+                label = build_tool_preview(function_name, function_args) or function_name\n                 _stream_q.put((\"__tool_progress__\", {\n-                    \"tool\": name,\n-                    \"emoji\": emoji,\n+                    \"tool\": function_name,\n+                    \"emoji\": get_tool_emoji(function_name),\n                     \"label\": label,\n+                    \"toolCallId\": tool_call_id,\n+                    \"status\": \"running\",\n+                }))\n+\n+            def _on_tool_complete(tool_call_id, function_name, function_args, function_result):\n+                \"\"\"Emit the matching ``status: completed`` event.\n+\n+                Dropped if the start was filtered (internal tool, missing\n+                id, or never seen) so clients never get an orphaned\n+                ``completed`` they can't correlate to a prior ``running``.\n+                \"\"\"\n+                if not tool_call_id or tool_call_id not in _started_tool_call_ids:\n+                    return\n+                _started_tool_call_ids.discard(tool_call_id)\n+                _stream_q.put((\"__tool_progress__\", {\n+                    \"tool\": function_name,\n+                    \"toolCallId\": tool_call_id,\n+                    \"status\": \"completed\",\n                 }))\n \n             # Start agent in background.  agent_ref is a mutable container\n             # so the SSE writer can interrupt the agent on client disconnect.\n+            #\n+            # ``tool_progress_callback`` is intentionally not wired here:\n+            # it would duplicate every emit because ``run_agent`` fires it\n+            # side-by-side with ``tool_start_callback``/``tool_complete_callback``.\n+            # The structured callbacks are strictly richer (they carry the\n+            # tool_call id), so they own the chat-completions SSE channel.\n             agent_ref = [None]\n             agent_task = asyncio.ensure_future(self._run_agent(\n                 user_message=user_message,","@@ -1021,7 +1044,8 @@ def _on_tool_progress(event_type, name, preview, args, **kwargs):\n                 ephemeral_system_prompt=system_prompt,\n                 session_id=session_id,\n                 stream_delta_callback=_on_delta,\n-                tool_progress_callback=_on_tool_progress,\n+                tool_start_callback=_on_tool_start,\n+                tool_complete_callback=_on_tool_complete,\n                 agent_ref=agent_ref,\n             ))\n ","@@ -1136,7 +1160,8 @@ async def _emit(item):\n                 Tagged tuples ``(\"__tool_progress__\", payload)`` are sent\n                 as a custom ``event: hermes.tool.progress`` SSE event so\n                 frontends can display them without storing the markers in\n-                conversation history.  See #6972.\n+                conversation history.  See #6972 for the original event,\n+                #16588 for the ``toolCallId``/``status`` lifecycle fields.\n                 \"\"\"\n                 if isinstance(item, tuple) and len(item) == 2 and item[0] == \"__tool_progress__\":\n                     event_data = json.dumps(item[1])"]},{"file":"tests/gateway/test_api_server.py","hunks":["@@ -688,17 +688,17 @@ async def _mock_run_agent(**kwargs):\n \n     @pytest.mark.asyncio\n     async def test_stream_includes_tool_progress(self, adapter):\n-        \"\"\"tool_progress_callback fires → progress appears as custom SSE event, not in delta.content.\"\"\"\n+        \"\"\"tool_start_callback fires → progress appears as custom SSE event, not in delta.content.\"\"\"\n         import asyncio\n \n         app = _create_app(adapter)\n         async with TestClient(TestServer(app)) as cli:\n             async def _mock_run_agent(**kwargs):\n                 cb = kwargs.get(\"stream_delta_callback\")\n-                tp_cb = kwargs.get(\"tool_progress_callback\")\n-                # Simulate tool progress before streaming content\n-                if tp_cb:\n-                    tp_cb(\"tool.started\", \"terminal\", \"ls -la\", {\"command\": \"ls -la\"})\n+                ts_cb = kwargs.get(\"tool_start_callback\")\n+                # Simulate the structured tool start the gateway now consumes.\n+                if ts_cb:\n+                    ts_cb(\"call_terminal_1\", \"terminal\", {\"command\": \"ls -la\"})\n                 if cb:\n                     await asyncio.sleep(0.05)\n                     cb(\"Here are the files.\")","@@ -724,7 +724,10 @@ async def _mock_run_agent(**kwargs):\n                 # markers instead of calling tools (#6972).\n                 assert \"event: hermes.tool.progress\" in body\n                 assert '\"tool\": \"terminal\"' in body\n-                assert '\"label\": \"ls -la\"' in body\n+                # ``label`` is now derived by ``build_tool_preview`` from the\n+                # tool args rather than passed by the caller, so we assert\n+                # only that *some* label exists rather than a literal value.\n+                assert '\"label\":' in body\n                 # The progress marker must NOT appear inside any\n                 # chat.completion.chunk delta.content field.\n                 import json as _json","@@ -744,17 +747,17 @@ async def _mock_run_agent(**kwargs):\n \n     @pytest.mark.asyncio\n     async def test_stream_tool_progress_skips_internal_events(self, adapter):\n-        \"\"\"Internal events (name starting with _) are not streamed.\"\"\"\n+        \"\"\"Internal tool calls (name starting with ``_``) are not streamed.\"\"\"\n         import asyncio\n \n         app = _create_app(adapter)\n         async with TestClient(TestServer(app)) as cli:\n             async def _mock_run_agent(**kwargs):\n                 cb = kwargs.get(\"stream_delta_callback\")\n-                tp_cb = kwargs.get(\"tool_progress_callback\")\n-                if tp_cb:\n-                    tp_cb(\"tool.started\", \"_thinking\", \"some internal state\", {})\n-                    tp_cb(\"tool.started\", \"web_search\", \"Python docs\", {\"query\": \"Python docs\"})\n+                ts_cb = kwargs.get(\"tool_start_callback\")\n+                if ts_cb:\n+                    ts_cb(\"call_internal_1\", \"_thinking\", {\"text\": \"some internal state\"})\n+                    ts_cb(\"call_search_1\", \"web_search\", {\"query\": \"Python docs\"})\n                 if cb:\n                     await asyncio.sleep(0.05)\n                     cb(\"Found it.\")","@@ -776,10 +779,142 @@ async def _mock_run_agent(**kwargs):\n                 body = await resp.text()\n                 # Internal _thinking event should NOT appear anywhere\n                 assert \"some internal state\" not in body\n+                assert \"call_internal_1\" not in body\n                 # Real tool progress should appear as custom SSE event\n                 assert \"event: hermes.tool.progress\" in body\n                 assert '\"tool\": \"web_search\"' in body\n-                assert '\"label\": \"Python docs\"' in body\n+                # Label is derived from the args dict by build_tool_preview;\n+                # asserting on the structural fact (label exists, call id\n+                # is correlated) rather than a literal preview string keeps\n+                # the test robust against preview-formatter tweaks.\n+                assert '\"label\":' in body\n+                assert '\"toolCallId\": \"call_search_1\"' in body\n+\n+    @pytest.mark.asyncio\n+    async def test_stream_emits_tool_lifecycle_with_call_id(self, adapter):\n+        \"\"\"Regression for #16588.\n+\n+        ``/v1/chat/completions`` streaming previously emitted only a\n+        ``tool.started``-style ``hermes.tool.progress`` event; clients\n+        rendering tool lifecycle UI had no way to mark a tool as finished\n+        because no matching ``status: completed`` event was emitted, and\n+        no ``toolCallId`` was carried for correlation.\n+\n+        The fix adds ``tool_start_callback`` / ``tool_complete_callback``\n+        to the chat completions agent invocation and writes both halves\n+        of the lifecycle pair on the same ``event: hermes.tool.progress``\n+        SSE line, with stable ``toolCallId`` and ``status``.\n+        \"\"\"\n+        import asyncio\n+        import json as _json\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            async def _mock_run_agent(**kwargs):\n+                cb = kwargs.get(\"stream_delta_callback\")\n+                ts_cb = kwargs.get(\"tool_start_callback\")\n+                tc_cb = kwargs.get(\"tool_complete_callback\")\n+                # The structured callbacks own the chat-completions SSE\n+                # channel now; ``tool_progress_callback`` is intentionally\n+                # not wired so each tool start emits exactly one event.\n+                if ts_cb:\n+                    ts_cb(\"call_terminal_1\", \"terminal\", {\"command\": \"ls -la\"})\n+                if tc_cb:\n+                    tc_cb(\"call_terminal_1\", \"terminal\", {\"command\": \"ls -la\"}, \"ok\")\n+                if cb:\n+                    await asyncio.sleep(0.05)\n+                    cb(\"done.\")\n+                return (\n+                    {\"final_response\": \"done.\", \"messages\": [], \"api_calls\": 1},\n+                    {\"input_tokens\": 1, \"output_tokens\": 1, \"total_tokens\": 2},\n+                )\n+\n+            with patch.object(adapter, \"_run_agent\", side_effect=_mock_run_agent):\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    json={\n+                        \"model\": \"test\",\n+                        \"messages\": [{\"role\": \"user\", \"content\": \"list\"}],\n+                        \"stream\": True,\n+                    },\n+                )\n+                assert resp.status == 200\n+                body = await resp.text()\n+\n+            # Walk the SSE body and collect *(status, toolCallId)* pairs\n+            # per event so the assertions verify per-event correlation —\n+            # an event missing ``toolCallId`` would not pass even if a\n+            # different event happens to carry the right id.\n+            pairs: list[tuple[str | None, str | None]] = []\n+            lines = body.splitlines()\n+            for i, line in enumerate(lines):\n+                if line.strip() != \"event: hermes.tool.progress\":\n+                    continue\n+                for follow in lines[i + 1: i + 4]:\n+                    if follow.startswith(\"data: \"):\n+                        try:\n+                            payload = _json.loads(follow[len(\"data: \"):])\n+                        except _json.JSONDecodeError:\n+                            break\n+                        pairs.append((payload.get(\"status\"), payload.get(\"toolCallId\")))\n+                        break\n+\n+            # Each tool start must emit exactly one event (no duplicate\n+            # legacy + new emit), and each lifecycle pair must carry the\n+            # same toolCallId on every event — not just somewhere in the\n+            # aggregate.\n+            assert len(pairs) == 2, f\"expected 2 events (running+completed), got {pairs}\"\n+            assert pairs[0] == (\"running\", \"call_terminal_1\"), pairs\n+            assert pairs[1] == (\"completed\", \"call_terminal_1\"), pairs\n+\n+    @pytest.mark.asyncio\n+    async def test_stream_tool_lifecycle_skips_internal_and_orphan_completes(self, adapter):\n+        \"\"\"Internal tools (``_thinking``-style) and ``completed`` events\n+        without a prior matching ``running`` must produce no lifecycle\n+        events on the wire — otherwise clients would see orphaned\n+        ``status: completed`` updates they cannot correlate.\"\"\"\n+        import asyncio\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            async def _mock_run_agent(**kwargs):\n+                cb = kwargs.get(\"stream_delta_callback\")\n+                ts_cb = kwargs.get(\"tool_start_callback\")\n+                tc_cb = kwargs.get(\"tool_complete_callback\")\n+                # Internal tool — must be filtered.\n+                if ts_cb:\n+                    ts_cb(\"call_internal_1\", \"_thinking\", {})\n+                if tc_cb:\n+                    tc_cb(\"call_internal_1\", \"_thinking\", {}, \"\")\n+                # Completion without start — orphan, must be dropped.\n+                if tc_cb:\n+                    tc_cb(\"call_orphan_1\", \"web_search\", {}, \"ok\")\n+                if cb:\n+                    await asyncio.sleep(0.05)\n+                    cb(\"ok.\")\n+                return (\n+                    {\"final_response\": \"ok.\", \"messages\": [], \"api_calls\": 1},\n+                    {\"input_tokens\": 1, \"output_tokens\": 1, \"total_tokens\": 2},\n+                )\n+\n+            with patch.object(adapter, \"_run_agent\", side_effect=_mock_run_agent):\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    json={\n+                        \"model\": \"test\",\n+                        \"messages\": [{\"role\": \"user\", \"content\": \"ok\"}],\n+                        \"stream\": True,\n+                    },\n+                )\n+                assert resp.status == 200\n+                body = await resp.text()\n+\n+            # Neither the internal call_id nor the orphan call_id should\n+            # surface as a lifecycle payload on the wire.\n+            assert \"call_internal_1\" not in body\n+            assert \"call_orphan_1\" not in body\n+            assert '\"status\": \"running\"' not in body\n+            assert '\"status\": \"completed\"' not in body\n \n     @pytest.mark.asyncio\n     async def test_no_user_message_returns_400(self, adapter):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}