bub 0.3.8__tar.gz → 0.3.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bub-0.3.8 → bub-0.3.9}/PKG-INFO +1 -1
- {bub-0.3.8 → bub-0.3.9}/src/bub/__init__.py +14 -2
- {bub-0.3.8 → bub-0.3.9}/src/bub/_version.py +2 -2
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/agent.py +11 -8
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/hook_impl.py +2 -2
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/store.py +12 -6
- bub-0.3.9/src/bub/channels/__init__.py +5 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/channels/base.py +8 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/channels/cli/__init__.py +17 -20
- {bub-0.3.8 → bub-0.3.9}/src/bub/channels/cli/renderer.py +11 -36
- bub-0.3.9/src/bub/channels/manager.py +343 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/framework.py +32 -5
- {bub-0.3.8 → bub-0.3.9}/src/bub/hookspecs.py +14 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/tools.py +12 -1
- bub-0.3.9/src/bub/turn_admission.py +105 -0
- bub-0.3.9/src/skills/telegram/SKILL.md +184 -0
- bub-0.3.9/src/skills/telegram/scripts/telegram_rich.py +224 -0
- {bub-0.3.8 → bub-0.3.9}/src/skills/telegram/scripts/telegram_send.py +30 -3
- {bub-0.3.8 → bub-0.3.9}/tests/test_builtin_agent.py +56 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_builtin_hook_impl.py +29 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_channels.py +295 -62
- {bub-0.3.8 → bub-0.3.9}/tests/test_framework.py +49 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_tools.py +27 -3
- bub-0.3.8/src/bub/channels/__init__.py +0 -5
- bub-0.3.8/src/bub/channels/manager.py +0 -184
- bub-0.3.8/src/skills/telegram/SKILL.md +0 -115
- {bub-0.3.8 → bub-0.3.9}/.gitignore +0 -0
- {bub-0.3.8 → bub-0.3.9}/LICENSE +0 -0
- {bub-0.3.8 → bub-0.3.9}/README.md +0 -0
- {bub-0.3.8 → bub-0.3.9}/pyproject.toml +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/__main__.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/__init__.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/auth.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/cli.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/context.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/settings.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/shell_manager.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/tape.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/tools.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/channels/handler.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/channels/message.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/channels/telegram.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/configure.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/envelope.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/hook_runtime.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/inquirer.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/skills.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/types.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/bub/utils.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/skills/README.md +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/skills/gh/SKILL.md +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/conftest.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_builtin_cli.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_builtin_tools.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_cli_help.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_configure.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_envelope.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_file_tape_store_entry_ids.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_fork_store_merge_back.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_hook_runtime.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_image_message.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_settings.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_skills.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_subagent_tool.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_tape_search_output.py +0 -0
- {bub-0.3.8 → bub-0.3.9}/tests/test_utils.py +0 -0
{bub-0.3.8 → bub-0.3.9}/PKG-INFO
RENAMED
|
@@ -13,8 +13,20 @@ from bub.configure import Settings, config, ensure_config
|
|
|
13
13
|
from bub.framework import DEFAULT_HOME, BubFramework
|
|
14
14
|
from bub.hookspecs import hookimpl
|
|
15
15
|
from bub.tools import tool
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
from bub.turn_admission import AdmitDecision, SteeringBuffer, TurnSnapshot
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AdmitDecision",
|
|
20
|
+
"BubFramework",
|
|
21
|
+
"Settings",
|
|
22
|
+
"SteeringBuffer",
|
|
23
|
+
"TurnSnapshot",
|
|
24
|
+
"config",
|
|
25
|
+
"ensure_config",
|
|
26
|
+
"home",
|
|
27
|
+
"hookimpl",
|
|
28
|
+
"tool",
|
|
29
|
+
]
|
|
18
30
|
|
|
19
31
|
try:
|
|
20
32
|
__version__ = import_module("bub._version").version
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.3.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
21
|
+
__version__ = version = '0.3.9'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 9)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -24,6 +24,7 @@ from republic import (
|
|
|
24
24
|
StreamEvent,
|
|
25
25
|
StreamState,
|
|
26
26
|
TapeContext,
|
|
27
|
+
Tool,
|
|
27
28
|
ToolAutoResult,
|
|
28
29
|
ToolContext,
|
|
29
30
|
)
|
|
@@ -34,11 +35,11 @@ from bub.builtin.store import ForkTapeStore
|
|
|
34
35
|
from bub.builtin.tape import TapeService
|
|
35
36
|
from bub.framework import BubFramework
|
|
36
37
|
from bub.skills import discover_skills, render_skills_prompt
|
|
37
|
-
from bub.tools import REGISTRY, model_tools, render_tools_prompt
|
|
38
|
+
from bub.tools import REGISTRY, model_tools, render_tools_prompt, resolve_tool_names
|
|
38
39
|
from bub.types import State
|
|
39
40
|
from bub.utils import workspace_from_state
|
|
40
41
|
|
|
41
|
-
CONTINUE_PROMPT = "Continue the task."
|
|
42
|
+
CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
42
43
|
HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
|
|
43
44
|
_CONTEXT_LENGTH_PATTERNS = re.compile(
|
|
44
45
|
r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum",
|
|
@@ -530,12 +531,12 @@ class Agent:
|
|
|
530
531
|
) -> AsyncStreamEvents | ToolAutoResult:
|
|
531
532
|
prompt_text = prompt if isinstance(prompt, str) else _extract_text_from_parts(prompt)
|
|
532
533
|
if allowed_tools is not None:
|
|
533
|
-
allowed_tools =
|
|
534
|
+
allowed_tools = resolve_tool_names(allowed_tools)
|
|
534
535
|
if allowed_skills is not None:
|
|
535
536
|
allowed_skills = {name.casefold() for name in allowed_skills}
|
|
536
537
|
tape.context.state["allowed_skills"] = list(allowed_skills)
|
|
537
538
|
if allowed_tools is not None:
|
|
538
|
-
tools = [tool for tool in REGISTRY.values() if tool.name
|
|
539
|
+
tools = [tool for tool in REGISTRY.values() if tool.name in allowed_tools]
|
|
539
540
|
else:
|
|
540
541
|
tools = list(REGISTRY.values())
|
|
541
542
|
async with asyncio.timeout(self.settings.model_timeout_seconds):
|
|
@@ -543,7 +544,7 @@ class Agent:
|
|
|
543
544
|
return await tape.stream_events_async(
|
|
544
545
|
prompt=prompt,
|
|
545
546
|
system_prompt=self._system_prompt(
|
|
546
|
-
prompt_text, state=tape.context.state, allowed_skills=allowed_skills
|
|
547
|
+
prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools
|
|
547
548
|
),
|
|
548
549
|
max_tokens=self.settings.max_tokens,
|
|
549
550
|
tools=model_tools(tools),
|
|
@@ -553,18 +554,20 @@ class Agent:
|
|
|
553
554
|
return await tape.run_tools_async(
|
|
554
555
|
prompt=prompt,
|
|
555
556
|
system_prompt=self._system_prompt(
|
|
556
|
-
prompt_text, state=tape.context.state, allowed_skills=allowed_skills
|
|
557
|
+
prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools
|
|
557
558
|
),
|
|
558
559
|
max_tokens=self.settings.max_tokens,
|
|
559
560
|
tools=model_tools(tools),
|
|
560
561
|
model=model,
|
|
561
562
|
)
|
|
562
563
|
|
|
563
|
-
def _system_prompt(
|
|
564
|
+
def _system_prompt(
|
|
565
|
+
self, prompt: str, state: State, allowed_skills: set[str] | None = None, tools: Iterable[Tool] | None = None
|
|
566
|
+
) -> str:
|
|
564
567
|
blocks: list[str] = []
|
|
565
568
|
if result := self.framework.get_system_prompt(prompt=prompt, state=state):
|
|
566
569
|
blocks.append(result)
|
|
567
|
-
tools_prompt = render_tools_prompt(REGISTRY.values())
|
|
570
|
+
tools_prompt = render_tools_prompt(tools if tools is not None else REGISTRY.values())
|
|
568
571
|
if tools_prompt:
|
|
569
572
|
blocks.append(tools_prompt)
|
|
570
573
|
workspace = workspace_from_state(state)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import sys
|
|
2
|
-
from datetime import
|
|
2
|
+
from datetime import datetime
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import cast
|
|
5
5
|
|
|
@@ -134,7 +134,7 @@ class BuiltinImpl:
|
|
|
134
134
|
message.kind = "command"
|
|
135
135
|
return content
|
|
136
136
|
context = field_of(message, "context_str")
|
|
137
|
-
now = datetime.now(
|
|
137
|
+
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
138
138
|
context_prefix = f"{context}\n---Date: {now}---\n" if context else ""
|
|
139
139
|
text = f"{context_prefix}{content}"
|
|
140
140
|
|
|
@@ -101,16 +101,22 @@ class ForkTapeStore:
|
|
|
101
101
|
@contextlib.asynccontextmanager
|
|
102
102
|
async def fork(self, tape: str, merge_back: bool = True) -> AsyncGenerator[None, None]:
|
|
103
103
|
store = InMemoryTapeStore()
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
# Save/restore instead of ContextVar.reset(token) to avoid
|
|
105
|
+
# "Token was created in a different Context" when cleanup
|
|
106
|
+
# runs in a different asyncio Task (e.g. cancellation, TaskGroup).
|
|
107
|
+
prev_store = current_store.get(_empty_store)
|
|
108
|
+
prev_fork_tape = current_fork_tape.get()
|
|
109
|
+
prev_was_reset = current_tape_was_reset.get()
|
|
110
|
+
current_store.set(store)
|
|
111
|
+
current_fork_tape.set(tape)
|
|
112
|
+
current_tape_was_reset.set(False)
|
|
107
113
|
try:
|
|
108
114
|
yield
|
|
109
115
|
finally:
|
|
110
116
|
was_reset = current_tape_was_reset.get()
|
|
111
|
-
current_store.
|
|
112
|
-
current_fork_tape.
|
|
113
|
-
current_tape_was_reset.
|
|
117
|
+
current_store.set(prev_store)
|
|
118
|
+
current_fork_tape.set(prev_fork_tape)
|
|
119
|
+
current_tape_was_reset.set(prev_was_reset)
|
|
114
120
|
if merge_back:
|
|
115
121
|
if was_reset:
|
|
116
122
|
await self._parent.reset(tape)
|
|
@@ -39,3 +39,11 @@ class Channel(ABC):
|
|
|
39
39
|
def stream_events(self, message: ChannelMessage, stream: AsyncIterable[StreamEvent]) -> AsyncIterable[StreamEvent]:
|
|
40
40
|
"""Optionally wrap the output stream for this channel."""
|
|
41
41
|
return stream
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Interface(Channel):
|
|
45
|
+
"""User-facing inbound/outbound surface managed by the channel runtime."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Lifecycle(Channel):
|
|
49
|
+
"""Background runtime managed alongside channels."""
|
|
@@ -14,12 +14,11 @@ from prompt_toolkit.key_binding import KeyBindings
|
|
|
14
14
|
from prompt_toolkit.patch_stdout import patch_stdout
|
|
15
15
|
from republic import StreamEvent
|
|
16
16
|
from rich import get_console
|
|
17
|
-
from rich.live import Live
|
|
18
17
|
|
|
19
18
|
import bub
|
|
20
19
|
from bub.builtin.agent import Agent
|
|
21
20
|
from bub.builtin.tape import TapeInfo
|
|
22
|
-
from bub.channels.base import
|
|
21
|
+
from bub.channels.base import Interface
|
|
23
22
|
from bub.channels.cli.renderer import CliRenderer
|
|
24
23
|
from bub.channels.message import ChannelMessage
|
|
25
24
|
from bub.envelope import field_of
|
|
@@ -27,7 +26,7 @@ from bub.tools import REGISTRY
|
|
|
27
26
|
from bub.types import MessageHandler
|
|
28
27
|
|
|
29
28
|
|
|
30
|
-
class CliChannel(
|
|
29
|
+
class CliChannel(Interface):
|
|
31
30
|
"""A simple CLI channel for testing and debugging."""
|
|
32
31
|
|
|
33
32
|
name = "cli"
|
|
@@ -141,23 +140,21 @@ class CliChannel(Channel):
|
|
|
141
140
|
async def stream_events(
|
|
142
141
|
self, message: ChannelMessage, stream: AsyncIterable[StreamEvent]
|
|
143
142
|
) -> AsyncIterable[StreamEvent]:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if live is not None:
|
|
160
|
-
self._renderer.finish_stream(live, kind=message.kind, text=text)
|
|
143
|
+
head_printed = False
|
|
144
|
+
console = get_console()
|
|
145
|
+
async for event in stream:
|
|
146
|
+
if event.kind == "text":
|
|
147
|
+
content = str(event.data.get("delta", ""))
|
|
148
|
+
if not content.strip() and not head_printed:
|
|
149
|
+
continue # skip leading whitespace-only events
|
|
150
|
+
if not head_printed:
|
|
151
|
+
self._renderer.print_head(message.kind)
|
|
152
|
+
head_printed = True
|
|
153
|
+
|
|
154
|
+
console.print(content, end="", highlight=False)
|
|
155
|
+
elif event.kind == "final":
|
|
156
|
+
console.print("\n") # ensure we end with a newline after the final event
|
|
157
|
+
yield event
|
|
161
158
|
|
|
162
159
|
def _build_prompt(self, workspace: Path) -> PromptSession[str]:
|
|
163
160
|
kb = KeyBindings()
|
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
7
|
from rich.console import Console
|
|
8
|
-
from rich.live import Live
|
|
9
8
|
from rich.panel import Panel
|
|
10
9
|
from rich.text import Text
|
|
11
10
|
|
|
@@ -33,54 +32,30 @@ class CliRenderer:
|
|
|
33
32
|
return
|
|
34
33
|
self.console.print(Text(text, style="bright_black"))
|
|
35
34
|
|
|
36
|
-
def panel(self, kind: MessageKind, text: str) -> Panel:
|
|
37
|
-
title, border_style = self._panel_style(kind)
|
|
38
|
-
return Panel(text, title=title, border_style=border_style)
|
|
39
|
-
|
|
40
35
|
def command_output(self, text: str) -> None:
|
|
41
36
|
if not text.strip():
|
|
42
37
|
return
|
|
43
|
-
self.console.print(
|
|
38
|
+
self.console.print(f"[cyan bold]Command >[/]\n{text}")
|
|
44
39
|
|
|
45
40
|
def assistant_output(self, text: str) -> None:
|
|
46
41
|
if not text.strip():
|
|
47
42
|
return
|
|
48
|
-
self.console.print(
|
|
43
|
+
self.console.print(f"[blue bold]Assistant >[/]\n{text}")
|
|
49
44
|
|
|
50
45
|
def error(self, text: str) -> None:
|
|
51
46
|
if not text.strip():
|
|
52
47
|
return
|
|
53
|
-
self.console.print(
|
|
48
|
+
self.console.print(f"[red bold]Error >[/]\n{text}")
|
|
49
|
+
|
|
50
|
+
def print_head(self, kind: MessageKind) -> None:
|
|
51
|
+
if kind == "command":
|
|
52
|
+
self.console.print("[cyan bold]Command >[/]")
|
|
53
|
+
elif kind == "error":
|
|
54
|
+
self.console.print("[red bold]Error >[/]")
|
|
55
|
+
else:
|
|
56
|
+
self.console.print("[blue bold]Assistant >[/]")
|
|
54
57
|
|
|
55
58
|
def log(self, message: object) -> None:
|
|
56
59
|
text = str(message).rstrip()
|
|
57
60
|
if text:
|
|
58
61
|
self.console.print(text)
|
|
59
|
-
|
|
60
|
-
def start_stream(self, kind: MessageKind, text: str) -> Live:
|
|
61
|
-
live = Live(
|
|
62
|
-
self.panel(kind, text),
|
|
63
|
-
console=self.console,
|
|
64
|
-
auto_refresh=False,
|
|
65
|
-
transient=False,
|
|
66
|
-
vertical_overflow="visible",
|
|
67
|
-
)
|
|
68
|
-
live.start(refresh=True)
|
|
69
|
-
return live
|
|
70
|
-
|
|
71
|
-
def update_stream(self, live: Live, *, kind: MessageKind, text: str) -> None:
|
|
72
|
-
live.update(self.panel(kind, text), refresh=True)
|
|
73
|
-
|
|
74
|
-
def finish_stream(self, live: Live, *, kind: MessageKind, text: str) -> None:
|
|
75
|
-
live.update(self.panel(kind, text), refresh=True)
|
|
76
|
-
live.stop()
|
|
77
|
-
|
|
78
|
-
@staticmethod
|
|
79
|
-
def _panel_style(kind: MessageKind) -> tuple[str, str]:
|
|
80
|
-
match kind:
|
|
81
|
-
case "error":
|
|
82
|
-
return "Error", "red"
|
|
83
|
-
case "command":
|
|
84
|
-
return "Command", "green"
|
|
85
|
-
case _:
|
|
86
|
-
return "Assistant", "blue"
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import functools
|
|
4
|
+
from collections.abc import AsyncIterable, Collection
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic_settings import SettingsConfigDict
|
|
9
|
+
from republic import StreamEvent
|
|
10
|
+
|
|
11
|
+
from bub import config
|
|
12
|
+
from bub.channels.base import Channel, Interface, Lifecycle
|
|
13
|
+
from bub.channels.handler import BufferedMessageHandler
|
|
14
|
+
from bub.channels.message import ChannelMessage
|
|
15
|
+
from bub.configure import Settings, ensure_config
|
|
16
|
+
from bub.envelope import content_of, field_of
|
|
17
|
+
from bub.framework import BubFramework
|
|
18
|
+
from bub.turn_admission import AdmitDecision, SessionTurnController
|
|
19
|
+
from bub.types import Envelope, MessageHandler
|
|
20
|
+
from bub.utils import wait_until_stopped
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@config()
|
|
24
|
+
class ChannelSettings(Settings):
|
|
25
|
+
model_config = SettingsConfigDict(env_prefix="BUB_", extra="ignore", env_file=".env")
|
|
26
|
+
|
|
27
|
+
enabled_channels: str = Field(
|
|
28
|
+
default="all", description="Comma-separated list of enabled channels, or 'all' for all channels."
|
|
29
|
+
)
|
|
30
|
+
debounce_seconds: float = Field(
|
|
31
|
+
default=1.0,
|
|
32
|
+
description="Minimum seconds between processing two messages from the same channel to prevent overload.",
|
|
33
|
+
)
|
|
34
|
+
max_wait_seconds: float = Field(
|
|
35
|
+
default=10.0,
|
|
36
|
+
description="Maximum seconds to wait for processing before new messages reach the channel.",
|
|
37
|
+
)
|
|
38
|
+
active_time_window: float = Field(
|
|
39
|
+
default=60.0,
|
|
40
|
+
description="Time window in seconds to consider a channel active for processing messages.",
|
|
41
|
+
)
|
|
42
|
+
stream_output: bool = Field(default=False, description="Whether to stream model output to channels in real-time.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ChannelManager:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
framework: BubFramework,
|
|
49
|
+
enabled_channels: Collection[str] | None = None,
|
|
50
|
+
stream_output: bool | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.framework = framework
|
|
53
|
+
self._channels: dict[str, Channel] = self.framework.get_channels(self.on_receive)
|
|
54
|
+
self._settings = ensure_config(ChannelSettings)
|
|
55
|
+
self._stream_output = stream_output if stream_output is not None else self._settings.stream_output
|
|
56
|
+
if enabled_channels is not None:
|
|
57
|
+
self._enabled_channels = list(enabled_channels)
|
|
58
|
+
else:
|
|
59
|
+
self._enabled_channels = self._settings.enabled_channels.split(",")
|
|
60
|
+
self._messages = asyncio.Queue[ChannelMessage]()
|
|
61
|
+
self._session_controllers: dict[str, SessionTurnController] = {}
|
|
62
|
+
self._session_handlers: dict[str, MessageHandler] = {}
|
|
63
|
+
|
|
64
|
+
async def on_receive(self, message: ChannelMessage) -> None:
|
|
65
|
+
channel = message.channel
|
|
66
|
+
session_id = message.session_id
|
|
67
|
+
if channel not in self._channels:
|
|
68
|
+
logger.warning(f"Received message from unknown channel '{channel}', ignoring.")
|
|
69
|
+
return
|
|
70
|
+
if session_id not in self._session_handlers:
|
|
71
|
+
handler: MessageHandler
|
|
72
|
+
if self._channels[channel].needs_debounce:
|
|
73
|
+
handler = BufferedMessageHandler(
|
|
74
|
+
self._messages.put,
|
|
75
|
+
active_time_window=self._settings.active_time_window,
|
|
76
|
+
max_wait_seconds=self._settings.max_wait_seconds,
|
|
77
|
+
debounce_seconds=self._settings.debounce_seconds,
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
handler = self._messages.put
|
|
81
|
+
self._session_handlers[session_id] = handler
|
|
82
|
+
await self._session_handlers[session_id](message)
|
|
83
|
+
|
|
84
|
+
def get_channel(self, name: str) -> Channel | None:
|
|
85
|
+
return self._channels.get(name)
|
|
86
|
+
|
|
87
|
+
async def dispatch_output(self, message: Envelope) -> bool:
|
|
88
|
+
channel_name = field_of(message, "output_channel", field_of(message, "channel"))
|
|
89
|
+
if channel_name is None:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
channel_key = str(channel_name)
|
|
93
|
+
channel = self.get_channel(channel_key)
|
|
94
|
+
if channel is None:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
outbound = ChannelMessage(
|
|
98
|
+
session_id=str(field_of(message, "session_id", f"{channel_key}:default")),
|
|
99
|
+
channel=channel_key,
|
|
100
|
+
chat_id=str(field_of(message, "chat_id", "default")),
|
|
101
|
+
content=content_of(message),
|
|
102
|
+
context=field_of(message, "context", {}),
|
|
103
|
+
kind=field_of(message, "kind", "normal"),
|
|
104
|
+
)
|
|
105
|
+
await channel.send(outbound)
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
def wrap_stream(self, message: Envelope, stream: AsyncIterable[StreamEvent]) -> AsyncIterable[StreamEvent]:
|
|
109
|
+
channel_name = field_of(message, "output_channel", field_of(message, "channel"))
|
|
110
|
+
if channel_name is None:
|
|
111
|
+
return stream
|
|
112
|
+
|
|
113
|
+
channel_key = str(channel_name)
|
|
114
|
+
channel = self.get_channel(channel_key)
|
|
115
|
+
if channel is None:
|
|
116
|
+
return stream
|
|
117
|
+
|
|
118
|
+
return channel.stream_events(message, stream)
|
|
119
|
+
|
|
120
|
+
async def quit(self, session_id: str) -> None:
|
|
121
|
+
controller = self._session_controllers.get(session_id)
|
|
122
|
+
if controller is None:
|
|
123
|
+
self.framework.clear_steering(session_id)
|
|
124
|
+
logger.info(f"channel.manager quit session_id={session_id}, cancelled 0 tasks")
|
|
125
|
+
return
|
|
126
|
+
controller.clear_pending()
|
|
127
|
+
controller.steering.drain_nowait()
|
|
128
|
+
tasks = set(controller.active_tasks)
|
|
129
|
+
current_task = asyncio.current_task()
|
|
130
|
+
cancelled_count = 0
|
|
131
|
+
for task in tasks:
|
|
132
|
+
if task is current_task:
|
|
133
|
+
continue
|
|
134
|
+
task.cancel()
|
|
135
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
136
|
+
await task
|
|
137
|
+
cancelled_count += 1
|
|
138
|
+
controller.active_tasks.difference_update(task for task in tasks if task is not current_task)
|
|
139
|
+
self._drop_empty_controller(session_id)
|
|
140
|
+
logger.info(f"channel.manager quit session_id={session_id}, cancelled {cancelled_count} tasks")
|
|
141
|
+
|
|
142
|
+
def enabled_channels(self) -> list[Channel]:
|
|
143
|
+
"""Channels are enabled by the following rules:
|
|
144
|
+
- Interfaces are enabled only if explicitly *included*.
|
|
145
|
+
- Lifecycles are always enabled unless explicitly *excluded*.
|
|
146
|
+
- Regular channels depend on the values of include and exclude.
|
|
147
|
+
"""
|
|
148
|
+
included_channels = [
|
|
149
|
+
name.strip() for name in self._enabled_channels if name.strip() and not name.strip().startswith("!")
|
|
150
|
+
]
|
|
151
|
+
excluded_channels = {name.strip()[1:] for name in self._enabled_channels if name.strip().startswith("!")}
|
|
152
|
+
|
|
153
|
+
if "all" in included_channels:
|
|
154
|
+
return [
|
|
155
|
+
channel
|
|
156
|
+
for channel in self._channels.values()
|
|
157
|
+
if channel.name not in excluded_channels and channel.enabled and not isinstance(channel, Interface)
|
|
158
|
+
]
|
|
159
|
+
channels = [
|
|
160
|
+
channel
|
|
161
|
+
for name, channel in self._channels.items()
|
|
162
|
+
if name in included_channels and name not in excluded_channels and channel.enabled
|
|
163
|
+
]
|
|
164
|
+
if not any(not isinstance(channel, Lifecycle) for channel in channels):
|
|
165
|
+
return channels
|
|
166
|
+
enabled_names = {channel.name for channel in channels}
|
|
167
|
+
channels.extend(
|
|
168
|
+
channel
|
|
169
|
+
for channel in self._channels.values()
|
|
170
|
+
if channel.name not in enabled_names
|
|
171
|
+
and channel.name not in excluded_channels
|
|
172
|
+
and channel.enabled
|
|
173
|
+
and isinstance(channel, Lifecycle)
|
|
174
|
+
)
|
|
175
|
+
return channels
|
|
176
|
+
|
|
177
|
+
def _controller(self, session_id: str) -> SessionTurnController:
|
|
178
|
+
controller = self._session_controllers.get(session_id)
|
|
179
|
+
if controller is None:
|
|
180
|
+
controller = SessionTurnController(session_id=session_id, steering=self.framework.steering(session_id))
|
|
181
|
+
self._session_controllers[session_id] = controller
|
|
182
|
+
return controller
|
|
183
|
+
|
|
184
|
+
def _drop_empty_controller(self, session_id: str) -> None:
|
|
185
|
+
controller = self._session_controllers.get(session_id)
|
|
186
|
+
if controller is None:
|
|
187
|
+
return
|
|
188
|
+
if controller.active() or controller.pending_queue or controller.steering.count > 0:
|
|
189
|
+
return
|
|
190
|
+
self._session_controllers.pop(session_id, None)
|
|
191
|
+
self.framework.clear_steering(session_id)
|
|
192
|
+
|
|
193
|
+
def _on_task_done(self, session_id: str, task: asyncio.Task) -> None:
|
|
194
|
+
if task.cancelled():
|
|
195
|
+
logger.info("channel.manager task cancelled session_id={}", session_id)
|
|
196
|
+
else:
|
|
197
|
+
task.exception() # to log any exception
|
|
198
|
+
controller = self._session_controllers.get(session_id)
|
|
199
|
+
if controller is None:
|
|
200
|
+
return
|
|
201
|
+
controller.active_tasks.discard(task)
|
|
202
|
+
if not controller.active():
|
|
203
|
+
controller.promote_steering_to_pending()
|
|
204
|
+
self._schedule_pending(session_id)
|
|
205
|
+
self._drop_empty_controller(session_id)
|
|
206
|
+
|
|
207
|
+
async def _admit_message(self, message: ChannelMessage) -> bool:
|
|
208
|
+
try:
|
|
209
|
+
session_id = await self._resolve_message_session(message)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
logger.exception("channel.manager resolve_session failed")
|
|
212
|
+
await self.framework._hook_runtime.notify_error(stage="resolve_session", error=exc, message=message)
|
|
213
|
+
return False
|
|
214
|
+
controller = self._controller(session_id)
|
|
215
|
+
try:
|
|
216
|
+
decision = await self.framework.admit_message(
|
|
217
|
+
session_id=session_id,
|
|
218
|
+
message=message,
|
|
219
|
+
turn=controller.snapshot(),
|
|
220
|
+
)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
logger.exception("channel.manager admission hook failed")
|
|
223
|
+
await self.framework._hook_runtime.notify_error(stage="admit_message", error=exc, message=message)
|
|
224
|
+
return True
|
|
225
|
+
if decision is None:
|
|
226
|
+
self._drop_empty_controller(session_id)
|
|
227
|
+
return True
|
|
228
|
+
admitted = await self._apply_admission_decision(controller, message, decision)
|
|
229
|
+
if not admitted or not controller.active():
|
|
230
|
+
self._drop_empty_controller(session_id)
|
|
231
|
+
return admitted
|
|
232
|
+
|
|
233
|
+
async def _apply_admission_decision(
|
|
234
|
+
self,
|
|
235
|
+
controller: SessionTurnController,
|
|
236
|
+
message: ChannelMessage,
|
|
237
|
+
decision: AdmitDecision,
|
|
238
|
+
) -> bool:
|
|
239
|
+
action = decision.action
|
|
240
|
+
if action == "process":
|
|
241
|
+
return True
|
|
242
|
+
if action == "drop":
|
|
243
|
+
logger.info(
|
|
244
|
+
"channel.manager admission drop session_id={} reason={}",
|
|
245
|
+
message.session_id,
|
|
246
|
+
decision.reason,
|
|
247
|
+
)
|
|
248
|
+
return False
|
|
249
|
+
if action == "follow_up":
|
|
250
|
+
return self._queue_pending(controller, message, decision.reason)
|
|
251
|
+
if action == "steer":
|
|
252
|
+
if controller.active():
|
|
253
|
+
controller.steering.put_nowait(message)
|
|
254
|
+
logger.info(
|
|
255
|
+
"channel.manager admission steer session_id={} reason={}",
|
|
256
|
+
message.session_id,
|
|
257
|
+
decision.reason,
|
|
258
|
+
)
|
|
259
|
+
return False
|
|
260
|
+
return self._queue_pending(controller, message, decision.reason)
|
|
261
|
+
logger.warning("channel.manager admission unknown action={} session_id={}", decision.action, message.session_id)
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
def _queue_pending(
|
|
265
|
+
self,
|
|
266
|
+
controller: SessionTurnController,
|
|
267
|
+
message: ChannelMessage,
|
|
268
|
+
reason: str | None,
|
|
269
|
+
) -> bool:
|
|
270
|
+
if not controller.active():
|
|
271
|
+
return True
|
|
272
|
+
controller.add_pending(message)
|
|
273
|
+
logger.info(
|
|
274
|
+
"channel.manager admission follow_up session_id={} pending_count={} reason={}",
|
|
275
|
+
message.session_id,
|
|
276
|
+
len(controller.pending_queue),
|
|
277
|
+
reason,
|
|
278
|
+
)
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
def _schedule_message(self, message: ChannelMessage) -> asyncio.Task:
|
|
282
|
+
controller = self._controller(message.session_id)
|
|
283
|
+
task = asyncio.create_task(self._run_message(message))
|
|
284
|
+
task.add_done_callback(functools.partial(self._on_task_done, message.session_id))
|
|
285
|
+
controller.active_tasks.add(task)
|
|
286
|
+
return task
|
|
287
|
+
|
|
288
|
+
def _schedule_pending(self, session_id: str) -> None:
|
|
289
|
+
controller = self._session_controllers.get(session_id)
|
|
290
|
+
if controller is None or controller.active():
|
|
291
|
+
return
|
|
292
|
+
message = controller.pop_pending()
|
|
293
|
+
if message is not None:
|
|
294
|
+
self._schedule_message(message)
|
|
295
|
+
|
|
296
|
+
async def _resolve_message_session(self, message: ChannelMessage) -> str:
|
|
297
|
+
session_id = await self.framework.resolve_session(message)
|
|
298
|
+
message.session_id = session_id
|
|
299
|
+
return session_id
|
|
300
|
+
|
|
301
|
+
async def _run_message(self, message: ChannelMessage) -> None:
|
|
302
|
+
await self.framework.process_inbound(message, self._stream_output)
|
|
303
|
+
|
|
304
|
+
async def listen_and_run(self) -> None:
|
|
305
|
+
stop_event = asyncio.Event()
|
|
306
|
+
self.framework.bind_outbound_router(self)
|
|
307
|
+
async with self.framework.running():
|
|
308
|
+
for channel in self.enabled_channels():
|
|
309
|
+
await channel.start(stop_event)
|
|
310
|
+
logger.info("channel.manager started listening")
|
|
311
|
+
try:
|
|
312
|
+
while True:
|
|
313
|
+
message = await wait_until_stopped(self._messages.get(), stop_event)
|
|
314
|
+
if not await self._admit_message(message):
|
|
315
|
+
continue
|
|
316
|
+
self._schedule_message(message)
|
|
317
|
+
except asyncio.CancelledError:
|
|
318
|
+
logger.info("channel.manager received shutdown signal")
|
|
319
|
+
except Exception:
|
|
320
|
+
logger.exception("channel.manager error")
|
|
321
|
+
raise
|
|
322
|
+
finally:
|
|
323
|
+
self.framework.bind_outbound_router(None)
|
|
324
|
+
await self.shutdown()
|
|
325
|
+
logger.info("channel.manager stopped")
|
|
326
|
+
|
|
327
|
+
async def shutdown(self) -> None:
|
|
328
|
+
count = 0
|
|
329
|
+
session_ids = list(self._session_controllers)
|
|
330
|
+
for controller in list(self._session_controllers.values()):
|
|
331
|
+
controller.clear_pending()
|
|
332
|
+
controller.steering.drain_nowait()
|
|
333
|
+
for task in set(controller.active_tasks):
|
|
334
|
+
task.cancel()
|
|
335
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
336
|
+
await task
|
|
337
|
+
count += 1
|
|
338
|
+
self._session_controllers.clear()
|
|
339
|
+
for session_id in session_ids:
|
|
340
|
+
self.framework.clear_steering(session_id)
|
|
341
|
+
logger.info(f"channel.manager cancelled {count} in-flight tasks")
|
|
342
|
+
for channel in self.enabled_channels():
|
|
343
|
+
await channel.stop()
|