Skip to content
Open
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
9 changes: 8 additions & 1 deletion gateway/display_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

_GLOBAL_DEFAULTS: dict[str, Any] = {
"tool_progress": "all",
"tool_progress_transport": "edit",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main already exposes this edit-versus-separate-message choice as display.tool_progress_grouping: accumulate|separate (gateway/display_config.py:35, commit 6373aba8). Reuse that surface during salvage rather than introduce a second transport key.

"tool_progress_results": False,
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
Expand Down Expand Up @@ -184,7 +186,12 @@ def _normalise(setting: str, value: Any) -> Any:
if value is True:
return "all"
return str(value).lower()
if setting in ("show_reasoning", "streaming"):
if setting == "tool_progress_transport":
value = str(value).lower()
if value in ("message", "send", "separate"):
return "messages"
return value
if setting in ("show_reasoning", "streaming", "tool_progress_results"):
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "on")
return bool(value)
Expand Down
95 changes: 91 additions & 4 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10679,6 +10679,22 @@ def _run_still_current() -> bool:
if _env_tp and not _tool_progress_configured
else (_resolved_tp or _env_tp or "all")
)
progress_transport = resolve_display_setting(
user_config,
platform_key,
"tool_progress_transport",
"edit",
)
progress_transport = str(progress_transport or "edit").lower()
progress_as_messages = progress_transport in {"messages", "message", "send", "separate"}
progress_show_results = bool(
resolve_display_setting(
user_config,
platform_key,
"tool_progress_results",
False,
)
)
# Disable tool progress for webhooks - they don't support message editing,
# so each progress line would be sent as a separate message.
from gateway.config import Platform
Expand Down Expand Up @@ -10709,16 +10725,27 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
if not progress_queue or not _run_still_current():
return

# Reasoning events are only useful when progress is rendered as
# discrete messages; edited progress bubbles would bury them.
if event_type == "reasoning.available":
if progress_as_messages and progress_mode == "verbose" and preview:
progress_queue.put(f"💭 Thinking\n```\n{str(preview).strip()}\n```")
return

# First-touch onboarding: the first time a tool takes longer than
# _LONG_TOOL_THRESHOLD_S during a run that's streaming every tool
# (progress_mode == "all"), append a one-time hint suggesting
# /verbose. We only fire when (a) the user hasn't seen the hint
# before and (b) /verbose is actually usable on this platform
# (gateway gate must be open). The CLI has its own trigger.
if event_type == "tool.completed" and not long_tool_hint_fired[0]:
if event_type == "tool.completed":
try:
duration = kwargs.get("duration") or 0
if duration >= _LONG_TOOL_THRESHOLD_S and progress_mode == "all":
duration = float(kwargs.get("duration") or 0)
if (
not long_tool_hint_fired[0]
and duration >= _LONG_TOOL_THRESHOLD_S
and progress_mode == "all"
):
from agent.onboarding import (
TOOL_PROGRESS_FLAG,
is_seen,
Expand All @@ -10733,6 +10760,26 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG)
except Exception as _hint_err:
logger.debug("tool-progress onboarding hint failed: %s", _hint_err)

if progress_as_messages and progress_mode == "verbose" and progress_show_results:
from agent.display import get_tool_emoji, get_tool_preview_max_len
emoji = get_tool_emoji(tool_name, default="⚙️")
duration = float(kwargs.get("duration") or 0)
is_error = bool(kwargs.get("is_error", False))
result = kwargs.get("function_result", kwargs.get("result"))
status = "errored" if is_error else "completed"
if result is None:
progress_queue.put(f"{emoji} {tool_name} {status} in {duration:.1f}s")
else:
result = str(result)
_pl = get_tool_preview_max_len()
if _pl > 0 and len(result) > _pl:
result = result[:_pl - 3] + "..."
result = result.replace("```", "` ` `").rstrip()
progress_queue.put(
f"{emoji} {tool_name} {status} in {duration:.1f}s\n"
f"```text\n{result}\n```"
)
return


Expand Down Expand Up @@ -10800,7 +10847,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
# Dedup: collapse consecutive identical progress messages.
# Common with execute_code where models iterate with the same
# code (same boilerplate imports → identical previews).
if msg == last_progress_msg[0]:
if not progress_as_messages and msg == last_progress_msg[0]:
repeat_count[0] += 1
# Update the last line in progress_lines with a counter
# via a special "dedup" queue message.
Expand Down Expand Up @@ -10833,6 +10880,46 @@ async def send_progress_messages():
if not adapter:
return

