fix(channels): support per-channel progress controls

This commit is contained in:
hanyuanling 2026-04-27 22:44:10 +08:00 committed by Xubin Ren
parent 67b4d113c9
commit 0b111a0e0c
3 changed files with 162 additions and 4 deletions

View File

@ -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. | | `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"`. | | `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 Behavior
Retry is intentionally simple. Retry is intentionally simple.

View File

@ -27,6 +27,24 @@ def _default_webui_dist() -> Path | None:
candidate = Path(web_pkg.__file__).resolve().parent / "dist" candidate = Path(web_pkg.__file__).resolve().parent / "dist"
return candidate if candidate.is_dir() else None 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) # Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
_SEND_RETRY_DELAYS = (1, 2, 4) _SEND_RETRY_DELAYS = (1, 2, 4)
@ -131,6 +149,28 @@ class ChannelManager:
f'Set ["*"] to allow everyone, or add specific user IDs.' 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: async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions.""" """Start a channel and log any exceptions."""
try: try:
@ -216,9 +256,13 @@ class ChannelManager:
) )
if msg.metadata.get("_progress"): 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 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 continue
if msg.metadata.get("_retry_wait"): if msg.metadata.get("_retry_wait"):

View File

@ -1,6 +1,6 @@
"""Tests for ChannelManager delta coalescing to reduce streaming latency.""" """Tests for ChannelManager delta coalescing to reduce streaming latency."""
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock
import pytest import pytest
@ -8,7 +8,7 @@ from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
from nanobot.config.schema import Config from nanobot.config.schema import ChannelsConfig, Config
class MockChannel(BaseChannel): class MockChannel(BaseChannel):
@ -298,6 +298,100 @@ class TestDispatchOutboundWithCoalescing:
assert pending[0].content == "Final" 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: class TestRetryWaitFiltering:
"""Internal provider retry heartbeats must never reach channels.""" """Internal provider retry heartbeats must never reach channels."""