Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/bub/channels/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .base import Channel
from .base import Channel, Interface, Lifecycle
from .manager import ChannelManager
from .message import ChannelMessage

__all__ = ["Channel", "ChannelManager", "ChannelMessage"]
__all__ = ["Channel", "ChannelManager", "ChannelMessage", "Interface", "Lifecycle"]
8 changes: 8 additions & 0 deletions src/bub/channels/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,11 @@ async def send(self, message: ChannelMessage) -> None:
def stream_events(self, message: ChannelMessage, stream: AsyncIterable[StreamEvent]) -> AsyncIterable[StreamEvent]:
"""Optionally wrap the output stream for this channel."""
return stream


class Interface(Channel):
"""User-facing inbound/outbound surface managed by the channel runtime."""


class Lifecycle(Channel):
"""Background runtime managed alongside channels."""
4 changes: 2 additions & 2 deletions src/bub/channels/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
import bub
from bub.builtin.agent import Agent
from bub.builtin.tape import TapeInfo
from bub.channels.base import Channel
from bub.channels.base import Interface
from bub.channels.cli.renderer import CliRenderer
from bub.channels.message import ChannelMessage
from bub.envelope import field_of
from bub.tools import REGISTRY
from bub.types import MessageHandler


class CliChannel(Channel):
class CliChannel(Interface):
"""A simple CLI channel for testing and debugging."""

name = "cli"
Expand Down
39 changes: 33 additions & 6 deletions src/bub/channels/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from republic import StreamEvent

from bub import config
from bub.channels.base import Channel
from bub.channels.base import Channel, Interface, Lifecycle
from bub.channels.handler import BufferedMessageHandler
from bub.channels.message import ChannelMessage
from bub.configure import Settings, ensure_config
Expand Down Expand Up @@ -130,12 +130,39 @@ async def quit(self, session_id: str) -> None:
logger.info(f"channel.manager quit session_id={session_id}, cancelled {cancelled_count} tasks")