async def _send_progress_item(raw):
if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__":
_, base_msg, count = raw
msg = f"{base_msg} (×{count + 1})"
else:
msg = raw
await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When salvaging onto current main, route this through the shared progress-send helper rather than calling adapter.send directly. The helper carries reply anchors, thread metadata, and cleanup tracking; bypassing it would regress current threaded progress delivery.


if progress_as_messages:
while True:
try:
if not _run_still_current():
while not progress_queue.empty():
try:
progress_queue.get_nowait()
except Exception:
break
return

raw = progress_queue.get_nowait()
await _send_progress_item(raw)

await asyncio.sleep(0.1)
if _run_still_current():
await adapter.send_typing(source.chat_id, metadata=_progress_metadata)

except queue.Empty:
await asyncio.sleep(0.3)
except asyncio.CancelledError:
while not progress_queue.empty():
try:
raw = progress_queue.get_nowait()
await _send_progress_item(raw)
except Exception:
break
return
except Exception as e:
logger.error("Progress message error: %s", e)
await asyncio.sleep(1)

# Skip tool progress for platforms that don't support message
# editing (e.g. iMessage/BlueBubbles) — each progress update
# would become a separate message bubble, which is noisy.
Expand Down
2 changes: 2 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9494,6 +9494,7 @@ def _run_tool(index, tool_call, function_name, function_args):
self.tool_progress_callback(
"tool.completed", function_name, None, None,
duration=tool_duration, is_error=is_error,
function_result=function_result,
)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
Expand Down Expand Up @@ -9869,6 +9870,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
self.tool_progress_callback(
"tool.completed", function_name, None, None,
duration=tool_duration, is_error=_is_error_result,
function_result=function_result,
)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
Expand Down
14 changes: 14 additions & 0 deletions tests/gateway/test_display_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,20 @@ def test_tool_preview_length_string(self):
config = {"display": {"platforms": {"slack": {"tool_preview_length": "80"}}}}
assert resolve_display_setting(config, "slack", "tool_preview_length") == 80

def test_tool_progress_transport_aliases(self):
"""Per-platform tool progress transport supports message-style aliases."""
from gateway.display_config import resolve_display_setting

config = {"display": {"platforms": {"telegram": {"tool_progress_transport": "separate"}}}}
assert resolve_display_setting(config, "telegram", "tool_progress_transport") == "messages"

def test_tool_progress_results_is_boolean(self):
"""Tool progress result output accepts YAML-style booleans."""
from gateway.display_config import resolve_display_setting

config = {"display": {"platforms": {"telegram": {"tool_progress_results": "true"}}}}
assert resolve_display_setting(config, "telegram", "tool_progress_results") is True

def test_platform_override_false_tool_progress(self):
"""Per-platform bare off → normalised."""
from gateway.display_config import resolve_display_setting
Expand Down
118 changes: 118 additions & 0 deletions tests/gateway/test_run_progress_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,98 @@ async def test_run_agent_progress_does_not_use_event_message_id_for_telegram_dm(
assert all(call["metadata"] is None for call in adapter.typing)


@pytest.mark.asyncio
async def test_run_agent_progress_can_send_each_tool_as_telegram_message(monkeypatch, tmp_path):
adapter, result = await _run_with_agent(
monkeypatch,
tmp_path,
FakeAgent,
session_id="sess-progress-messages",
config_data={
"display": {
"platforms": {
"telegram": {
"tool_progress": "all",
"tool_progress_transport": "messages",
}
}
}
},
chat_id="12345",
chat_type="dm",
thread_id=None,
)

assert result["final_response"] == "done"
assert [call["content"] for call in adapter.sent] == [
'💻 terminal: "pwd"',
'🌐 browser_navigate: "https://example.com"',
]
assert adapter.edits == []
assert all(call["metadata"] is None for call in adapter.sent)


@pytest.mark.asyncio
async def test_verbose_message_transport_sends_reasoning_and_args_without_output(monkeypatch, tmp_path):
adapter, result = await _run_with_agent(
monkeypatch,
tmp_path,
VerboseMessagesOutputAgent,
session_id="sess-progress-input-messages",
config_data={
"display": {
"platforms": {
"telegram": {
"tool_progress": "verbose",
"tool_progress_transport": "messages",
}
}
}
},
chat_id="12345",
chat_type="dm",
thread_id=None,
)

assert result["final_response"] == "done"
contents = [call["content"] for call in adapter.sent]
assert contents[0] == "💭 Thinking\n```\nInspect the page, then run curl.\n```"
assert "terminal(['command'])" in contents[1]
assert "printf" in contents[1]
assert "ok\\\\n" in contents[1]
assert len(contents) == 2
assert adapter.edits == []


@pytest.mark.asyncio
async def test_verbose_message_transport_can_include_tool_output(monkeypatch, tmp_path):
adapter, result = await _run_with_agent(
monkeypatch,
tmp_path,
VerboseMessagesOutputAgent,
session_id="sess-progress-output-messages",
config_data={
"display": {
"platforms": {
"telegram": {
"tool_progress": "verbose",
"tool_progress_transport": "messages",
"tool_progress_results": True,
}
}
}
},
chat_id="12345",
chat_type="dm",
thread_id=None,
)

assert result["final_response"] == "done"
contents = [call["content"] for call in adapter.sent]
assert contents[2].endswith("terminal completed in 1.2s\n```text\nok\n```")
assert adapter.edits == []


@pytest.mark.asyncio
async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch, tmp_path):
"""Slack DM progress should keep event ts fallback threading."""
Expand Down Expand Up @@ -511,6 +603,32 @@ def run_conversation(self, message, conversation_history=None, task_id=None):
}


