{"repo":"NousResearch/hermes-agent","public_id":"169","display_label":"Expose subagent tool calls and thinking to users","summary":"A feature request and related pull requests to surface subagent actions, tool calls, and internal reasoning to the user. Implementations target both a gateway layer and persistent CLI output lines.","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":186,"merged_at":"2026-03-01T07:18:01+00:00"},"resolution":{"state":"resolved","by":"pr-186"},"freshness":{"state":"stale","last_verified_at":"2026-03-01T07:18:01+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":169,"pr_number":186,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-01T07:18:01+00:00"}],"members":[{"kind":"issue","number":169,"title":"Feature Request: Expose subagent actions/thoughts to the user","url":null,"state":"closed"},{"kind":"pr","number":186,"title":"feat(gateway): expose subagent tool calls and thinking to user (fixes #169)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/display.py","run_agent.py","tests/agent/test_subagent_progress.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"agent/display.py","hunks":["@@ -199,6 +199,21 @@ def start(self):\n     def update_text(self, new_message: str):\n         self.message = new_message\n \n+    def print_above(self, text: str):\n+        \"\"\"Print a line above the spinner without disrupting animation.\n+\n+        Clears the current spinner line, prints the text, and lets the\n+        next animation tick redraw the spinner on the line below.\n+        Thread-safe: uses the captured stdout reference (self._out).\n+        Works inside redirect_stdout(devnull) because _write bypasses\n+        sys.stdout and writes to the stdout captured at spinner creation.\n+        \"\"\"\n+        if not self.running:\n+            self._write(f\"  {text}\", flush=True)\n+            return\n+        # Clear spinner line, print text above, spinner redraws on next tick\n+        self._write(f\"\\r\\033[K  {text}\", flush=True)\n+\n     def stop(self, final_message: str = None):\n         self.running = False\n         if self.thread:"]},{"file":"run_agent.py","hunks":["@@ -2362,6 +2362,24 @@ def run_conversation(\n                 # Handle assistant response\n                 if assistant_message.content and not self.quiet_mode:\n                     print(f\"{self.log_prefix}🤖 Assistant: {assistant_message.content[:100]}{'...' if len(assistant_message.content) > 100 else ''}\")\n+\n+                # Notify progress callback of model's thinking (used by subagent\n+                # delegation to relay the child's reasoning to the parent display).\n+                # Guard: only fire for subagents (_delegate_depth >= 1) to avoid\n+                # spamming gateway platforms with the main agent's every thought.\n+                if (assistant_message.content and self.tool_progress_callback\n+                        and getattr(self, '_delegate_depth', 0) > 0):\n+                    _think_text = assistant_message.content.strip()\n+                    # Strip reasoning XML tags that shouldn't leak to parent display\n+                    _think_text = re.sub(\n+                        r'</?(?:REASONING_SCRATCHPAD|think|reasoning)>', '', _think_text\n+                    ).strip()\n+                    first_line = _think_text.split('\\n')[0][:80] if _think_text else \"\"\n+                    if first_line:\n+                        try:\n+                            self.tool_progress_callback(\"_thinking\", first_line)\n+                        except Exception:\n+                            pass\n                 \n                 # Check for incomplete <REASONING_SCRATCHPAD> (opened but never closed)\n                 # This means the model ran out of output tokens mid-reasoning — retry up to 2 times"]},{"file":"tests/agent/test_subagent_progress.py","hunks":["@@ -0,0 +1,367 @@\n+\"\"\"\n+Tests for subagent progress relay (issue #169).\n+\n+Verifies that:\n+- KawaiiSpinner.print_above() works with and without active spinner\n+- _build_child_progress_callback handles CLI/gateway/no-display paths\n+- Thinking events are relayed correctly\n+- Parallel callbacks don't share state\n+\"\"\"\n+\n+import io\n+import sys\n+import time\n+import threading\n+import pytest\n+from unittest.mock import MagicMock, patch\n+\n+from agent.display import KawaiiSpinner\n+from tools.delegate_tool import _build_child_progress_callback\n+\n+\n+# =========================================================================\n+# KawaiiSpinner.print_above tests\n+# =========================================================================\n+\n+class TestPrintAbove:\n+    \"\"\"Tests for KawaiiSpinner.print_above method.\"\"\"\n+\n+    def test_print_above_without_spinner_running(self):\n+        \"\"\"print_above should write to stdout even when spinner is not running.\"\"\"\n+        buf = io.StringIO()\n+        spinner = KawaiiSpinner(\"test\")\n+        spinner._out = buf  # Redirect to buffer\n+        \n+        spinner.print_above(\"hello world\")\n+        output = buf.getvalue()\n+        assert \"hello world\" in output\n+\n+    def test_print_above_with_spinner_running(self):\n+        \"\"\"print_above should clear spinner line and print text.\"\"\"\n+        buf = io.StringIO()\n+        spinner = KawaiiSpinner(\"test\")\n+        spinner._out = buf\n+        spinner.running = True  # Pretend spinner is running (don't start thread)\n+        \n+        spinner.print_above(\"tool line\")\n+        output = buf.getvalue()\n+        assert \"tool line\" in output\n+        assert \"\\r\\033[K\" in output  # Should start with line clear\n+\n+    def test_print_above_uses_captured_stdout(self):\n+        \"\"\"print_above should use self._out, not sys.stdout.\n+        This ensures it works inside redirect_stdout(devnull).\"\"\"\n+        buf = io.StringIO()\n+        spinner = KawaiiSpinner(\"test\")\n+        spinner._out = buf\n+        \n+        # Simulate redirect_stdout(devnull)\n+        old_stdout = sys.stdout\n+        sys.stdout = io.StringIO()\n+        try:\n+            spinner.print_above(\"should go to buf\")\n+        finally:\n+            sys.stdout = old_stdout\n+        \n+        assert \"should go to buf\" in buf.getvalue()\n+\n+\n+# =========================================================================\n+# _build_child_progress_callback tests\n+# =========================================================================\n+\n+class TestBuildChildProgressCallback:\n+    \"\"\"Tests for child progress callback builder.\"\"\"\n+\n+    def test_returns_none_when_no_display(self):\n+        \"\"\"Should return None when parent has no spinner or callback.\"\"\"\n+        parent = MagicMock()\n+        parent._delegate_spinner = None\n+        parent.tool_progress_callback = None\n+        \n+        cb = _build_child_progress_callback(0, parent)\n+        assert cb is None\n+\n+    def test_cli_spinner_tool_event(self):\n+        \"\"\"Should print tool line above spinner for CLI path.\"\"\"\n+        buf = io.StringIO()\n+        spinner = KawaiiSpinner(\"delegating\")\n+        spinner._out = buf\n+        spinner.running = True\n+        \n+        parent = MagicMock()\n+        parent._delegate_spinner = spinner\n+        parent.tool_progress_callback = None\n+        \n+        cb = _build_child_progress_callback(0, parent)\n+        assert cb is not None\n+        \n+        cb(\"web_search\", \"quantum computing\")\n+        output = buf.getvalue()\n+        assert \"web_search\" in output\n+        assert \"quantum computing\" in output\n+        assert \"├─\" in output\n+\n+    def test_cli_spinner_thinking_event(self):\n+        \"\"\"Should print thinking line above spinner for CLI path.\"\"\"\n+        buf = io.StringIO()\n+        spinner = KawaiiSpinner(\"delegating\")\n+        spinner._out = buf\n+        spinner.running = True\n+        \n+        parent = MagicMock()\n+        parent._delegate_spinner = spinner\n+        parent.tool_progress_callback = None\n+        \n+        cb = _build_child_progress_callback(0, parent)\n+        cb(\"_thinking\", \"I'll search for papers first\")\n+        \n+        output = buf.getvalue()\n+        assert \"💭\" in output\n+        assert \"search for papers\" in output\n+\n+    def test_gateway_batched_progress(self):\n+        \"\"\"Gateway path should batch tool calls and flush at BATCH_SIZE.\"\"\"\n+        parent = MagicMock()\n+        parent._delegate_spinner = None\n+        parent_cb = MagicMock()\n+        parent.tool_progress_callback = parent_cb\n+        \n+        cb = _build_child_progress_callback(0, parent)\n+        \n+        # Send 4 tool calls — shouldn't flush yet (BATCH_SIZE = 5)\n+        for i in range(4):\n+            cb(f\"tool_{i}\", f\"arg_{i}\")\n+        parent_cb.assert_not_called()\n+        \n+        # 5th call should trigger flush\n+        cb(\"tool_4\", \"arg_4\")\n+        parent_cb.assert_called_once()\n+        call_args = parent_cb.call_args\n+        assert \"tool_0\" in call_args[0][1]\n+        assert \"tool_4\" in call_args[0][1]\n+\n+    def test_thinking_not_relayed_to_gateway(self):\n+        \"\"\"Thinking events should NOT be sent to gateway (too noisy).\"\"\"\n+        parent = MagicMock()\n+        parent._delegate_spinner = None\n+        parent_cb = MagicMock()\n+        parent.tool_progress_callback = parent_cb\n+        \n+        cb = _build_child_progress_callback(0, parent)\n+        cb(\"_thinking\", \"some reasoning text\")\n+        \n+        parent_cb.assert_not_called()\n+\n+    def test_parallel_callbacks_independent(self):\n+        \"\"\"Each child's callback should have independent batch state.\"\"\"\n+        parent = MagicMock()\n+        parent._delegate_spinner = None\n+        parent_cb = MagicMock()\n+        parent.tool_progress_callback = parent_cb\n+        \n+        cb0 = _build_child_progress_callback(0, parent)\n+        cb1 = _build_child_progress_callback(1, parent)\n+        \n+        # Send 3 calls to each — neither should flush (batch size = 5)\n+        for i in range(3):\n+            cb0(f\"tool_{i}\")\n+            cb1(f\"other_{i}\")\n+        \n+        parent_cb.assert_not_called()\n+\n+    def test_task_index_prefix_in_output(self):\n+        \"\"\"Multi-task mode should show task index prefix.\"\"\"\n+        buf = io.StringIO()\n+        spinner = KawaiiSpinner(\"delegating\")\n+        spinner._out = buf\n+        spinner.running = True\n+        \n+        parent = MagicMock()\n+        parent._delegate_spinner = spinner\n+        parent.tool_progress_callback = None\n+        \n+        # task_index > 0 should add prefix\n+        cb = _build_child_progress_callback(2, parent)\n+        cb(\"web_search\", \"test\")\n+        \n+        output = buf.getvalue()\n+        assert \"[2]\" in output\n+\n+    def test_task_index_zero_no_prefix(self):\n+        \"\"\"Single task (index 0) should not show index prefix.\"\"\"\n+        buf = io.StringIO()\n+        spinner = KawaiiSpinner(\"delegating\")\n+        spinner._out = buf\n+        spinner.running = True\n+        \n+        parent = MagicMock()\n+        parent._delegate_spinner = spinner\n+        parent.tool_progress_callback = None\n+        \n+        cb = _build_child_progress_callback(0, parent)\n+        cb(\"web_search\", \"test\")\n+        \n+        output = buf.getvalue()\n+        assert \"[0]\" not in output\n+\n+\n+# =========================================================================\n+# Integration: thinking callback in run_agent.py\n+# =========================================================================\n+\n+class TestThinkingCallback:\n+    \"\"\"Tests for the _thinking callback in AIAgent conversation loop.\"\"\"\n+\n+    def _simulate_thinking_callback(self, content, callback, delegate_depth=1):\n+        \"\"\"Simulate the exact code path from run_agent.py for the thinking callback.\n+        \n+        delegate_depth: simulates self._delegate_depth.\n+            0 = main agent (should NOT fire), >=1 = subagent (should fire).\n+        \"\"\"\n+        import re\n+        if (content and callback and delegate_depth > 0):\n+            _think_text = content.strip()\n+            _think_text = re.sub(\n+                r'</?(?:REASONING_SCRATCHPAD|think|reasoning)>', '', _think_text\n+            ).strip()\n+            first_line = _think_text.split('\\n')[0][:80] if _think_text else \"\"\n+            if first_line:\n+                try:\n+                    callback(\"_thinking\", first_line)\n+                except Exception:\n+                    pass\n+\n+    def test_thinking_callback_fires_on_content(self):\n+        \"\"\"tool_progress_callback should receive _thinking event\n+        when assistant message has content.\"\"\"\n+        calls = []\n+        self._simulate_thinking_callback(\n+            \"I'll research quantum computing first, then summarize.\",\n+            lambda name, preview=None: calls.append((name, preview))\n+        )\n+        assert len(calls) == 1\n+        assert calls[0][0] == \"_thinking\"\n+        assert \"quantum computing\" in calls[0][1]\n+\n+    def test_thinking_callback_skipped_when_no_content(self):\n+        \"\"\"Should not fire when assistant has no content.\"\"\"\n+        calls = []\n+        self._simulate_thinking_callback(\n+            None,\n+            lambda name, preview=None: calls.append((name, preview))\n+        )\n+        assert len(calls) == 0\n+\n+    def test_thinking_callback_truncates_long_content(self):\n+        \"\"\"Should truncate long content to 80 chars.\"\"\"\n+        calls = []\n+        self._simulate_thinking_callback(\n+            \"A\" * 200 + \"\\nSecond line should be ignored\",\n+            lambda name, preview=None: calls.append((name, preview))\n+        )\n+        assert len(calls) == 1\n+        assert len(calls[0][1]) == 80\n+\n+    def test_thinking_callback_skipped_for_main_agent(self):\n+        \"\"\"Main agent (delegate_depth=0) should NOT fire thinking events.\n+        This prevents gateway spam on Telegram/Discord.\"\"\"\n+        calls = []\n+        self._simulate_thinking_callback(\n+            \"I'll help you with that request.\",\n+            lambda name, preview=None: calls.append((name, preview)),\n+            delegate_depth=0,\n+        )\n+        assert len(calls) == 0\n+\n+    def test_thinking_callback_strips_reasoning_scratchpad(self):\n+        \"\"\"REASONING_SCRATCHPAD tags should be stripped before display.\"\"\"\n+        calls = []\n+        self._simulate_thinking_callback(\n+            \"<REASONING_SCRATCHPAD>I need to analyze this carefully</REASONING_SCRATCHPAD>\",\n+            lambda name, preview=None: calls.append((name, preview))\n+        )\n+        assert len(calls) == 1\n+        assert \"<REASONING_SCRATCHPAD>\" not in calls[0][1]\n+        assert \"analyze this carefully\" in calls[0][1]\n+\n+    def test_thinking_callback_strips_think_tags(self):\n+        \"\"\"<think> tags should be stripped before display.\"\"\"\n+        calls = []\n+        self._simulate_thinking_callback(\n+            \"<think>Let me think about this problem</think>\",\n+            lambda name, preview=None: calls.append((name, preview))\n+        )\n+        assert len(calls) == 1\n+        assert \"<think>\" not in calls[0][1]\n+        assert \"think about this problem\" in calls[0][1]\n+\n+    def test_thinking_callback_empty_after_strip(self):\n+        \"\"\"Should not fire when content is only XML tags.\"\"\"\n+        calls = []\n+        self._simulate_thinking_callback(\n+            \"<REASONING_SCRATCHPAD></REASONING_SCRATCHPAD>\",\n+            lambda name, preview=None: calls.append((name, preview))\n+        )\n+        assert len(calls) == 0\n+\n+\n+# =========================================================================\n+# Gateway batch flush tests\n+# =========================================================================\n+\n+class TestBatchFlush:\n+    \"\"\"Tests for gateway batch flush on subagent completion.\"\"\"\n+\n+    def test_flush_sends_remaining_batch(self):\n+        \"\"\"_flush should send remaining tool names to gateway.\"\"\"\n+        parent = MagicMock()\n+        parent._delegate_spinner = None\n+        parent_cb = MagicMock()\n+        parent.tool_progress_callback = parent_cb\n+\n+        cb = _build_child_progress_callback(0, parent)\n+\n+        # Send 3 tools (below batch size of 5)\n+        cb(\"web_search\", \"query1\")\n+        cb(\"read_file\", \"file.txt\")\n+        cb(\"write_file\", \"out.txt\")\n+        parent_cb.assert_not_called()\n+\n+        # Flush should send the remaining 3\n+        cb._flush()\n+        parent_cb.assert_called_once()\n+        summary = parent_cb.call_args[0][1]\n+        assert \"web_search\" in summary\n+        assert \"write_file\" in summary\n+\n+    def test_flush_noop_when_batch_empty(self):\n+        \"\"\"_flush should not send anything when batch is empty.\"\"\"\n+        parent = MagicMock()\n+        parent._delegate_spinner = None\n+        parent_cb = MagicMock()\n+        parent.tool_progress_callback = parent_cb\n+\n+        cb = _build_child_progress_callback(0, parent)\n+        cb._flush()\n+        parent_cb.assert_not_called()\n+\n+    def test_flush_noop_when_no_parent_callback(self):\n+        \"\"\"_flush should not crash when there's no parent callback.\"\"\"\n+        buf = io.StringIO()\n+        spinner = KawaiiSpinner(\"test\")\n+        spinner._out = buf\n+        spinner.running = True\n+\n+        parent = MagicMock()\n+        parent._delegate_spinner = spinner\n+        parent.tool_progress_callback = None\n+\n+        cb = _build_child_progress_callback(0, parent)\n+        cb(\"web_search\", \"test\")\n+        cb._flush()  # Should not crash\n+\n+\n+if __name__ == \"__main__\":\n+    pytest.main([__file__, \"-v\"])\n+"]},{"file":"tools/delegate_tool.py","hunks":["@@ -77,6 +77,84 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]:\n     return [t for t in toolsets if t not in blocked_toolset_names]\n \n \n+def _build_child_progress_callback(task_index: int, parent_agent) -> Optional[callable]:\n+    \"\"\"Build a callback that relays child agent tool calls to the parent display.\n+\n+    Two display paths:\n+      CLI:     prints tree-view lines above the parent's delegation spinner\n+      Gateway: batches tool names and relays to parent's progress callback\n+\n+    Returns None if no display mechanism is available, in which case the\n+    child agent runs with no progress callback (identical to current behavior).\n+    \"\"\"\n+    spinner = getattr(parent_agent, '_delegate_spinner', None)\n+    parent_cb = getattr(parent_agent, 'tool_progress_callback', None)\n+\n+    if not spinner and not parent_cb:\n+        return None  # No display → no callback → zero behavior change\n+\n+    prefix = f\"[{task_index}] \" if task_index > 0 else \"\"\n+\n+    # Gateway: batch tool names, flush periodically\n+    _BATCH_SIZE = 5\n+    _batch: List[str] = []\n+\n+    def _callback(tool_name: str, preview: str = None):\n+        # Special \"_thinking\" event: model produced text content (reasoning)\n+        if tool_name == \"_thinking\":\n+            if spinner:\n+                short = (preview[:55] + \"...\") if preview and len(preview) > 55 else (preview or \"\")\n+                try:\n+                    spinner.print_above(f\" {prefix}├─ 💭 \\\"{short}\\\"\")\n+                except Exception:\n+                    pass\n+            # Don't relay thinking to gateway (too noisy for chat)\n+            return\n+\n+        # Regular tool call event\n+        if spinner:\n+            short = (preview[:35] + \"...\") if preview and len(preview) > 35 else (preview or \"\")\n+            tool_emojis = {\n+                \"terminal\": \"💻\", \"web_search\": \"🔍\", \"web_extract\": \"📄\",\n+                \"read_file\": \"📖\", \"write_file\": \"✍️\", \"patch\": \"🔧\",\n+                \"search_files\": \"🔎\", \"list_directory\": \"📂\",\n+                \"browser_navigate\": \"🌐\", \"browser_click\": \"👆\",\n+                \"text_to_speech\": \"🔊\", \"image_generate\": \"🎨\",\n+                \"vision_analyze\": \"👁️\", \"process\": \"⚙️\",\n+            }\n+            emoji = tool_emojis.get(tool_name, \"⚡\")\n+            line = f\" {prefix}├─ {emoji} {tool_name}\"\n+            if short:\n+                line += f\"  \\\"{short}\\\"\"\n+            try:\n+                spinner.print_above(line)\n+            except Exception:\n+                pass\n+\n+        if parent_cb:\n+            _batch.append(tool_name)\n+            if len(_batch) >= _BATCH_SIZE:\n+                summary = \", \".join(_batch)\n+                try:\n+                    parent_cb(\"subagent_progress\", f\"🔀 {prefix}{summary}\")\n+                except Exception:\n+                    pass\n+                _batch.clear()\n+\n+    def _flush():\n+        \"\"\"Flush remaining batched tool names to gateway on completion.\"\"\"\n+        if parent_cb and _batch:\n+            summary = \", \".join(_batch)\n+            try:\n+                parent_cb(\"subagent_progress\", f\"🔀 {prefix}{summary}\")\n+            except Exception:\n+                pass\n+            _batch.clear()\n+\n+    _callback._flush = _flush\n+    return _callback\n+\n+\n def _run_single_child(\n     task_index: int,\n     goal: str,","@@ -98,33 +176,15 @@ def _run_single_child(\n \n     child_prompt = _build_child_system_prompt(goal, context)\n \n-    # Build a progress callback that surfaces subagent tool activity.\n-    # CLI: updates the parent's delegate spinner text.\n-    # Gateway: forwards to the parent's progress callback (feeds message queue).\n-    parent_progress_cb = getattr(parent_agent, 'tool_progress_callback', None)\n-    def _child_progress(tool_name: str, preview: str = None):\n-        tag = f\"[subagent-{task_index+1}] {tool_name}\"\n-        # Update CLI spinner\n-        spinner = getattr(parent_agent, '_delegate_spinner', None)\n-        if spinner:\n-            detail = f'\"{preview}\"' if preview else \"\"\n-            try:\n-                spinner.update_text(f\"🔀 {tag} {detail}\")\n-            except Exception:\n-                pass\n-        # Forward to gateway progress queue\n-        if parent_progress_cb:\n-            try:\n-                parent_progress_cb(tag, preview)\n-            except Exception:\n-                pass\n-\n     try:\n         # Extract parent's API key so subagents inherit auth (e.g. Nous Portal)\n         parent_api_key = None\n         if hasattr(parent_agent, '_client_kwargs'):\n             parent_api_key = parent_agent._client_kwargs.get(\"api_key\")\n \n+        # Build progress callback to relay tool calls to parent display\n+        child_progress_cb = _build_child_progress_callback(task_index, parent_agent)\n+\n         child = AIAgent(\n             base_url=parent_agent.base_url,\n             api_key=[redacted:secret-assignment]","@@ -143,7 +203,7 @@ def _child_progress(tool_name: str, preview: str = None):\n             providers_ignored=parent_agent.providers_ignored,\n             providers_order=parent_agent.providers_order,\n             provider_sort=parent_agent.provider_sort,\n-            tool_progress_callback=_child_progress,\n+            tool_progress_callback=child_progress_cb,\n         )\n \n         # Set delegation depth so children can't spawn grandchildren","@@ -158,6 +218,13 @@ def _child_progress(tool_name: str, preview: str = None):\n         with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):\n             result = child.run_conversation(user_message=goal)\n \n+        # Flush any remaining batched progress to gateway\n+        if child_progress_cb and hasattr(child_progress_cb, '_flush'):\n+            try:\n+                child_progress_cb._flush()\n+            except Exception:\n+                pass\n+\n         duration = round(time.monotonic() - child_start, 2)\n \n         summary = result.get(\"final_response\") or \"\"\n"]}]},{"kind":"pr","number":187,"title":"feat: expose subagent tool activity as persistent CLI lines","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}