def enabled_channels(self) -> list[Channel]:
if "all" in self._enabled_channels:
# Exclude 'cli' channel from 'all' to prevent interference with other channels
return [channel for name, channel in self._channels.items() if name != "cli" and channel.enabled]
return [
channel for name, channel in self._channels.items() if name in self._enabled_channels and channel.enabled
"""Channels are enabled by the following rules:
- Interfaces are enabled only if explicitly *included*.
- Lifecycles are always enabled unless explicitly *excluded*.
- Regular channels depend on the values of include and exclude.
"""
included_channels = [
Comment thread
PsiACE marked this conversation as resolved.
name.strip() for name in self._enabled_channels if name.strip() and not name.strip().startswith("!")
]
excluded_channels = {name.strip()[1:] for name in self._enabled_channels if name.strip().startswith("!")}

if "all" in included_channels:
return [
channel
for channel in self._channels.values()
if channel.name not in excluded_channels and channel.enabled and not isinstance(channel, Interface)
]
channels = [
channel
for name, channel in self._channels.items()
if name in included_channels and name not in excluded_channels and channel.enabled
]
if not any(not isinstance(channel, Lifecycle) for channel in channels):
return channels
enabled_names = {channel.name for channel in channels}
channels.extend(
channel
for channel in self._channels.values()
if channel.name not in enabled_names
and channel.name not in excluded_channels
and channel.enabled
and isinstance(channel, Lifecycle)
)
return channels

def _on_task_done(self, session_id: str, task: asyncio.Task) -> None:
if task.cancelled():
Expand Down
58 changes: 51 additions & 7 deletions tests/test_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest
from republic import StreamEvent

from bub.channels.base import Channel, Interface, Lifecycle
from bub.channels.cli import CliChannel
from bub.channels.cli import renderer as cli_renderer
from bub.channels.cli.renderer import CliRenderer
Expand All @@ -34,7 +35,7 @@ def _load_channel_config(
load_config(content)


class FakeChannel:
class _FakeChannelMixin:
def __init__(self, name: str, *, needs_debounce: bool = False) -> None:
self.name = name
self._needs_debounce = needs_debounce
Expand All @@ -61,8 +62,20 @@ async def send(self, message: ChannelMessage) -> None:
self.sent.append(message)


class FakeChannel(_FakeChannelMixin, Channel):
pass


class FakeInterfaceChannel(_FakeChannelMixin, Interface):
pass


class FakeLifecycleChannel(_FakeChannelMixin, Lifecycle):
pass


class FakeFramework:
def __init__(self, channels: dict[str, FakeChannel]) -> None:
def __init__(self, channels: dict[str, Channel]) -> None:
self._channels = channels
self.router = None
self.process_calls: list[tuple[ChannelMessage, bool]] = []
Expand Down Expand Up @@ -213,12 +226,43 @@ async def test_channel_manager_dispatch_uses_output_channel_and_preserves_metada
assert outbound.context["source"] == "test"


def test_channel_manager_enabled_channels_excludes_cli_from_all(load_config) -> None:
@pytest.mark.parametrize(
("enabled_channels", "expected_channels"),
[
(["all"], ["mcp.lifecycle", "manual.lifecycle", "telegram", "discord"]),
(["cli"], ["cli", "mcp.lifecycle", "manual.lifecycle"]),
(["mcp.lifecycle"], ["mcp.lifecycle"]),
(["cli", "!mcp.lifecycle"], ["cli", "manual.lifecycle"]),
(["all", "!mcp.lifecycle", "!telegram"], ["manual.lifecycle", "discord"]),
(["mcp.lifecycle", "!mcp.lifecycle"], []),
],
)
def test_channel_manager_selects_channels_by_runtime_role(
load_config, enabled_channels: list[str], expected_channels: list[str]
) -> None:
_load_channel_config(load_config)
channels = {"cli": FakeChannel("cli"), "telegram": FakeChannel("telegram"), "discord": FakeChannel("discord")}
manager = ChannelManager(FakeFramework(channels), enabled_channels=["all"])
channels = {
"cli": FakeInterfaceChannel("cli"),
"mcp.lifecycle": FakeLifecycleChannel("mcp.lifecycle"),
"manual.lifecycle": FakeLifecycleChannel("manual.lifecycle"),
"telegram": FakeChannel("telegram"),
"discord": FakeChannel("discord"),
}
manager = ChannelManager(FakeFramework(channels), enabled_channels=enabled_channels)

assert [channel.name for channel in manager.enabled_channels()] == expected_channels


def test_channel_manager_selects_real_channel_types(load_config) -> None:
_load_channel_config(load_config, telegram_value="test-token")
cli = CliChannel.__new__(CliChannel)
telegram = TelegramChannel(lambda message: None)
manager = ChannelManager(
FakeFramework({"cli": cli, "telegram": telegram}),
enabled_channels=["all"],
)

assert [channel.name for channel in manager.enabled_channels()] == ["telegram", "discord"]
assert [channel.name for channel in manager.enabled_channels()] == ["telegram"]


@pytest.mark.asyncio
Expand Down Expand Up @@ -257,7 +301,7 @@ async def __call__(self, message: ChannelMessage) -> None:
async def test_channel_manager_shutdown_cancels_tasks_and_stops_enabled_channels(load_config) -> None:
_load_channel_config(load_config)
telegram = FakeChannel("telegram")
cli = FakeChannel("cli")
cli = FakeInterfaceChannel("cli")
manager = ChannelManager(FakeFramework({"telegram": telegram, "cli": cli}), enabled_channels=["all"])

async def never_finish() -> None:
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/docs/operate/channels/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Inside `bub chat`, the same prefix works at any prompt. `Ctrl-X` toggles "shell

## 4. Why `cli` is excluded from `bub gateway`

`bub gateway` listens on every enabled channel. Its default — `--enable-channel` left empty — expands to `all`, which the channel manager interprets as "every registered channel except `cli`". The CLI channel would otherwise grab the controlling terminal that the operator is using to run the gateway itself.
`bub gateway` listens on every enabled channel. Its default — `--enable-channel` left empty — expands to every enabled non-`Interface` channel, including listener channels and `Lifecycle` runtimes. The CLI interface is excluded so it does not grab the controlling terminal that the operator is using to run the gateway itself.

To open a REPL, use `bub chat` (which forces `enabled_channels=["cli"]` and `stream_output=True`). To run only Telegram, use `bub gateway --enable-channel telegram`.

Expand Down
8 changes: 7 additions & 1 deletion website/src/content/docs/docs/operate/channels/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A **channel** is one inbound/outbound surface — a CLI prompt, a chat platform,

## 1. Choose which channels to enable

`bub gateway` listens on every enabled channel that ships with a working config. By default it enables `all`, which expands to every registered channel **except `cli`** — the CLI channel is excluded so that a long-lived gateway does not steal the operator's terminal.
`bub gateway` listens on every enabled channel that ships with a working config. By default it enables `all`, which expands to every enabled non-`Interface` channel. That includes listener channels such as Telegram as well as `Lifecycle` runtimes such as `mcp.lifecycle`. The built-in `cli` interface is excluded so that a long-lived gateway does not steal the operator's terminal.

Enable a specific channel:

Expand All @@ -30,6 +30,12 @@ Enable several:
uv run bub gateway --enable-channel telegram --enable-channel wechat
```

Exclude a default lifecycle channel:

```bash
uv run bub gateway --enable-channel all --enable-channel '!mcp.lifecycle'
```

To pin the default in config, set `enabled_channels` (comma-separated, or `all`):

```yaml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The same proxy is used for both the polling connection and outbound API calls.
uv run bub gateway --enable-channel telegram
```

`--enable-channel telegram` pins the listener to one channel. Without it, `bub gateway` enables `all` channels (which excludes `cli` and includes Telegram if its token is set). On startup you should see:
`--enable-channel telegram` pins the listener to one channel. Without it, `bub gateway` enables every non-`Interface` channel in the default runtime set, which includes Telegram when its token is set and also starts enabled `Lifecycle` runtimes. On startup you should see:

```text
telegram.start allow_users_count=2 allow_chats_count=1 proxy_enabled=False
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ bub gateway [OPTIONS]
Behavior notes:

- When `--enable-channel` is omitted the manager reads `ChannelSettings.enabled_channels` (`BUB_ENABLED_CHANNELS`, default `all`).
- `all` excludes the `cli` channel automatically to avoid stdout contention.
- `all` starts every enabled non-`Interface` channel. When you list at least one non-`Lifecycle` channel explicitly, enabled `Lifecycle` runtimes are attached automatically. Use `!name` to exclude one.
- `framework.running()` is held open until the manager loop exits; `provide_tape_store` cleanup runs on shutdown.

See [Operate › Channels](/docs/operate/channels/) for per-channel deployment notes.
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/docs/reference/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Loaded under the YAML root section.

| Env var | Default | YAML key | Description |
| --- | --- | --- | --- |
| `BUB_ENABLED_CHANNELS` | `all` | `enabled_channels` | Comma-separated channel names, or `all` (excludes `cli`). Overridden per-invocation by `bub gateway --enable-channel`. |
| `BUB_ENABLED_CHANNELS` | `all` | `enabled_channels` | Comma-separated channel names, `all`, or exclusions prefixed with `!`. The default runtime set includes every enabled non-`Interface` channel, and explicit lists that contain a non-`Lifecycle` channel also attach enabled `Lifecycle` runtimes unless excluded. Overridden per-invocation by `bub gateway --enable-channel`. |
| `BUB_DEBOUNCE_SECONDS` | `1.0` | `debounce_seconds` | Minimum gap between two messages from the same channel when the channel sets `needs_debounce=True`. |
| `BUB_MAX_WAIT_SECONDS` | `10.0` | `max_wait_seconds` | Hard cap for the debounce wait. |
| `BUB_ACTIVE_TIME_WINDOW` | `60.0` | `active_time_window` | Window in seconds during which a session stays "active" for buffered handling. |
Expand Down
8 changes: 8 additions & 0 deletions website/src/content/docs/docs/reference/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ class Channel(ABC):
def stream_events(
self, message: ChannelMessage, stream: AsyncIterable[StreamEvent]
) -> AsyncIterable[StreamEvent]: ...


class Interface(Channel): ...


class Lifecycle(Channel): ...
```

Abstract base for inbound/outbound channels (`src/bub/channels/base.py`).
Expand All @@ -132,6 +138,8 @@ Abstract base for inbound/outbound channels (`src/bub/channels/base.py`).
| `send(message)` | Outbound delivery. Default impl is a no-op. |
| `stream_events(message, stream)` | Optional stream wrapper used by `OutboundChannelRouter.wrap_stream`. Returns the stream unchanged by default. |

`Interface` is the public base for channels that should not be part of the default `gateway all` runtime set, such as the built-in CLI REPL. `Lifecycle` is the public base for background runtimes that start and stop with the channel manager, such as MCP bootstrap services.

See [Operate › Channels](/docs/operate/channels/) and [Build › Plugins](/docs/build/plugins/) for end-to-end examples.

## `BubFramework`
Expand Down
5 changes: 3 additions & 2 deletions website/src/content/docs/docs/tutorials/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,11 @@ The process should start without exiting. Press `Ctrl-C` to stop it, fix the und

## 4. Use the MCP tool from a running turn

`bub mcp list` is enough to confirm the integration. Calling the tool from a real turn requires Bub's **channel runtime**, which only `bub gateway` starts:
`bub mcp list` is enough to confirm the integration. Calling the tool from a real turn requires Bub's **channel runtime**:

- `bub run` and `bub chat` do **not** start any `Channel`. The `mcp.lifecycle` channel that owns the MCP servers never boots, so MCP tools are not exposed to the model in those commands.
- `bub chat` starts the `cli` interface and automatically attaches enabled `Lifecycle` runtimes, including `mcp.lifecycle`.
- `bub gateway` starts every channel returned by the `provide_channels` hook (subject to `--enable-channel` / `BUB_ENABLED_CHANNELS`). With `mcp.lifecycle` enabled, the channel boots in the background, registers each remote tool into the global tool registry as `mcp.<server>_<tool>`, and from then on the model can call them.
- `bub run` still does not start channels, so MCP tools discovered through `mcp.lifecycle` are not exposed there.

Run the gateway with both the input channel and the MCP lifecycle channel enabled:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ uv run bub run ",echo hello" # 找不到的命令会回落到 shell

## 4. 为什么 `cli` 不在 `bub gateway` 中

`bub gateway` 监听所有已启用 channel。它的默认 —— `--enable-channel` 留空 —— 展开为 `all`,channel 管理器将其解释为"除 `cli` 外的全部已注册 channel"。否则 CLI channel 会抢占运维者用来运行 gateway 的终端。
`bub gateway` 监听所有已启用 channel。它的默认 —— `--enable-channel` 留空 —— 会展开为所有已启用且不是 `Interface` 的 channel,其中包括 listener 和 `Lifecycle` 后台服务。`cli` interface 会被排除,否则它会抢占运维者用来运行 gateway 的终端。

要打开 REPL,使用 `bub chat`(它强制 `enabled_channels=["cli"]` 并启用 `stream_output=True`)。要只跑 Telegram,使用 `bub gateway --enable-channel telegram`。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ sidebar:

## 1. 选择启用哪些 channel

`bub gateway` 监听所有已启用、配置可用的 channel。它默认启用 `all`,展开为已注册的全部 channel **不含 `cli`** —— CLI channel 被排除,避免长期运行的网关抢占运维者的终端。
`bub gateway` 监听所有已启用、配置可用的 channel。它默认启用 `all`,展开为所有已启用且不是 `Interface` 的 channel。这既包括 Telegram 这类 listener,也包括 `mcp.lifecycle` 这类 `Lifecycle` 后台服务。内置 `cli` interface 会被排除,避免长期运行的网关抢占运维者的终端。

启用单个 channel:

Expand All @@ -30,6 +30,12 @@ uv run bub gateway --enable-channel telegram
uv run bub gateway --enable-channel telegram --enable-channel wechat
```

排除默认 lifecycle channel:

```bash
uv run bub gateway --enable-channel all --enable-channel '!mcp.lifecycle'
```

要在配置中固化默认值,设置 `enabled_channels`(逗号分隔,或 `all`):

```yaml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ BUB_TELEGRAM_PROXY=http://127.0.0.1:7890
uv run bub gateway --enable-channel telegram
```

`--enable-channel telegram` 把监听器固定到一个 channel。不带它时,`bub gateway` 启用 `all` channel(排除 `cli`,并在 token 已设置时包括 Telegram。启动时应看到:
`--enable-channel telegram` 把监听器固定到一个 channel。不带它时,`bub gateway` 会启用所有不是 `Interface` 的默认运行时 channel,其中会在 token 已设置时包含 Telegram,并同时启动已启用的 `Lifecycle` 后台服务。启动时应看到:

```text
telegram.start allow_users_count=2 allow_chats_count=1 proxy_enabled=False
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/zh-cn/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ bub gateway [OPTIONS]
行为说明:

- 未传 `--enable-channel` 时,Manager 读取 `ChannelSettings.enabled_channels`(`BUB_ENABLED_CHANNELS`,默认 `all`)。
- `all` 自动排除 `cli` channel,避免与 stdout 打架
- `all` 会启动所有已启用且不是 `Interface` 的 channel。显式列出且至少包含一个非 `Lifecycle` channel 时,会自动附着已启用的 `Lifecycle` 后台服务;可用 `!name` 排除其中某个 channel
- `framework.running()` 持续到 manager 主循环退出;关闭时执行 `provide_tape_store` 的清理。

部署细节见 [运维 › Channels](/zh-cn/docs/operate/channels/)。
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/zh-cn/docs/reference/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class ChannelSettings(Settings):

| 环境变量 | 默认值 | YAML 字段 | 描述 |
| --- | --- | --- | --- |
| `BUB_ENABLED_CHANNELS` | `all` | `enabled_channels` | 逗号分隔的 channel 名,或 `all`(排除 `cli`)。可被 `bub gateway --enable-channel` 单次覆盖。 |
| `BUB_ENABLED_CHANNELS` | `all` | `enabled_channels` | 逗号分隔的 channel 名`all`,或前缀为 `!` 的排除项。默认运行时集合包含所有已启用且不是 `Interface` 的 channel;显式列出且包含非 `Lifecycle` channel 时,也会附着已启用的 `Lifecycle` 服务,除非被排除。可被 `bub gateway --enable-channel` 单次覆盖。 |
| `BUB_DEBOUNCE_SECONDS` | `1.0` | `debounce_seconds` | channel 设置 `needs_debounce=True` 时,同一 channel 两次消息之间的最小间隔。 |
| `BUB_MAX_WAIT_SECONDS` | `10.0` | `max_wait_seconds` | 防抖等待的硬上限。 |
| `BUB_ACTIVE_TIME_WINDOW` | `60.0` | `active_time_window` | 会话保持"活跃"以接受缓冲处理的窗口秒数。 |
Expand Down
8 changes: 8 additions & 0 deletions website/src/content/docs/zh-cn/docs/reference/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ class Channel(ABC):
def stream_events(
self, message: ChannelMessage, stream: AsyncIterable[StreamEvent]
) -> AsyncIterable[StreamEvent]: ...


class Interface(Channel): ...


class Lifecycle(Channel): ...
```

inbound / outbound channel 的抽象基类(`src/bub/channels/base.py`)。
Expand All @@ -132,6 +138,8 @@ inbound / outbound channel 的抽象基类(`src/bub/channels/base.py`)。
| `send(message)` | outbound 投递。默认实现为 no-op。 |
| `stream_events(message, stream)` | `OutboundChannelRouter.wrap_stream` 调用的可选 stream 包装器。默认原样返回。 |

`Interface` 是不会进入默认 `gateway all` 运行时集合的公共基类,比如内置 CLI REPL。`Lifecycle` 是随 channel manager 启停的后台运行时基类,比如负责 MCP bootstrap 的服务。

完整示例见 [运维 › Channels](/zh-cn/docs/operate/channels/) 与 [构建 › 插件](/zh-cn/docs/build/plugins/)。

## `BubFramework`
Expand Down
Loading
Loading