class VerboseMessagesOutputAgent:
def __init__(self, **kwargs):
self.tool_progress_callback = kwargs.get("tool_progress_callback")
self.tools = []

def run_conversation(self, message, conversation_history=None, task_id=None):
cb = self.tool_progress_callback
cb("reasoning.available", "_thinking", "Inspect the page, then run curl.", None)
cb("tool.started", "terminal", None, {"command": "printf 'ok\\n'"})
cb(
"tool.completed",
"terminal",
None,
None,
duration=1.234,
is_error=False,
function_result="ok\n",
)
time.sleep(1.2)
return {
"final_response": "done",
"messages": [],
"api_calls": 1,
}


async def _run_with_agent(
monkeypatch,
tmp_path,
Expand Down
16 changes: 15 additions & 1 deletion website/docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,8 @@ This controls both the `text_to_speech` tool and spoken replies in voice mode (`
```yaml
display:
tool_progress: all # off | new | all | verbose
tool_progress_transport: edit # edit | messages
tool_progress_results: false # Gateway: include tool completion output in progress messages
tool_progress_command: false # Enable /verbose slash command in messaging gateway
platforms: {} # Per-platform display overrides (see below)
tool_progress_overrides: {} # DEPRECATED — use display.platforms instead
Expand All @@ -1172,7 +1174,7 @@ display:
| `off` | Silent — just the final response |
| `new` | Tool indicator only when the tool changes |
| `all` | Every tool call with a short preview (default) |
| `verbose` | Full args, results, and debug logs |
| `verbose` | Full tool-call args and debug logs; set `tool_progress_results: true` to include tool outputs in gateway progress messages |

In the CLI, cycle through these modes with `/verbose`. To use `/verbose` in messaging platforms (Telegram, Discord, Slack, etc.), set `tool_progress_command: true` in the `display` section above. The command will then cycle the mode and save to config.

Expand Down Expand Up @@ -1211,6 +1213,18 @@ display:

Platforms without an override fall back to the global `tool_progress` value. Valid platform keys: `telegram`, `discord`, `slack`, `signal`, `whatsapp`, `matrix`, `mattermost`, `email`, `sms`, `homeassistant`, `dingtalk`, `feishu`, `wecom`, `weixin`, `bluebubbles`, `qqbot`. The legacy `display.tool_progress_overrides` key still loads for backward compatibility but is deprecated and migrated into `display.platforms` on first load.

By default, edit-capable platforms collect tool progress into one message and edit it as new tools run. To send each tool call as its own chat message on a platform such as Telegram, set:

```yaml
display:
platforms:
telegram:
tool_progress: all
tool_progress_transport: messages
```

When `tool_progress_transport: messages` is paired with `tool_progress: verbose`, Hermes sends reasoning and tool-call inputs as separate messages. Tool completion output is omitted by default; set `tool_progress_results: true` globally or per platform to include it.

`interim_assistant_messages` is gateway-only. When enabled, Hermes sends completed mid-turn assistant updates as separate chat messages. This is independent from `tool_progress` and does not require gateway streaming.

## Privacy
Expand Down