fix(channels): support per-channel progress controls
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user