Skip to content

Commit 963df65

Browse files
authored
feat: Switchable streaming and non-streaming model output (#170)
- Updated `run_model` and `run_model_stream` hooks to allow for both synchronous and asynchronous execution. - Introduced a `stream_output` flag in `ChannelManager` and `BubFramework` to control streaming behavior. - Modified `process_inbound` to handle streaming output based on the new flag. - Enhanced `Agent` class to support streaming output through `run_stream` method. - Updated documentation to reflect changes in streaming capabilities and usage. - Added tests to verify the correct behavior of streaming and non-streaming executions. Signed-off-by: Frost Ming <me@frostming.com>
1 parent 73df07d commit 963df65

18 files changed

Lines changed: 581 additions & 67 deletions

docs/architecture.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@
1616
2. Initialize state with `_runtime_workspace` from `BubFramework.workspace`.
1717
3. Merge all `load_state(message, session_id)` dicts.
1818
4. Build prompt via `build_prompt(message, session_id, state)` (fallback to inbound `content` if empty).
19-
5. Execute `run_model_stream(prompt, session_id, state)`.
20-
6. For each stream event, call `OutboundChannelRouter.dispatch_event(...)`, which forwards to `channel.on_event(event, message)` when the target channel exists.
19+
5. Execute `run_model(prompt, session_id, state)` by default, or `run_model_stream(prompt, session_id, state)` when the caller opts into streaming.
20+
6. In streaming mode, forward each stream event through the outbound router before collecting final text.
2121
7. Always execute `save_state(...)` in a `finally` block.
2222
8. Render outbound batches via `render_outbound(...)`, then flatten them.
2323
9. If no outbound exists, emit one fallback outbound.
2424
10. Dispatch each outbound via `dispatch_outbound(message)`.
2525

26-
If no plugin implements `run_model_stream`, `HookRuntime` falls back to `run_model(prompt, session_id, state)` and adapts the returned text into a stream with a single text chunk.
26+
`HookRuntime` keeps both directions compatible: `run_model()` can consume a streaming plugin by concatenating text chunks, and `run_model_stream()` can consume a plain `run_model()` plugin by adapting its text into a single-chunk stream.
2727

2828
## Hook Priority Semantics
2929

@@ -50,7 +50,8 @@ If no plugin implements `run_model_stream`, `HookRuntime` falls back to `run_mod
5050
Builtin `BuiltinImpl` behavior includes:
5151

5252
- `build_prompt`: supports comma command mode; non-command text may include `context_str`.
53-
- `run_model_stream`: delegates to `Agent.run()`.
53+
- `run_model`: delegates to `Agent.run()`.
54+
- `run_model_stream`: delegates to `Agent.run_stream()`.
5455
- `system_prompt`: combines a default prompt with workspace `AGENTS.md`.
5556
- `register_cli_commands`: installs `run`, `gateway`, `chat`, plus hidden diagnostic commands.
5657
- `provide_channels`: returns `telegram` and `cli` channel adapters.
@@ -67,6 +68,8 @@ Channels have two different outbound surfaces:
6768

6869
If a channel does not implement any special event behavior, it can ignore `on_event` and rely entirely on `send()`.
6970

71+
Channel streaming is opt-in through `BUB_STREAM_OUTPUT=true` (used by `ChannelManager`). When disabled, channels only receive the final rendered outbound message.
72+
7073
## Boundaries
7174

7275
- `Envelope` stays intentionally weakly typed (`Any` + accessor helpers).

docs/channels/cli.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ Enable only selected channels:
5656
uv run bub gateway --enable-channel telegram
5757
```
5858

59+
Forward streaming model events to channel adapters instead of waiting for the final rendered message:
60+
61+
```bash
62+
BUB_STREAM_OUTPUT=true uv run bub gateway --enable-channel telegram
63+
```
64+
5965
## `bub chat`
6066

6167
Start an interactive REPL session via the `cli` channel.

docs/channels/index.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ Enable only Telegram:
2727
uv run bub gateway --enable-channel telegram
2828
```
2929

30+
Enable streaming event delivery for channel listeners:
31+
32+
```bash
33+
BUB_STREAM_OUTPUT=true uv run bub gateway --enable-channel telegram
34+
```
35+
3036
## Session Semantics
3137

3238
- `run` command default session id: `<channel>:<chat_id>`
@@ -43,7 +49,7 @@ Channel adapters can receive outbound data in two forms:
4349

4450
Use `on_event` for incremental UX such as live text updates, typing indicators, progress bars, or chunk-level logging. Use `send` for the final durable outbound payload.
4551

46-
`on_event` is optional. A channel that does not need streaming behavior can ignore it and only implement `send`.
52+
`on_event` is optional. A channel that does not need streaming behavior can ignore it and only implement `send`. `ChannelManager` only forwards stream events when `BUB_STREAM_OUTPUT=true`; otherwise channels receive final outbounds only.
4753

4854
## Debounce Behavior
4955

docs/extension-guide.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,16 @@ Current `process_inbound()` hook usage:
100100
1. `resolve_session` (`call_first`)
101101
2. `load_state` (`call_many`, then merged by framework)
102102
3. `build_prompt` (`call_first`)
103-
4. `run_model_stream` (`call_first`)
103+
4. `run_model` / `run_model_stream` (`call_first`)
104104
5. `save_state` (`call_many`, always executed in `finally`)
105105
6. `render_outbound` (`call_many`)
106106
7. `dispatch_outbound` (`call_many`, per outbound)
107107

108108
Compatibility note:
109109

110-
- `run_model_stream` is the primary model hook.
111-
- If no plugin implements `run_model_stream`, Bub falls back to `run_model`.
112-
- The `run_model` return value is wrapped into a stream with exactly one text chunk.
110+
- Bub can execute either `run_model` or `run_model_stream`, depending on whether the caller requests streaming.
111+
- `HookRuntime.run_model()` can consume a streaming plugin by concatenating text chunks.
112+
- `HookRuntime.run_model_stream()` can consume a plain `run_model()` implementation by wrapping it into a one-chunk stream.
113113
- A plugin should implement one of these hooks, not both.
114114

115115
Other hook consumers:
@@ -182,8 +182,8 @@ uv run bub hooks
182182
uv run bub run "hello"
183183
```
184184

185-
Check that your plugin is listed for `build_prompt` / `run_model_stream`, and output reflects your override.
186-
If you intentionally use the legacy compatibility hook, check for `run_model`.
185+
Check that your plugin is listed for `build_prompt` plus whichever model hook you implement, and output reflects your override.
186+
If you intentionally use the non-streaming path, check for `run_model`; if you need incremental output, check for `run_model_stream`.
187187

188188
## 10) Listen To Parent Stream
189189

@@ -229,7 +229,7 @@ class StreamTapPlugin:
229229

230230
Use this when you need to log chunks, redact text, inject extra events, or measure stream timing without reimplementing the underlying model call.
231231

232-
If you also need to support parents that only implement legacy `run_model`, add your own fallback path and wrap that text result into a one-chunk stream.
232+
If you also need to support parents that only implement `run_model`, add your own fallback path and wrap that text result into a one-chunk stream.
233233

234234
## 11) Common Pitfalls
235235

docs/features.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
Every turn stage is a [pluggy](https://pluggy.readthedocs.io/) hook.
66
Builtins are ordinary plugins — override any stage by registering your own.
77
Both first-result hooks (override) and broadcast hooks (observer) are supported.
8-
`run_model_stream` is the primary model hook.
9-
Legacy `run_model` hooks still work and are adapted into a single text chunk stream.
8+
`run_model` is the default model hook for turn execution.
9+
`run_model_stream` remains available for incremental channel output, and either hook shape can be adapted to the other.
1010
Safe fallback to prompt text when no model hook returns a value (with `on_error` notification).
1111
Automatic fallback outbound when `render_outbound` produces nothing.
1212

@@ -21,6 +21,7 @@ Context is reconstructed from tape records, not accumulated in session state.
2121
- **Model runtime**: agent loop with tool use, backed by [Republic](https://github.com/bubbuild/republic).
2222
- **Comma commands**: `,help`, `,skill`, `,fs.read`, etc. Unknown commands fall back to shell.
2323
- **Channels**: `cli` and `telegram` ship as defaults.
24+
- **Streaming toggle**: channel event streaming is controlled by `BUB_STREAM_OUTPUT` and is off by default.
2425

2526
All of these are hook implementations. Replace what you need.
2627

docs/index.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ uv run bub gateway # channel listener mode
3232
Every inbound message goes through one turn pipeline. Each stage is a hook.
3333

3434
```text
35-
resolve_session → load_state → build_prompt → run_model_stream
36-
37-
dispatch_outbound ← render_outbound ← save_state
35+
resolve_session → load_state → build_prompt → run_model / run_model_stream
36+
37+
dispatch_outbound ← render_outbound ← save_state
3838
```
3939

40-
`run_model` remains supported as a compatibility hook and is adapted into a single-chunk stream when `run_model_stream` is absent.
40+
By default Bub executes `run_model` and expects plain text. Streaming remains available through `run_model_stream`, and `HookRuntime` adapts either hook shape to the other for compatibility.
4141

4242
Builtins are plugins registered first. Later plugins override earlier ones. No special cases.
4343

0 commit comments

Comments
 (0)