From 0b111a0e0cbf742ea5f0b9788eff4469c45c7843 Mon Sep 17 00:00:00 2001 From: hanyuanling Date: Mon, 27 Apr 2026 22:44:10 +0800 Subject: [PATCH] fix(channels): support per-channel progress controls --- docs/configuration.md | 20 ++++ nanobot/channels/manager.py | 48 ++++++++- .../test_channel_manager_delta_coalescing.py | 98 ++++++++++++++++++- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 427b64d4..f295b50c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -474,6 +474,26 @@ Global settings that apply to all channels. Configure under the `channels` secti | `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. | | `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. | +`sendProgress` and `sendToolHints` can also be overridden per channel. The +global values stay as defaults for channels that do not set their own value: + +```json +{ + "channels": { + "sendProgress": true, + "sendToolHints": false, + "telegram": { + "enabled": true, + "sendProgress": false + }, + "websocket": { + "enabled": true, + "sendToolHints": true + } + } +} +``` + ### Retry Behavior Retry is intentionally simple. diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index ccac6306..81d13293 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -27,6 +27,24 @@ def _default_webui_dist() -> Path | None: candidate = Path(web_pkg.__file__).resolve().parent / "dist" return candidate if candidate.is_dir() else None + +def _snake_to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +def _coerce_optional_bool(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return True + if normalized in {"false", "0", "no", "off"}: + return False + return None + + # Retry delays for message sending (exponential backoff: 1s, 2s, 4s) _SEND_RETRY_DELAYS = (1, 2, 4) @@ -131,6 +149,28 @@ class ChannelManager: f'Set ["*"] to allow everyone, or add specific user IDs.' ) + def _should_send_progress(self, channel_name: str, *, tool_hint: bool = False) -> bool: + """Resolve progress visibility, allowing per-channel overrides.""" + key = "send_tool_hints" if tool_hint else "send_progress" + default = getattr(self.config.channels, key) + override = self._channel_bool_override(channel_name, key) + return default if override is None else override + + def _channel_bool_override(self, channel_name: str, key: str) -> bool | None: + section = getattr(self.config.channels, channel_name, None) + if section is None: + return None + + camel_key = _snake_to_camel(key) + if isinstance(section, dict): + value = section.get(key, section.get(camel_key)) + return _coerce_optional_bool(value) + + value = getattr(section, key, None) + if value is None: + value = getattr(section, camel_key, None) + return _coerce_optional_bool(value) + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -216,9 +256,13 @@ class ChannelManager: ) if msg.metadata.get("_progress"): - if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: + if msg.metadata.get("_tool_hint") and not self._should_send_progress( + msg.channel, tool_hint=True, + ): continue - if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: + if not msg.metadata.get("_tool_hint") and not self._should_send_progress( + msg.channel, tool_hint=False, + ): continue if msg.metadata.get("_retry_wait"): diff --git a/tests/channels/test_channel_manager_delta_coalescing.py b/tests/channels/test_channel_manager_delta_coalescing.py index 3c150f90..7956c96f 100644 --- a/tests/channels/test_channel_manager_delta_coalescing.py +++ b/tests/channels/test_channel_manager_delta_coalescing.py @@ -1,6 +1,6 @@ """Tests for ChannelManager delta coalescing to reduce streaming latency.""" import asyncio -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest @@ -8,7 +8,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager -from nanobot.config.schema import Config +from nanobot.config.schema import ChannelsConfig, Config class MockChannel(BaseChannel): @@ -298,6 +298,100 @@ class TestDispatchOutboundWithCoalescing: assert pending[0].content == "Final" +class TestProgressFiltering: + """Progress filtering should honor per-channel config overrides.""" + + def test_progress_visibility_uses_global_defaults(self, manager): + manager.config.channels = ChannelsConfig.model_validate({ + "sendProgress": True, + "sendToolHints": False, + }) + + assert manager._should_send_progress("mock", tool_hint=False) is True + assert manager._should_send_progress("mock", tool_hint=True) is False + + def test_progress_visibility_uses_channel_overrides(self, manager): + manager.config.channels = ChannelsConfig.model_validate({ + "sendProgress": True, + "sendToolHints": False, + "mock": { + "sendProgress": False, + "sendToolHints": True, + }, + }) + + assert manager._should_send_progress("mock", tool_hint=False) is False + assert manager._should_send_progress("mock", tool_hint=True) is True + assert manager._should_send_progress("other", tool_hint=False) is True + assert manager._should_send_progress("other", tool_hint=True) is False + + @pytest.mark.asyncio + async def test_channel_override_can_drop_progress_message(self, manager, bus): + manager.config.channels = ChannelsConfig.model_validate({ + "sendProgress": True, + "mock": {"sendProgress": False}, + }) + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="thinking", + metadata={"_progress": True}, + )) + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="final answer", + metadata={}, + )) + + task = asyncio.create_task(manager._dispatch_outbound()) + try: + for _ in range(30): + if manager.channels["mock"]._send_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + send_mock = manager.channels["mock"]._send_mock + assert send_mock.await_count == 1 + assert send_mock.await_args_list[0].args[0].content == "final answer" + + @pytest.mark.asyncio + async def test_channel_override_can_enable_tool_hints(self, manager, bus): + manager.config.channels = ChannelsConfig.model_validate({ + "sendToolHints": False, + "mock": {"sendToolHints": True}, + }) + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="read_file(foo.py)", + metadata={"_progress": True, "_tool_hint": True}, + )) + + task = asyncio.create_task(manager._dispatch_outbound()) + try: + for _ in range(30): + if manager.channels["mock"]._send_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + send_mock = manager.channels["mock"]._send_mock + assert send_mock.await_count == 1 + assert send_mock.await_args_list[0].args[0].content == "read_file(foo.py)" + + class TestRetryWaitFiltering: """Internal provider retry heartbeats must never reach channels."""