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.
Files changed (71) hide show
  1. {bub-0.3.8 → bub-0.3.9}/PKG-INFO +1 -1
  2. {bub-0.3.8 → bub-0.3.9}/src/bub/__init__.py +14 -2
  3. {bub-0.3.8 → bub-0.3.9}/src/bub/_version.py +2 -2
  4. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/agent.py +11 -8
  5. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/hook_impl.py +2 -2
  6. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/store.py +12 -6
  7. bub-0.3.9/src/bub/channels/__init__.py +5 -0
  8. {bub-0.3.8 → bub-0.3.9}/src/bub/channels/base.py +8 -0
  9. {bub-0.3.8 → bub-0.3.9}/src/bub/channels/cli/__init__.py +17 -20
  10. {bub-0.3.8 → bub-0.3.9}/src/bub/channels/cli/renderer.py +11 -36
  11. bub-0.3.9/src/bub/channels/manager.py +343 -0
  12. {bub-0.3.8 → bub-0.3.9}/src/bub/framework.py +32 -5
  13. {bub-0.3.8 → bub-0.3.9}/src/bub/hookspecs.py +14 -0
  14. {bub-0.3.8 → bub-0.3.9}/src/bub/tools.py +12 -1
  15. bub-0.3.9/src/bub/turn_admission.py +105 -0
  16. bub-0.3.9/src/skills/telegram/SKILL.md +184 -0
  17. bub-0.3.9/src/skills/telegram/scripts/telegram_rich.py +224 -0
  18. {bub-0.3.8 → bub-0.3.9}/src/skills/telegram/scripts/telegram_send.py +30 -3
  19. {bub-0.3.8 → bub-0.3.9}/tests/test_builtin_agent.py +56 -0
  20. {bub-0.3.8 → bub-0.3.9}/tests/test_builtin_hook_impl.py +29 -0
  21. {bub-0.3.8 → bub-0.3.9}/tests/test_channels.py +295 -62
  22. {bub-0.3.8 → bub-0.3.9}/tests/test_framework.py +49 -0
  23. {bub-0.3.8 → bub-0.3.9}/tests/test_tools.py +27 -3
  24. bub-0.3.8/src/bub/channels/__init__.py +0 -5
  25. bub-0.3.8/src/bub/channels/manager.py +0 -184
  26. bub-0.3.8/src/skills/telegram/SKILL.md +0 -115
  27. {bub-0.3.8 → bub-0.3.9}/.gitignore +0 -0
  28. {bub-0.3.8 → bub-0.3.9}/LICENSE +0 -0
  29. {bub-0.3.8 → bub-0.3.9}/README.md +0 -0
  30. {bub-0.3.8 → bub-0.3.9}/pyproject.toml +0 -0
  31. {bub-0.3.8 → bub-0.3.9}/src/bub/__main__.py +0 -0
  32. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/__init__.py +0 -0
  33. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/auth.py +0 -0
  34. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/cli.py +0 -0
  35. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/context.py +0 -0
  36. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/settings.py +0 -0
  37. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/shell_manager.py +0 -0
  38. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/tape.py +0 -0
  39. {bub-0.3.8 → bub-0.3.9}/src/bub/builtin/tools.py +0 -0
  40. {bub-0.3.8 → bub-0.3.9}/src/bub/channels/handler.py +0 -0
  41. {bub-0.3.8 → bub-0.3.9}/src/bub/channels/message.py +0 -0
  42. {bub-0.3.8 → bub-0.3.9}/src/bub/channels/telegram.py +0 -0
  43. {bub-0.3.8 → bub-0.3.9}/src/bub/configure.py +0 -0
  44. {bub-0.3.8 → bub-0.3.9}/src/bub/envelope.py +0 -0
  45. {bub-0.3.8 → bub-0.3.9}/src/bub/hook_runtime.py +0 -0
  46. {bub-0.3.8 → bub-0.3.9}/src/bub/inquirer.py +0 -0
  47. {bub-0.3.8 → bub-0.3.9}/src/bub/skills.py +0 -0
  48. {bub-0.3.8 → bub-0.3.9}/src/bub/types.py +0 -0
  49. {bub-0.3.8 → bub-0.3.9}/src/bub/utils.py +0 -0
  50. {bub-0.3.8 → bub-0.3.9}/src/skills/README.md +0 -0
  51. {bub-0.3.8 → bub-0.3.9}/src/skills/gh/SKILL.md +0 -0
  52. {bub-0.3.8 → bub-0.3.9}/src/skills/skill-creator/SKILL.md +0 -0
  53. {bub-0.3.8 → bub-0.3.9}/src/skills/skill-creator/license.txt +0 -0
  54. {bub-0.3.8 → bub-0.3.9}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  55. {bub-0.3.8 → bub-0.3.9}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  56. {bub-0.3.8 → bub-0.3.9}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  57. {bub-0.3.8 → bub-0.3.9}/tests/conftest.py +0 -0
  58. {bub-0.3.8 → bub-0.3.9}/tests/test_builtin_cli.py +0 -0
  59. {bub-0.3.8 → bub-0.3.9}/tests/test_builtin_tools.py +0 -0
  60. {bub-0.3.8 → bub-0.3.9}/tests/test_cli_help.py +0 -0
  61. {bub-0.3.8 → bub-0.3.9}/tests/test_configure.py +0 -0
  62. {bub-0.3.8 → bub-0.3.9}/tests/test_envelope.py +0 -0
  63. {bub-0.3.8 → bub-0.3.9}/tests/test_file_tape_store_entry_ids.py +0 -0
  64. {bub-0.3.8 → bub-0.3.9}/tests/test_fork_store_merge_back.py +0 -0
  65. {bub-0.3.8 → bub-0.3.9}/tests/test_hook_runtime.py +0 -0
  66. {bub-0.3.8 → bub-0.3.9}/tests/test_image_message.py +0 -0
  67. {bub-0.3.8 → bub-0.3.9}/tests/test_settings.py +0 -0
  68. {bub-0.3.8 → bub-0.3.9}/tests/test_skills.py +0 -0
  69. {bub-0.3.8 → bub-0.3.9}/tests/test_subagent_tool.py +0 -0
  70. {bub-0.3.8 → bub-0.3.9}/tests/test_tape_search_output.py +0 -0
  71. {bub-0.3.8 → bub-0.3.9}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bub
