bub 0.3.7__tar.gz → 0.3.8__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 (66) hide show
  1. {bub-0.3.7 → bub-0.3.8}/.gitignore +2 -0
  2. {bub-0.3.7 → bub-0.3.8}/PKG-INFO +14 -10
  3. {bub-0.3.7 → bub-0.3.8}/README.md +13 -9
  4. {bub-0.3.7 → bub-0.3.8}/src/bub/__main__.py +9 -4
  5. {bub-0.3.7 → bub-0.3.8}/src/bub/_version.py +2 -2
  6. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/agent.py +1 -1
  7. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/cli.py +9 -2
  8. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/settings.py +1 -1
  9. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/shell_manager.py +16 -2
  10. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/tools.py +9 -3
  11. {bub-0.3.7 → bub-0.3.8}/src/bub/channels/cli/__init__.py +2 -2
  12. {bub-0.3.7 → bub-0.3.8}/src/bub/channels/manager.py +29 -20
  13. {bub-0.3.7 → bub-0.3.8}/src/bub/channels/telegram.py +2 -2
  14. {bub-0.3.7 → bub-0.3.8}/src/bub/configure.py +47 -0
  15. {bub-0.3.7 → bub-0.3.8}/src/bub/framework.py +21 -2
  16. {bub-0.3.7 → bub-0.3.8}/src/bub/skills.py +27 -3
  17. {bub-0.3.7 → bub-0.3.8}/src/skills/skill-creator/SKILL.md +2 -2
  18. {bub-0.3.7 → bub-0.3.8}/src/skills/telegram/SKILL.md +6 -4
  19. {bub-0.3.7 → bub-0.3.8}/tests/test_builtin_cli.py +71 -8
  20. {bub-0.3.7 → bub-0.3.8}/tests/test_builtin_tools.py +68 -3
  21. {bub-0.3.7 → bub-0.3.8}/tests/test_channels.py +124 -0
  22. {bub-0.3.7 → bub-0.3.8}/tests/test_configure.py +56 -0
  23. {bub-0.3.7 → bub-0.3.8}/tests/test_framework.py +32 -0
  24. {bub-0.3.7 → bub-0.3.8}/tests/test_skills.py +53 -0
  25. {bub-0.3.7 → bub-0.3.8}/LICENSE +0 -0
  26. {bub-0.3.7 → bub-0.3.8}/pyproject.toml +0 -0
  27. {bub-0.3.7 → bub-0.3.8}/src/bub/__init__.py +0 -0
  28. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/__init__.py +0 -0
  29. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/auth.py +0 -0
  30. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/context.py +0 -0
  31. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/hook_impl.py +0 -0
  32. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/store.py +0 -0
  33. {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/tape.py +0 -0
  34. {bub-0.3.7 → bub-0.3.8}/src/bub/channels/__init__.py +0 -0
  35. {bub-0.3.7 → bub-0.3.8}/src/bub/channels/base.py +0 -0
  36. {bub-0.3.7 → bub-0.3.8}/src/bub/channels/cli/renderer.py +0 -0
  37. {bub-0.3.7 → bub-0.3.8}/src/bub/channels/handler.py +0 -0
  38. {bub-0.3.7 → bub-0.3.8}/src/bub/channels/message.py +0 -0
  39. {bub-0.3.7 → bub-0.3.8}/src/bub/envelope.py +0 -0
  40. {bub-0.3.7 → bub-0.3.8}/src/bub/hook_runtime.py +0 -0
  41. {bub-0.3.7 → bub-0.3.8}/src/bub/hookspecs.py +0 -0
  42. {bub-0.3.7 → bub-0.3.8}/src/bub/inquirer.py +0 -0
  43. {bub-0.3.7 → bub-0.3.8}/src/bub/tools.py +0 -0
  44. {bub-0.3.7 → bub-0.3.8}/src/bub/types.py +0 -0
  45. {bub-0.3.7 → bub-0.3.8}/src/bub/utils.py +0 -0
  46. {bub-0.3.7 → bub-0.3.8}/src/skills/README.md +0 -0
  47. {bub-0.3.7 → bub-0.3.8}/src/skills/gh/SKILL.md +0 -0
  48. {bub-0.3.7 → bub-0.3.8}/src/skills/skill-creator/license.txt +0 -0
  49. {bub-0.3.7 → bub-0.3.8}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  50. {bub-0.3.7 → bub-0.3.8}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  51. {bub-0.3.7 → bub-0.3.8}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  52. {bub-0.3.7 → bub-0.3.8}/src/skills/telegram/scripts/telegram_send.py +0 -0
  53. {bub-0.3.7 → bub-0.3.8}/tests/conftest.py +0 -0
  54. {bub-0.3.7 → bub-0.3.8}/tests/test_builtin_agent.py +0 -0
  55. {bub-0.3.7 → bub-0.3.8}/tests/test_builtin_hook_impl.py +0 -0
  56. {bub-0.3.7 → bub-0.3.8}/tests/test_cli_help.py +0 -0
  57. {bub-0.3.7 → bub-0.3.8}/tests/test_envelope.py +0 -0
  58. {bub-0.3.7 → bub-0.3.8}/tests/test_file_tape_store_entry_ids.py +0 -0
  59. {bub-0.3.7 → bub-0.3.8}/tests/test_fork_store_merge_back.py +0 -0
  60. {bub-0.3.7 → bub-0.3.8}/tests/test_hook_runtime.py +0 -0
  61. {bub-0.3.7 → bub-0.3.8}/tests/test_image_message.py +0 -0
  62. {bub-0.3.7 → bub-0.3.8}/tests/test_settings.py +0 -0
  63. {bub-0.3.7 → bub-0.3.8}/tests/test_subagent_tool.py +0 -0
  64. {bub-0.3.7 → bub-0.3.8}/tests/test_tape_search_output.py +0 -0
  65. {bub-0.3.7 → bub-0.3.8}/tests/test_tools.py +0 -0
  66. {bub-0.3.7 → bub-0.3.8}/tests/test_utils.py +0 -0
@@ -142,6 +142,8 @@ cython_debug/
142
142
 
143
143
  # Reference directory - ignore all reference projects
144
144
  reference/
145
+ !website/src/content/docs/**/build
146
+ !website/src/content/docs/**/reference
145
147
 
146
148
  # Local legacy backups created during framework migrations
147
149
  backup/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bub
3
- Version: 0.3.7
3
+ Version: 0.3.8
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
@@ -106,24 +106,28 @@ Key source files:
106
106
 
107
107
  ```python
108
108
  from bub import hookimpl
109
+ from bub.envelope import content_of
109
110
 
110
111
 
111
112
  class EchoPlugin:
112
113
  @hookimpl
113
114
  def build_prompt(self, message, session_id, state):
114
- return f"[echo] {message['content']}"
115
+ return f"[echo] {content_of(message)}"
115
116
 
116
117
  @hookimpl
117
118
  async def run_model(self, prompt, session_id, state):
118
119
  return prompt
120
+
121
+
122
+ echo_plugin = EchoPlugin()
119
123
  ```
120
124
 
121
125
  ```toml
122
126
  [project.entry-points."bub"]
123
- echo = "my_package.plugin:EchoPlugin"
127
+ echo = "my_package.plugin:echo_plugin"
124
128
  ```
125
129
 
126
- See the [Extending docs](https://bub.build/docs/extending/) for hook guides, packaging, and plugin structure.
130
+ See the [Build docs](https://bub.build/docs/build/) for hook guides, packaging, and plugin structure.
127
131
 
128
132
  ## CLI
129
133
 
@@ -150,7 +154,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
150
154
  | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
151
155
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
152
156
  | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
153
- | `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
157
+ | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
154
158
  | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
155
159
 
156
160
  ## Background
@@ -166,11 +170,11 @@ Read more:
166
170
  ## Docs
167
171
 
168
172
  - [Getting Started](https://bub.build/docs/getting-started/) — install Bub and run the first turn
169
- - [Architecture](https://bub.build/docs/concepts/architecture/) — the mental model behind the runtime
170
- - [Channels](https://bub.build/docs/guides/channels/) — run Bub in CLI, Telegram, or your own channel
171
- - [Skills](https://bub.build/docs/guides/skills/) — discover, inspect, and author Agent Skills in Bub
172
- - [Extending](https://bub.build/docs/extending/) — write plugins, override hooks, ship tools and skills
173
- - [Deployment](https://bub.build/docs/guides/deployment/) — Docker, environment, upgrades
173
+ - [Concepts](https://bub.build/docs/concepts/) — the mental model behind the runtime
174
+ - [Channels](https://bub.build/docs/operate/channels/) — run Bub in CLI, Telegram, or your own channel
175
+ - [Skills](https://bub.build/docs/build/skills/) — discover, inspect, and author Agent Skills in Bub
176
+ - [Build](https://bub.build/docs/build/) — write plugins, override hooks, ship tools and skills
177
+ - [Deployment](https://bub.build/docs/operate/deploy/) — Docker, environment, upgrades
174
178
 
175
179
  ## Development
176
180
 
@@ -70,24 +70,28 @@ Key source files:
70
70
 
71
71
  ```python
72
72
  from bub import hookimpl
73
+ from bub.envelope import content_of
73
74
 
74
75
 
75
76
  class EchoPlugin:
76
77
  @hookimpl
77
78
  def build_prompt(self, message, session_id, state):
78
- return f"[echo] {message['content']}"
79
+ return f"[echo] {content_of(message)}"
79
80
 
80
81
  @hookimpl
81
82
  async def run_model(self, prompt, session_id, state):
82
83
  return prompt
84
+
85
+
86
+ echo_plugin = EchoPlugin()
83
87
  ```
84
88
 
85
89
  ```toml
86
90
  [project.entry-points."bub"]
87
- echo = "my_package.plugin:EchoPlugin"
91
+ echo = "my_package.plugin:echo_plugin"
88
92
  ```
89
93
 
90
- See the [Extending docs](https://bub.build/docs/extending/) for hook guides, packaging, and plugin structure.
94
+ See the [Build docs](https://bub.build/docs/build/) for hook guides, packaging, and plugin structure.
91
95
 
92
96
  ## CLI
93
97
 
@@ -114,7 +118,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
114
118
  | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
115
119
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
116
120
  | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
117
- | `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
121
+ | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
118
122
  | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
119
123
 
120
124
  ## Background
@@ -130,11 +134,11 @@ Read more:
130
134
  ## Docs
131
135
 
132
136
  - [Getting Started](https://bub.build/docs/getting-started/) — install Bub and run the first turn
133
- - [Architecture](https://bub.build/docs/concepts/architecture/) — the mental model behind the runtime
134
- - [Channels](https://bub.build/docs/guides/channels/) — run Bub in CLI, Telegram, or your own channel
135
- - [Skills](https://bub.build/docs/guides/skills/) — discover, inspect, and author Agent Skills in Bub
136
- - [Extending](https://bub.build/docs/extending/) — write plugins, override hooks, ship tools and skills
137
- - [Deployment](https://bub.build/docs/guides/deployment/) — Docker, environment, upgrades
137
+ - [Concepts](https://bub.build/docs/concepts/) — the mental model behind the runtime
138
+ - [Channels](https://bub.build/docs/operate/channels/) — run Bub in CLI, Telegram, or your own channel
139
+ - [Skills](https://bub.build/docs/build/skills/) — discover, inspect, and author Agent Skills in Bub
140
+ - [Build](https://bub.build/docs/build/) — write plugins, override hooks, ship tools and skills
141
+ - [Deployment](https://bub.build/docs/operate/deploy/) — Docker, environment, upgrades
138
142
 
139
143
  ## Development
140
144
 
@@ -2,22 +2,27 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sys
6
+
5
7
  import typer
6
8
 
7
9
  from bub.framework import BubFramework
8
10
 
9
11
 
10
12
  def _instrument_bub() -> None:
13
+ from loguru import logger
14
+
15
+ logger.remove()
16
+ logger.add(sys.stderr, colorize=True)
17
+
11
18
  try:
12
19
  import logfire
20
+ from logfire.integrations.loguru import LogfireHandler
13
21
 
14
22
  logfire.configure()
23
+ logger.add(LogfireHandler(), format="{message}")
15
24
  except ImportError:
16
25
  pass
17
- else:
18
- from loguru import logger
19
-
20
- logger.configure(handlers=[logfire.loguru_handler()])
21
26
 
22
27
 
23
28
  def create_cli_app() -> typer.Typer:
@@ -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.7'
22
- __version_tuple__ = version_tuple = (0, 3, 7)
21
+ __version__ = version = '0.3.8'
22
+ __version_tuple__ = version_tuple = (0, 3, 8)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -41,7 +41,7 @@ from bub.utils import workspace_from_state
41
41
  CONTINUE_PROMPT = "Continue the task."
42
42
  HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
43
43
  _CONTEXT_LENGTH_PATTERNS = re.compile(
44
- r"context.{0,20}length|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum",
44
+ r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum",
45
45
  re.IGNORECASE,
46
46
  )
47
47
  MAX_AUTO_HANDOFF_RETRIES = 1
@@ -21,6 +21,7 @@ from bub.builtin.auth import app as login_app # noqa: F401
21
21
  from bub.channels.message import ChannelMessage
22
22
  from bub.envelope import field_of
23
23
  from bub.framework import BubFramework
24
+ from bub.types import TurnResult
24
25
 
25
26
  ONBOARD_BANNER = r"""
26
27
  ███████████ █████
@@ -53,7 +54,11 @@ def run(
53
54
  context={"sender_id": sender_id},
54
55
  )
55
56
 
56
- result = asyncio.run(framework.process_inbound(inbound))
57
+ async def _run() -> TurnResult:
58
+ async with framework.running():
59
+ return await framework.process_inbound(inbound)
60
+
61
+ result = asyncio.run(_run())
57
62
  for outbound in result.outbounds:
58
63
  rendered = str(field_of(outbound, "content", ""))
59
64
  target_channel = str(field_of(outbound, "channel", "stdout"))
@@ -128,8 +133,10 @@ def _find_uv() -> str:
128
133
  import shutil
129
134
  import sysconfig
130
135
 
136
+ default_path = Path.home() / ".local" / "bin"
137
+
131
138
  bin_path = sysconfig.get_path("scripts")
132
- uv_path = shutil.which("uv", path=os.pathsep.join([bin_path, os.getenv("PATH", "")]))
139
+ uv_path = shutil.which("uv", path=os.pathsep.join([bin_path, str(default_path), os.getenv("PATH", "")]))
133
140
  if uv_path is None:
134
141
  raise FileNotFoundError("uv executable not found in PATH or scripts directory.")
135
142
  return uv_path
@@ -12,7 +12,7 @@ from pydantic_settings import SettingsConfigDict
12
12
  from bub import Settings, config, ensure_config
13
13
 
14
14
  DEFAULT_MODEL = "openrouter:openrouter/free"
15
- DEFAULT_MAX_TOKENS = 1024
15
+ DEFAULT_MAX_TOKENS = 16384
16
16
 
17
17
 
18
18
  def provider_specific(setting_name: str) -> Callable[[], dict[str, str] | None]:
@@ -13,6 +13,7 @@ class ManagedShell:
13
13
  shell_id: str
14
14
  cmd: str
15
15
  cwd: str | None
16
+ session_id: str | None
16
17
  process: asyncio.subprocess.Process
17
18
  output_chunks: list[str] = field(default_factory=list)
18
19
  read_tasks: list[asyncio.Task[None]] = field(default_factory=list)
@@ -36,7 +37,7 @@ class ShellManager:
36
37
  def __init__(self) -> None:
37
38
  self._shells: dict[str, ManagedShell] = {}
38
39
 
39
- async def start(self, *, cmd: str, cwd: str | None) -> ManagedShell:
40
+ async def start(self, *, cmd: str, cwd: str | None, session_id: str | None = None) -> ManagedShell:
40
41
  process = await asyncio.create_subprocess_shell(
41
42
  cmd,
42
43
  cwd=cwd,
@@ -44,7 +45,13 @@ class ShellManager:
44
45
  stderr=asyncio.subprocess.PIPE,
45
46
  executable=self.SHELL,
46
47
  )
47
- shell = ManagedShell(shell_id=f"bash-{uuid.uuid4().hex[:8]}", cmd=cmd, cwd=cwd, process=process)
48
+ shell = ManagedShell(
49
+ shell_id=f"bash-{uuid.uuid4().hex[:8]}",
50
+ cmd=cmd,
51
+ cwd=cwd,
52
+ session_id=session_id,
53
+ process=process,
54
+ )
48
55
  shell.read_tasks.extend([
49
56
  asyncio.create_task(self._drain_stream(shell, process.stdout)),
50
57
  asyncio.create_task(self._drain_stream(shell, process.stderr)),
@@ -77,6 +84,13 @@ class ShellManager:
77
84
  await self._finalize_shell(shell)
78
85
  return shell
79
86
 
87
+ async def terminate_session(self, session_id: str) -> int:
88
+ shell_ids = [shell.shell_id for shell in self._shells.values() if shell.session_id == session_id]
89
+ for shell_id in shell_ids:
90
+ with contextlib.suppress(KeyError):
91
+ await self.terminate(shell_id)
92
+ return len(shell_ids)
93
+
80
94
  async def wait_closed(self, shell_id: str) -> ManagedShell:
81
95
  shell = self.get(shell_id)
82
96
  if shell.returncode is None:
@@ -79,12 +79,17 @@ async def bash(
79
79
  """Run a shell command. Use background=true to keep it running and fetch output later via bash_output."""
80
80
  workspace = context.state.get("_runtime_workspace")
81
81
  target_cwd = cwd or workspace
82
- shell = await shell_manager.start(cmd=cmd, cwd=target_cwd)
82
+ raw_session_id = context.state.get("session_id")
83
+ session_id = str(raw_session_id) if raw_session_id is not None else None
84
+ shell = await shell_manager.start(cmd=cmd, cwd=target_cwd, session_id=session_id)
83
85
  if background:
84
86
  return f"started: {shell.shell_id}"
85
87
  try:
86
88
  async with asyncio.timeout(timeout_seconds):
87
89
  shell = await shell_manager.wait_closed(shell.shell_id)
90
+ except asyncio.CancelledError:
91
+ await shell_manager.terminate(shell.shell_id)
92
+ raise
88
93
  except TimeoutError:
89
94
  await shell_manager.terminate(shell.shell_id)
90
95
  return f"command timed out after {timeout_seconds} seconds and was terminated"
@@ -307,9 +312,10 @@ def show_help() -> str:
307
312
 
308
313
  @tool(name="quit", context=True)
309
314
  async def quit_tool(*, context: ToolContext) -> str:
310
- """Quit the tasks of the current session."""
315
+ """Abort the tasks of the current session. DO NOT use it in a normal workflow."""
311
316
  agent = _get_agent(context)
312
- session_id = context.state.get("session_id", "temp/unknown")
317
+ session_id = str(context.state.get("session_id", "temp/unknown"))
318
+ await shell_manager.terminate_session(session_id)
313
319
  await agent.framework.quit_via_router(session_id)
314
320
  return "Session tasks stopped."
315
321
 
@@ -44,14 +44,13 @@ class CliChannel(Channel):
44
44
  self._mode = "agent" # or "shell"
45
45
  self._main_task: asyncio.Task | None = None
46
46
  self._renderer = CliRenderer(get_console())
47
- self._log_handler_id = self._install_log_sink()
48
47
  self._last_tape_info: TapeInfo | None = None
49
48
  self._workspace = self._agent.framework.workspace
50
49
  self._prompt = self._build_prompt(self._workspace)
51
50
 
52
51
  def _install_log_sink(self) -> int:
53
52
  with contextlib.suppress(ValueError):
54
- logger.remove(0)
53
+ logger.remove()
55
54
  return logger.add(self._renderer.log, colorize=False, format="{level:<8} | {message}")
56
55
 
57
56
  async def _refresh_tape_info(self) -> None:
@@ -66,6 +65,7 @@ class CliChannel(Channel):
66
65
  self._message_template["chat_id"] = chat_id
67
66
 
68
67
  async def start(self, stop_event: asyncio.Event) -> None:
68
+ self._log_handler_id = self._install_log_sink()
69
69
  self._stop_event = stop_event
70
70
  self._main_task = asyncio.create_task(self._main_loop())
71
71
 
@@ -118,11 +118,16 @@ class ChannelManager:
118
118
 
119
119
  async def quit(self, session_id: str) -> None:
120
120
  tasks = self._ongoing_tasks.pop(session_id, set())
121
+ current_task = asyncio.current_task()
122
+ cancelled_count = 0
121
123
  for task in tasks:
124
+ if task is current_task:
125
+ continue
122
126
  task.cancel()
123
127
  with contextlib.suppress(asyncio.CancelledError):
124
128
  await task
125
- logger.info(f"channel.manager quit session_id={session_id}, cancelled {len(tasks)} tasks")
129
+ cancelled_count += 1
130
+ logger.info(f"channel.manager quit session_id={session_id}, cancelled {cancelled_count} tasks")
126
131
 
127
132
  def enabled_channels(self) -> list[Channel]:
128
133
  if "all" in self._enabled_channels:
@@ -133,7 +138,10 @@ class ChannelManager:
133
138
  ]
134
139
 
135
140
  def _on_task_done(self, session_id: str, task: asyncio.Task) -> None:
136
- task.exception() # to log any exception
141
+ if task.cancelled():
142
+ logger.info("channel.manager task cancelled session_id={}", session_id)
143
+ else:
144
+ task.exception() # to log any exception
137
145
  tasks = self._ongoing_tasks.get(session_id, set())
138
146
  tasks.discard(task)
139
147
  if not tasks:
@@ -142,24 +150,25 @@ class ChannelManager:
142
150
  async def listen_and_run(self) -> None:
143
151
  stop_event = asyncio.Event()
144
152
  self.framework.bind_outbound_router(self)
145
- for channel in self.enabled_channels():
146
- await channel.start(stop_event)
147
- logger.info("channel.manager started listening")
148
- try:
149
- while True:
150
- message = await wait_until_stopped(self._messages.get(), stop_event)
151
- task = asyncio.create_task(self.framework.process_inbound(message, self._stream_output))
152
- task.add_done_callback(functools.partial(self._on_task_done, message.session_id))
153
- self._ongoing_tasks.setdefault(message.session_id, set()).add(task)
154
- except asyncio.CancelledError:
155
- logger.info("channel.manager received shutdown signal")
156
- except Exception:
157
- logger.exception("channel.manager error")
158
- raise
159
- finally:
160
- self.framework.bind_outbound_router(None)
161
- await self.shutdown()
162
- logger.info("channel.manager stopped")
153
+ async with self.framework.running():
154
+ for channel in self.enabled_channels():
155
+ await channel.start(stop_event)
156
+ logger.info("channel.manager started listening")
157
+ try:
158
+ while True:
159
+ message = await wait_until_stopped(self._messages.get(), stop_event)
160
+ task = asyncio.create_task(self.framework.process_inbound(message, self._stream_output))
161
+ task.add_done_callback(functools.partial(self._on_task_done, message.session_id))
162
+ self._ongoing_tasks.setdefault(message.session_id, set()).add(task)
163
+ except asyncio.CancelledError:
164
+ logger.info("channel.manager received shutdown signal")
165
+ except Exception:
166
+ logger.exception("channel.manager error")
167
+ raise
168
+ finally:
169
+ self.framework.bind_outbound_router(None)
170
+ await self.shutdown()
171
+ logger.info("channel.manager stopped")
163
172
 
164
173
  async def shutdown(self) -> None:
165
174
  count = 0
@@ -173,10 +173,10 @@ class TelegramChannel(Channel):
173
173
  len(self._allow_chats),
174
174
  bool(proxy),
175
175
  )
176
- get_updates_request = HTTPXRequest(read_timeout=30)
176
+ get_updates_request = HTTPXRequest(read_timeout=30, proxy=proxy)
177
177
  builder = Application.builder().token(self._settings.token).get_updates_request(get_updates_request)
178
178
  if proxy:
179
- builder = builder.proxy(proxy).get_updates_proxy(proxy)
179
+ builder = builder.proxy(proxy)
180
180
  self._app = builder.build()
181
181
  self._app.add_handler(CommandHandler("start", self._on_start))
182
182
  self._app.add_handler(CommandHandler("bub", self._on_message, has_args=True, block=False))
@@ -6,6 +6,7 @@ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
6
6
 
7
7
  CONFIG_MAP: dict[str, list[type[BaseSettings]]] = {}
8
8
  ROOT = ""
9
+ MISSING = object()
9
10
 
10
11
  _global_config: dict[str, list[BaseSettings]] = {}
11
12
  _config_data: dict[str, Any] = {}
@@ -95,6 +96,22 @@ def ensure_config[C: BaseSettings](config_cls: type[C]) -> C:
95
96
  return instance
96
97
 
97
98
 
99
+ def get_value(path: str, default: Any = MISSING) -> Any:
100
+ """Get a loaded config value by dotted path, preserving registered settings behavior."""
101
+
102
+ parts = [part for part in path.split(".") if part]
103
+ if not parts:
104
+ raise ValueError("config path must not be empty")
105
+
106
+ value = _lookup_registered_config(parts)
107
+ if value is not MISSING:
108
+ return value
109
+
110
+ if default is not MISSING:
111
+ return default
112
+ raise KeyError(path)
113
+
114
+
98
115
  def _copy_dict(data: dict[str, Any]) -> dict[str, Any]:
99
116
  copied: dict[str, Any] = {}
100
117
  for key, value in data.items():
@@ -105,6 +122,36 @@ def _copy_dict(data: dict[str, Any]) -> dict[str, Any]:
105
122
  return copied
106
123
 
107
124
 
125
+ def _lookup_registered_config(parts: list[str]) -> Any:
126
+ section, *subpath = parts
127
+ if section in CONFIG_MAP and section != ROOT:
128
+ for config_cls in CONFIG_MAP[section]:
129
+ value = _lookup_path(ensure_config(config_cls), subpath)
130
+ if value is not MISSING:
131
+ return value
132
+
133
+ for config_cls in CONFIG_MAP.get(ROOT, []):
134
+ value = _lookup_path(ensure_config(config_cls), parts)
135
+ if value is not MISSING:
136
+ return value
137
+
138
+ return MISSING
139
+
140
+
141
+ def _lookup_path(value: Any, parts: list[str]) -> Any:
142
+ current = value
143
+ for part in parts:
144
+ if isinstance(current, dict):
145
+ if part not in current:
146
+ return MISSING
147
+ current = current[part]
148
+ continue
149
+ if not hasattr(current, part):
150
+ return MISSING
151
+ current = getattr(current, part)
152
+ return current
153
+
154
+
108
155
  def _merge_into(target: dict[str, Any], incoming: dict[str, Any], path: tuple[str, ...]) -> None:
109
156
  for key, value in incoming.items():
110
157
  existing = target.get(key)
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
6
+ from collections.abc import AsyncGenerator, AsyncIterator, Iterator
5
7
  from dataclasses import dataclass
6
8
  from pathlib import Path
7
9
  from typing import TYPE_CHECKING, Any
@@ -46,6 +48,7 @@ class BubFramework:
46
48
  self._hook_runtime = HookRuntime(self._plugin_manager)
47
49
  self._plugin_status: dict[str, PluginStatus] = {}
48
50
  self._outbound_router: OutboundChannelRouter | None = None
51
+ self._tape_store: TapeStore | AsyncTapeStore | None = None
49
52
  configure.load(self.config_file)
50
53
 
51
54
  def _load_builtin_hooks(self) -> None:
@@ -253,8 +256,24 @@ class BubFramework:
253
256
  channels[channel.name] = channel
254
257
  return channels
255
258
 
259
+ @contextlib.asynccontextmanager
260
+ async def running(self) -> AsyncGenerator[contextlib.AsyncExitStack, None]:
261
+ async with contextlib.AsyncExitStack() as stack:
262
+ tape_store = self._hook_runtime.call_first_sync("provide_tape_store")
263
+ # Allow plugins to return either TapeStore/AsyncTapeStore instances or context managers for them
264
+ # This benefits plugins that need to initialize and clean up resources with the tape store.
265
+ if isinstance(tape_store, AsyncIterator):
266
+ tape_store = await stack.enter_async_context(contextlib.asynccontextmanager(lambda: tape_store)())
267
+ elif isinstance(tape_store, Iterator):
268
+ tape_store = stack.enter_context(contextlib.contextmanager(lambda: tape_store)())
269
+ self._tape_store = tape_store
270
+ try:
271
+ yield stack
272
+ finally:
273
+ self._tape_store = None
274
+
256
275
  def get_tape_store(self) -> TapeStore | AsyncTapeStore | None:
257
- return self._hook_runtime.call_first_sync("provide_tape_store")
276
+ return self._tape_store
258
277
 
259
278
  def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) -> str:
260
279
  return "\n\n".join(
@@ -269,7 +288,7 @@ class BubFramework:
269
288
  def collect_onboard_config(self) -> dict[str, Any]:
270
289
  current_config: dict[str, Any] = {}
271
290
 
272
- for impl in self._hook_runtime._iter_hookimpls("onboard_config"):
291
+ for impl in reversed(list(self._hook_runtime._iter_hookimpls("onboard_config"))):
273
292
  result = self._hook_runtime._invoke_impl_sync(
274
293
  hook_name="onboard_config",
275
294
  impl=impl,
@@ -13,11 +13,14 @@ from typing import Any
13
13
 
14
14
  import yaml
15
15
 
16
+ import bub.configure as configure
17
+
16
18
  PROJECT_SKILLS_DIR = ".agents/skills"
17
19
  LEGACY_SKILLS_DIR = ".agent/skills"
18
20
  SKILL_FILE_NAME = "SKILL.md"
19
21
  SKILL_SOURCES = ("project", "global", "builtin")
20
22
  SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
23
+ CONFIG_TEMPLATE_PATTERN = re.compile(r"\$\{\s*config\.([a-zA-Z0-9_.-]+)\s*\}")
21
24
 
22
25
 
23
26
  @dataclass(frozen=True)
@@ -33,11 +36,15 @@ class SkillMetadata:
33
36
  def body(self) -> str:
34
37
  front_matter_pattern = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
35
38
  try:
36
- template = string.Template(self.location.read_text(encoding="utf-8").strip())
39
+ template_content = self.location.read_text(encoding="utf-8").strip()
37
40
  except OSError:
38
41
  return ""
39
- content = template.safe_substitute({"SKILL_DIR": str(self.location.parent), "PYTHON": sys.executable})
40
- return front_matter_pattern.sub("", content, count=1).strip()
42
+ raw_content = front_matter_pattern.sub("", template_content, count=1).strip()
43
+ content = _render_config_templates(raw_content)
44
+ return string.Template(content).safe_substitute({
45
+ "SKILL_DIR": str(self.location.parent),
46
+ "PYTHON": sys.executable,
47
+ })
41
48
 
42
49
 
43
50
  def discover_skills(workspace_path: Path) -> list[SkillMetadata]:
@@ -60,6 +67,23 @@ def discover_skills(workspace_path: Path) -> list[SkillMetadata]:
60
67
  return sorted(skills_by_name.values(), key=lambda item: item.name.casefold())
61
68
 
62
69
 
70
+ def _render_config_templates(content: str) -> str:
71
+ def replace(match: re.Match[str]) -> str:
72
+ try:
73
+ value = configure.get_value(match.group(1), default="")
74
+ except KeyError:
75
+ return match.group(0)
76
+ if isinstance(value, str):
77
+ return value
78
+ if isinstance(value, bool):
79
+ return "true" if value else "false"
80
+ if isinstance(value, int | float):
81
+ return str(value)
82
+ return yaml.safe_dump(value, sort_keys=False).strip()
83
+
84
+ return CONFIG_TEMPLATE_PATTERN.sub(replace, content)
85
+
86
+
63
87
  def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None:
64
88
  skill_file = skill_dir / SKILL_FILE_NAME
65
89
  if not skill_file.is_file():
@@ -20,8 +20,8 @@ equipped with procedural knowledge that no model can fully possess.
20
20
 
21
21
  When creating a skill, place it in one of these two roots:
22
22
 
23
- 1. Project-local: `$workspace/.agent/skills/<skill-name>`
24
- 2. Global: `~/.agent/skills/<skill-name>` (shared across workspaces)
23
+ 1. Project-local: `$workspace/.agents/skills/<skill-name>`
24
+ 2. Global: `~/.agents/skills/<skill-name>` (shared across workspaces)
25
25
 
26
26
  Prefer project-local by default. Use global only when the user explicitly wants the skill available across multiple workspaces.
27
27
 
@@ -13,7 +13,9 @@ metadata:
13
13
 
14
14
  Agent-facing execution guide for Telegram outbound communication.
15
15
 
16
- Assumption: `BUB_TELEGRAM_TOKEN` is already available.
16
+ Env vars:
17
+
18
+ - `BUB_TELEGRAM_TOKEN=${config.telegram.token}`
17
19
 
18
20
  ## Required Inputs
19
21
  Collect these before execution:
@@ -76,18 +78,18 @@ Paths are relative to this skill directory.
76
78
 
77
79
  ```bash
78
80
  # Send message (ALWAYS use heredoc stdin, never inline text in arguments)
79
- cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --message -
81
+ cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --message -
80
82
  Your message content here.
81
83
  Special characters are safe: $100, "quotes", 'apostrophes', !exclamation
82
84
  EOF
83
85
 
84
86
  # Reply to a specific message
85
- cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --reply-to <MESSAGE_ID> --message -
87
+ cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --reply-to <MESSAGE_ID> --message -
86
88
  Reply content here.
87
89
  EOF
88
90
 
89
91
  # Edit an existing message
90
- cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_edit.py --chat-id <CHAT_ID> --message-id <MESSAGE_ID> --text -
92
+ cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_edit.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --message-id <MESSAGE_ID> --text -
91
93
  Updated content here.
92
94
  EOF
93
95
  ```