3
- Version: 0.3.8
3
+ Version: 0.3.9
4
4
  Summary: A common shape for agents that live alongside people.
5
5
  Project-URL: Homepage, https://bub.build
6
6
  Project-URL: Repository, https://github.com/bubbuild/bub
@@ -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
- __all__ = ["BubFramework", "Settings", "config", "ensure_config", "home", "hookimpl", "tool"]
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.8'
22
- __version_tuple__ = version_tuple = (0, 3, 8)
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 = {name.casefold() for name in 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.casefold() in allowed_tools]
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(self, prompt: str, state: State, allowed_skills: set[str] | None = None) -> str:
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 UTC, datetime
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(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
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
- token = current_store.set(store)
105
- tape_token = current_fork_tape.set(tape)
106
- reset_token = current_tape_was_reset.set(False)
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.reset(token)
112
- current_fork_tape.reset(tape_token)
113
- current_tape_was_reset.reset(reset_token)
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)
@@ -0,0 +1,5 @@
1
+ from .base import Channel, Interface, Lifecycle
2
+ from .manager import ChannelManager
3
+ from .message import ChannelMessage
4
+
5
+ __all__ = ["Channel", "ChannelManager", "ChannelMessage", "Interface", "Lifecycle"]
@@ -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 Channel
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(Channel):
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
- live: Live | None = None
145
- text = ""
146
- try:
147
- async for event in stream:
148
- if event.kind == "text":
149
- content = str(event.data.get("delta", ""))
150
- if not content.strip() and not text:
151
- continue # skip leading whitespace-only events
152
- text += content
153
- if live is None:
154
- live = self._renderer.start_stream(message.kind, text)
155
- else:
156
- self._renderer.update_stream(live, kind=message.kind, text=text)
157
- yield event
158
- finally:
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(self.panel("command", text))
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(self.panel("normal", text))
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(self.panel("error", text))
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()