bub 0.3.1__tar.gz → 0.3.2__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 (61) hide show
  1. {bub-0.3.1 → bub-0.3.2}/PKG-INFO +27 -18
  2. {bub-0.3.1 → bub-0.3.2}/README.md +26 -17
  3. {bub-0.3.1 → bub-0.3.2}/pyproject.toml +1 -1
  4. {bub-0.3.1 → bub-0.3.2}/src/bub/__init__.py +1 -1
  5. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/agent.py +2 -6
  6. bub-0.3.2/src/bub/builtin/settings.py +71 -0
  7. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/shell_manager.py +4 -0
  8. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/tools.py +2 -5
  9. {bub-0.3.1 → bub-0.3.2}/src/bub/framework.py +4 -0
  10. {bub-0.3.1 → bub-0.3.2}/src/bub/skills.py +1 -1
  11. {bub-0.3.1 → bub-0.3.2}/src/bub/tools.py +45 -0
  12. {bub-0.3.1 → bub-0.3.2}/src/skills/telegram/SKILL.md +0 -1
  13. {bub-0.3.1 → bub-0.3.2}/tests/test_builtin_tools.py +27 -3
  14. bub-0.3.2/tests/test_settings.py +134 -0
  15. {bub-0.3.1 → bub-0.3.2}/tests/test_subagent_tool.py +48 -0
  16. {bub-0.3.1 → bub-0.3.2}/tests/test_tools.py +31 -1
  17. bub-0.3.1/src/bub/builtin/settings.py +0 -63
  18. bub-0.3.1/tests/test_settings_from_env.py +0 -92
  19. {bub-0.3.1 → bub-0.3.2}/LICENSE +0 -0
  20. {bub-0.3.1 → bub-0.3.2}/src/bub/__main__.py +0 -0
  21. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/__init__.py +0 -0
  22. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/auth.py +0 -0
  23. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/cli.py +0 -0
  24. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/context.py +0 -0
  25. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/hook_impl.py +0 -0
  26. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/store.py +0 -0
  27. {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/tape.py +0 -0
  28. {bub-0.3.1 → bub-0.3.2}/src/bub/channels/__init__.py +0 -0
  29. {bub-0.3.1 → bub-0.3.2}/src/bub/channels/base.py +0 -0
  30. {bub-0.3.1 → bub-0.3.2}/src/bub/channels/cli/__init__.py +0 -0
  31. {bub-0.3.1 → bub-0.3.2}/src/bub/channels/cli/renderer.py +0 -0
  32. {bub-0.3.1 → bub-0.3.2}/src/bub/channels/handler.py +0 -0
  33. {bub-0.3.1 → bub-0.3.2}/src/bub/channels/manager.py +0 -0
  34. {bub-0.3.1 → bub-0.3.2}/src/bub/channels/message.py +0 -0
  35. {bub-0.3.1 → bub-0.3.2}/src/bub/channels/telegram.py +0 -0
  36. {bub-0.3.1 → bub-0.3.2}/src/bub/envelope.py +0 -0
  37. {bub-0.3.1 → bub-0.3.2}/src/bub/hook_runtime.py +0 -0
  38. {bub-0.3.1 → bub-0.3.2}/src/bub/hookspecs.py +0 -0
  39. {bub-0.3.1 → bub-0.3.2}/src/bub/types.py +0 -0
  40. {bub-0.3.1 → bub-0.3.2}/src/bub/utils.py +0 -0
  41. {bub-0.3.1 → bub-0.3.2}/src/skills/README.md +0 -0
  42. {bub-0.3.1 → bub-0.3.2}/src/skills/gh/SKILL.md +0 -0
  43. {bub-0.3.1 → bub-0.3.2}/src/skills/skill-creator/SKILL.md +0 -0
  44. {bub-0.3.1 → bub-0.3.2}/src/skills/skill-creator/license.txt +0 -0
  45. {bub-0.3.1 → bub-0.3.2}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  46. {bub-0.3.1 → bub-0.3.2}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  47. {bub-0.3.1 → bub-0.3.2}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  48. {bub-0.3.1 → bub-0.3.2}/src/skills/telegram/scripts/telegram_send.py +0 -0
  49. {bub-0.3.1 → bub-0.3.2}/tests/test_builtin_agent.py +0 -0
  50. {bub-0.3.1 → bub-0.3.2}/tests/test_builtin_cli.py +0 -0
  51. {bub-0.3.1 → bub-0.3.2}/tests/test_builtin_hook_impl.py +0 -0
  52. {bub-0.3.1 → bub-0.3.2}/tests/test_channels.py +0 -0
  53. {bub-0.3.1 → bub-0.3.2}/tests/test_cli_help.py +0 -0
  54. {bub-0.3.1 → bub-0.3.2}/tests/test_envelope.py +0 -0
  55. {bub-0.3.1 → bub-0.3.2}/tests/test_file_tape_store_entry_ids.py +0 -0
  56. {bub-0.3.1 → bub-0.3.2}/tests/test_fork_store_merge_back.py +0 -0
  57. {bub-0.3.1 → bub-0.3.2}/tests/test_framework.py +0 -0
  58. {bub-0.3.1 → bub-0.3.2}/tests/test_hook_runtime.py +0 -0
  59. {bub-0.3.1 → bub-0.3.2}/tests/test_image_message.py +0 -0
  60. {bub-0.3.1 → bub-0.3.2}/tests/test_skills.py +0 -0
  61. {bub-0.3.1 → bub-0.3.2}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bub
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A common shape for agents that live alongside people.
5
5
  Author-Email: Chojan Shang <psiace@apache.org>, Frost Ming <me@frostming.com>, Yihong <zouzou0208@gmail.com>
6
6
  Classifier: Intended Audience :: Developers
@@ -31,7 +31,16 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  # Bub
33
33
 
34
- **A common shape for agents that live alongside people.**
34
+ <div align="center">
35
+
36
+ <picture>
37
+ <source srcset="https://bub.build/dark.png" media="(prefers-color-scheme: dark)">
38
+ <img alt="Bub logo" src="https://bub.build/light.png" width="200">
39
+ </picture>
40
+
41
+ <p><strong>A common shape for agents that live alongside people.</strong></p>
42
+
43
+ </div>
35
44
 
36
45
  Bub started in group chats. Not as a demo or a personal assistant, but as a teammate that had to coexist with real humans and other agents in the same messy conversations — concurrent tasks, incomplete context, and nobody waiting.
37
46
 
@@ -113,27 +122,27 @@ See the [Extension Guide](https://bub.build/extension-guide/) for hook semantics
113
122
 
114
123
  ## CLI
115
124
 
116
- | Command | Description |
117
- |---------|-------------|
118
- | `bub chat` | Interactive REPL |
119
- | `bub run MESSAGE` | One-shot turn |
120
- | `bub gateway` | Channel listener (Telegram, etc.) |
121
- | `bub login openai` | OpenAI Codex OAuth |
122
- | `bub hooks` | Print hook-to-plugin bindings |
125
+ | Command | Description |
126
+ | ------------------ | --------------------------------- |
127
+ | `bub chat` | Interactive REPL |
128
+ | `bub run MESSAGE` | One-shot turn |
129
+ | `bub gateway` | Channel listener (Telegram, etc.) |
130
+ | `bub login openai` | OpenAI Codex OAuth |
131
+ | `bub hooks` | Print hook-to-plugin bindings |
123
132
 
124
133
  Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`).
125
134
 
126
135
  ## Configuration
127
136
 
128
- | Variable | Default | Description |
129
- |----------|---------|-------------|
130
- | `BUB_MODEL` | `openrouter:qwen/qwen3-coder-next` | Model identifier |
131
- | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
132
- | `BUB_API_BASE` | — | Custom provider endpoint |
133
- | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
134
- | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
135
- | `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
136
- | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
137
+ | Variable | Default | Description |
138
+ | --------------------------- | ---------------------------------- | ----------------------------------------------- |
139
+ | `BUB_MODEL` | `openrouter:qwen/qwen3-coder-next` | Model identifier |
140
+ | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
141
+ | `BUB_API_BASE` | — | Custom provider endpoint |
142
+ | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
143
+ | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
144
+ | `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
145
+ | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
137
146
 
138
147
  ## Background
139
148
 
@@ -1,6 +1,15 @@
1
1
  # Bub
2
2
 
3
- **A common shape for agents that live alongside people.**
3
+ <div align="center">
4
+
5
+ <picture>
6
+ <source srcset="https://bub.build/dark.png" media="(prefers-color-scheme: dark)">
7
+ <img alt="Bub logo" src="https://bub.build/light.png" width="200">
8
+ </picture>
9
+
10
+ <p><strong>A common shape for agents that live alongside people.</strong></p>
11
+
12
+ </div>
4
13
 
5
14
  Bub started in group chats. Not as a demo or a personal assistant, but as a teammate that had to coexist with real humans and other agents in the same messy conversations — concurrent tasks, incomplete context, and nobody waiting.
6
15
 
@@ -82,27 +91,27 @@ See the [Extension Guide](https://bub.build/extension-guide/) for hook semantics
82
91
 
83
92
  ## CLI
84
93
 
85
- | Command | Description |
86
- |---------|-------------|
87
- | `bub chat` | Interactive REPL |
88
- | `bub run MESSAGE` | One-shot turn |
89
- | `bub gateway` | Channel listener (Telegram, etc.) |
90
- | `bub login openai` | OpenAI Codex OAuth |
91
- | `bub hooks` | Print hook-to-plugin bindings |
94
+ | Command | Description |
95
+ | ------------------ | --------------------------------- |
96
+ | `bub chat` | Interactive REPL |
97
+ | `bub run MESSAGE` | One-shot turn |
98
+ | `bub gateway` | Channel listener (Telegram, etc.) |
99
+ | `bub login openai` | OpenAI Codex OAuth |
100
+ | `bub hooks` | Print hook-to-plugin bindings |
92
101
 
93
102
  Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`).
94
103
 
95
104
  ## Configuration
96
105
 
97
- | Variable | Default | Description |
98
- |----------|---------|-------------|
99
- | `BUB_MODEL` | `openrouter:qwen/qwen3-coder-next` | Model identifier |
100
- | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
101
- | `BUB_API_BASE` | — | Custom provider endpoint |
102
- | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
103
- | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
104
- | `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
105
- | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
106
+ | Variable | Default | Description |
107
+ | --------------------------- | ---------------------------------- | ----------------------------------------------- |
108
+ | `BUB_MODEL` | `openrouter:qwen/qwen3-coder-next` | Model identifier |
109
+ | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
110
+ | `BUB_API_BASE` | — | Custom provider endpoint |
111
+ | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
112
+ | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
113
+ | `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
114
+ | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
106
115
 
107
116
  ## Background
108
117
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bub"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "A common shape for agents that live alongside people."
5
5
  authors = [
6
6
  { name = "Chojan Shang", email = "psiace@apache.org" },
@@ -5,4 +5,4 @@ from bub.hookspecs import hookimpl
5
5
  from bub.tools import tool
6
6
 
7
7
  __all__ = ["BubFramework", "hookimpl", "tool"]
8
- __version__ = "0.3.1"
8
+ __version__ = "0.3.2"
@@ -18,7 +18,7 @@ from loguru import logger
18
18
  from republic import LLM, AsyncTapeStore, TapeContext, ToolAutoResult, ToolContext
19
19
  from republic.tape import InMemoryTapeStore, Tape
20
20
 
21
- from bub.builtin.settings import AgentSettings
21
+ from bub.builtin.settings import AgentSettings, load_settings
22
22
  from bub.builtin.store import ForkTapeStore
23
23
  from bub.builtin.tape import TapeService
24
24
  from bub.framework import BubFramework
@@ -36,7 +36,7 @@ class Agent:
36
36
  """Agent that processes prompts using hooks and tools. Backed by republic."""
37
37
 
38
38
  def __init__(self, framework: BubFramework) -> None:
39
- self.settings = _load_runtime_settings()
39
+ self.settings = load_settings()
40
40
  self.framework = framework
41
41
 
42
42
  @cached_property
@@ -290,10 +290,6 @@ def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore, tape_context
290
290
  )
291
291
 
292
292
 
293
- def _load_runtime_settings() -> AgentSettings:
294
- return AgentSettings.from_env()
295
-
296
-
297
293
  @dataclass(frozen=True)
298
294
  class Args:
299
295
  positional: list[str]
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pathlib
5
+ import re
6
+ from collections.abc import Callable
7
+ from functools import lru_cache
8
+ from typing import Literal
9
+
10
+ from pydantic import Field
11
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource
12
+
13
+ DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next"
14
+ DEFAULT_MAX_TOKENS = 1024
15
+ DEFAULT_HOME = pathlib.Path.home() / ".bub"
16
+ DEFAULT_CONFIG_FILE = DEFAULT_HOME / "config.yml"
17
+
18
+
19
+ def provider_specific(setting_name: str) -> Callable[[], dict[str, str] | None]:
20
+ def default_factory() -> dict[str, str] | None:
21
+ setting_regex = re.compile(rf"^BUB_(.+)_{setting_name.upper()}$")
22
+ loaded_env = os.environ
23
+ result: dict[str, str] = {}
24
+ for key, value in loaded_env.items():
25
+ if value is None:
26
+ continue
27
+ if match := setting_regex.match(key):
28
+ provider = match.group(1).lower()
29
+ result[provider] = value
30
+ return result or None
31
+
32
+ return default_factory
33
+
34
+
35
+ class AgentSettings(BaseSettings):
36
+ """Configuration settings for the Agent."""
37
+
38
+ model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore")
39
+ home: pathlib.Path = Field(default=DEFAULT_HOME)
40
+ model: str = DEFAULT_MODEL
41
+ fallback_models: list[str] | None = None
42
+ api_key: str | dict[str, str] | None = Field(default_factory=provider_specific("api_key"))
43
+ api_base: str | dict[str, str] | None = Field(default_factory=provider_specific("api_base"))
44
+ api_format: Literal["completion", "responses", "messages"] = "completion"
45
+ max_steps: int = 50
46
+ max_tokens: int = DEFAULT_MAX_TOKENS
47
+ model_timeout_seconds: int | None = None
48
+ verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
49
+
50
+ @classmethod
51
+ def settings_customise_sources(
52
+ cls,
53
+ settings_cls: type[BaseSettings],
54
+ init_settings: PydanticBaseSettingsSource,
55
+ env_settings: PydanticBaseSettingsSource,
56
+ dotenv_settings: PydanticBaseSettingsSource,
57
+ file_secret_settings: PydanticBaseSettingsSource,
58
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
59
+ home = os.getenv("BUB_HOME", str(DEFAULT_HOME))
60
+ return (
61
+ init_settings,
62
+ env_settings,
63
+ dotenv_settings,
64
+ YamlConfigSettingsSource(settings_cls, yaml_file=pathlib.Path(home) / "config.yml"),
65
+ file_secret_settings,
66
+ )
67
+
68
+
69
+ @lru_cache(maxsize=1)
70
+ def load_settings() -> AgentSettings:
71
+ return AgentSettings()
@@ -53,6 +53,9 @@ class ShellManager:
53
53
  except KeyError as exc:
54
54
  raise KeyError(f"unknown shell id: {shell_id}") from exc
55
55
 
56
+ def release(self, shell_id: str) -> ManagedShell | None:
57
+ return self._shells.pop(shell_id, None)
58
+
56
59
  async def terminate(self, shell_id: str) -> ManagedShell:
57
60
  shell = self.get(shell_id)
58
61
  if shell.returncode is not None:
@@ -80,6 +83,7 @@ class ShellManager:
80
83
  for task in shell.read_tasks:
81
84
  with contextlib.suppress(asyncio.CancelledError):
82
85
  await task
86
+ self._shells.pop(shell.shell_id, None)
83
87
 
84
88
  async def _drain_stream(
85
89
  self,
@@ -11,7 +11,7 @@ from republic import AsyncTapeStore, TapeQuery, ToolContext
11
11
 
12
12
  from bub.builtin.shell_manager import shell_manager
13
13
  from bub.skills import discover_skills
14
- from bub.tools import REGISTRY, tool
14
+ from bub.tools import resolve_tool_names, tool
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from bub.builtin.agent import Agent
@@ -263,10 +263,7 @@ async def run_subagent(param: SubAgentInput, *, context: ToolContext) -> str:
263
263
  else:
264
264
  subagent_session = param.session
265
265
  state = {**context.state, "session_id": subagent_session}
266
- if param.allowed_tools:
267
- allowed_tools = set(param.allowed_tools) - {"subagent"}
268
- else:
269
- allowed_tools = set(REGISTRY.keys()) - {"subagent"}
266
+ allowed_tools = resolve_tool_names(param.allowed_tools or None, exclude={"subagent"})
270
267
  return await agent.run(
271
268
  session_id=subagent_session,
272
269
  prompt=param.prompt,
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  import pluggy
10
10
  import typer
11
+ from dotenv import load_dotenv
11
12
  from loguru import logger
12
13
  from republic import AsyncTapeStore, TapeContext
13
14
  from republic.tape import TapeStore
@@ -21,6 +22,9 @@ if TYPE_CHECKING:
21
22
  from bub.channels.base import Channel
22
23
 
23
24
 
25
+ load_dotenv()
26
+
27
+
24
28
  @dataclass(frozen=True)
25
29
  class PluginStatus:
26
30
  is_success: bool
@@ -172,7 +172,7 @@ def render_skills_prompt(skills: list[SkillMetadata], expanded_skills: Collectio
172
172
  for skill in skills:
173
173
  line = f"- {skill.name}: {skill.description}"
174
174
  if skill.name in expanded_skills:
175
- line += f" Location: {skill.location}"
175
+ line += f"\n Location: {skill.location}"
176
176
  body = skill.body()
177
177
  if body:
178
178
  line += f"\n{body}"
@@ -136,6 +136,51 @@ def _to_model_name(name: str) -> str:
136
136
  return name.replace(".", "_")
137
137
 
138
138
 
139
+ def _tool_name_index() -> dict[str, str]:
140
+ real_names = {tool_name.casefold(): tool_name for tool_name in REGISTRY}
141
+ alias_names = {_to_model_name(tool_name).casefold(): tool_name for tool_name in REGISTRY}
142
+ return {**alias_names, **real_names}
143
+
144
+
145
+ def resolve_tool_name(name: str) -> str | None:
146
+ """Resolve a user/model-provided tool name to the runtime registry name."""
147
+ key = name.strip().casefold()
148
+ if not key:
149
+ return None
150
+ return _tool_name_index().get(key)
151
+
152
+
153
+ def _resolve_explicit_tool_names(names: Iterable[str]) -> tuple[set[str], set[str]]:
154
+ resolved: set[str] = set()
155
+ unknown: set[str] = set()
156
+ for name in names:
157
+ normalized_name = name.strip()
158
+ if resolved_name := resolve_tool_name(normalized_name):
159
+ resolved.add(resolved_name)
160
+ else:
161
+ unknown.add(normalized_name)
162
+ return resolved, unknown
163
+
164
+
165
+ def _raise_unknown_tool_names(names: set[str]) -> None:
166
+ formatted = ", ".join(sorted(repr(name) for name in names))
167
+ raise ValueError(f"unknown tool name(s): {formatted}")
168
+
169
+
170
+ def resolve_tool_names(names: Iterable[str] | None = None, *, exclude: Iterable[str] = ()) -> set[str]:
171
+ """Resolve tool names from either runtime names or model-facing aliases."""
172
+ excluded, unknown_excluded = _resolve_explicit_tool_names(exclude)
173
+ if unknown_excluded:
174
+ _raise_unknown_tool_names(unknown_excluded)
175
+ if names is None:
176
+ return set(REGISTRY) - excluded
177
+
178
+ resolved, unknown = _resolve_explicit_tool_names(names)
179
+ if unknown:
180
+ _raise_unknown_tool_names(unknown)
181
+ return resolved - excluded
182
+
183
+
139
184
  def model_tools(tools: Iterable[Tool]) -> list[Tool]:
140
185
  """Helper to convert a list of Tool instances into a format accepted by LLMs."""
141
186
  return [replace(tool, name=_to_model_name(tool.name)) for tool in tools]
@@ -17,7 +17,6 @@ Agent-facing execution guide for Telegram outbound communication.
17
17
  Assumption: `BUB_TELEGRAM_TOKEN` is already available.
18
18
 
19
19
  ## Required Inputs
20
-
21
20
  Collect these before execution:
22
21
 
23
22
  - `chat_id` (required)
@@ -9,6 +9,8 @@ from republic import ToolContext
9
9
  from republic.core.errors import ErrorKind
10
10
  from republic.tools.executor import ToolExecutor
11
11
 
12
+ import bub.builtin.tools as builtin_tools
13
+ from bub.builtin.shell_manager import ShellManager
12
14
  from bub.builtin.tools import bash, bash_output, kill_bash
13
15
 
14
16
 
@@ -27,6 +29,28 @@ async def test_bash_returns_stdout_for_foreground_command(tmp_path) -> None:
27
29
  assert result == "hello"
28
30
 
29
31
 
32
+ @pytest.mark.asyncio
33
+ async def test_foreground_bash_releases_shell_from_shell_manager(tmp_path, monkeypatch) -> None:
34
+ manager = ShellManager()
35
+ monkeypatch.setattr(builtin_tools, "shell_manager", manager)
36
+
37
+ result = await bash.run(cmd=_python_shell("print('hello')"), context=_tool_context(tmp_path))
38
+
39
+ assert result == "hello"
40
+ assert manager._shells == {}
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_foreground_bash_releases_shell_when_command_fails(tmp_path, monkeypatch) -> None:
45
+ manager = ShellManager()
46
+ monkeypatch.setattr(builtin_tools, "shell_manager", manager)
47
+
48
+ with pytest.raises(RuntimeError, match="command exited with code"):
49
+ await bash.run(cmd=_python_shell("import sys; sys.exit(2)"), context=_tool_context(tmp_path))
50
+
51
+ assert manager._shells == {}
52
+
53
+
30
54
  @pytest.mark.asyncio
31
55
  async def test_bash_non_zero_exit_is_returned_as_tool_error(tmp_path) -> None:
32
56
  command = _python_shell("import sys; print('boom'); sys.exit(7)")
@@ -71,7 +95,7 @@ async def test_background_bash_exposes_output_via_bash_output(tmp_path) -> None:
71
95
 
72
96
 
73
97
  @pytest.mark.asyncio
74
- async def test_kill_bash_terminates_background_process(tmp_path) -> None:
98
+ async def test_kill_bash_terminates_background_process_and_releases_shell(tmp_path) -> None:
75
99
  started = await bash.run(
76
100
  cmd=_python_shell("import time; time.sleep(10)"),
77
101
  background=True,
@@ -80,11 +104,11 @@ async def test_kill_bash_terminates_background_process(tmp_path) -> None:
80
104
  shell_id = started.removeprefix("started: ").strip()
81
105
 
82
106
  killed = await kill_bash.run(shell_id=shell_id)
83
- output = await bash_output.run(shell_id=shell_id)
84
107
 
85
108
  assert killed.startswith(f"id: {shell_id}\nstatus: exited\nexit_code: ")
86
109
  assert "exit_code: null" not in killed
87
- assert output.startswith(f"id: {shell_id}\nstatus: exited\n")
110
+ with pytest.raises(KeyError, match="unknown shell id"):
111
+ await bash_output.run(shell_id=shell_id)
88
112
 
89
113
 
90
114
  @pytest.mark.asyncio
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ from bub.builtin.settings import AgentSettings, load_settings
7
+
8
+
9
+ def _settings_with_env(env: dict[str, str]) -> AgentSettings:
10
+ with patch.dict("os.environ", env, clear=True):
11
+ return AgentSettings()
12
+
13
+
14
+ def _write_config(home: Path, content: str) -> None:
15
+ home.mkdir(parents=True, exist_ok=True)
16
+ (home / "config.yml").write_text(content, encoding="utf-8")
17
+
18
+
19
+ def test_settings_single_api_key_and_base() -> None:
20
+ settings = _settings_with_env({"BUB_API_KEY": "sk-test", "BUB_API_BASE": "https://api.example.com"})
21
+
22
+ assert isinstance(settings.api_key, str)
23
+ assert isinstance(settings.api_base, str)
24
+
25
+
26
+ def test_settings_per_provider_keys() -> None:
27
+ settings = _settings_with_env({
28
+ "BUB_OPENAI_API_KEY": "sk-openai",
29
+ "BUB_OPENAI_API_BASE": "https://api.openai.com",
30
+ "BUB_ANTHROPIC_API_KEY": "sk-anthropic",
31
+ })
32
+
33
+ assert isinstance(settings.api_key, dict)
34
+ assert settings.api_key["openai"] == "sk-openai"
35
+ assert settings.api_key["anthropic"] == "sk-anthropic"
36
+ assert isinstance(settings.api_base, dict)
37
+ assert settings.api_base["openai"] == "https://api.openai.com"
38
+
39
+
40
+ def test_settings_no_keys_returns_none() -> None:
41
+ settings = _settings_with_env({})
42
+
43
+ assert settings.api_key is None
44
+ assert settings.api_base is None
45
+
46
+
47
+ def test_settings_provider_names_are_lowercased() -> None:
48
+ settings = _settings_with_env({"BUB_OPENROUTER_API_KEY": "sk-or"})
49
+
50
+ assert isinstance(settings.api_key, dict)
51
+ assert "openrouter" in settings.api_key
52
+
53
+
54
+ def test_settings_mixed_single_key_with_per_provider_base() -> None:
55
+ settings = _settings_with_env({
56
+ "BUB_API_KEY": "sk-global",
57
+ "BUB_OPENAI_API_BASE": "https://api.openai.com",
58
+ })
59
+
60
+ assert settings.api_key == "sk-global"
61
+ assert isinstance(settings.api_base, dict)
62
+ assert settings.api_base["openai"] == "https://api.openai.com"
63
+
64
+
65
+ def test_settings_load_values_from_yaml(tmp_path: Path) -> None:
66
+ _write_config(
67
+ tmp_path,
68
+ """
69
+ model: openai:gpt-5
70
+ fallback_models:
71
+ - openai:gpt-4o-mini
72
+ max_steps: 77
73
+ api_key:
74
+ openai: sk-yaml
75
+ api_base:
76
+ openai: https://api.openai.com
77
+ """.strip(),
78
+ )
79
+
80
+ with patch.dict("os.environ", {"BUB_HOME": str(tmp_path)}, clear=True):
81
+ settings = AgentSettings()
82
+
83
+ assert settings.model == "openai:gpt-5"
84
+ assert settings.fallback_models == ["openai:gpt-4o-mini"]
85
+ assert settings.max_steps == 77
86
+ assert settings.api_key == {"openai": "sk-yaml"}
87
+ assert settings.api_base == {"openai": "https://api.openai.com"}
88
+
89
+
90
+ def test_env_settings_override_yaml(tmp_path: Path) -> None:
91
+ _write_config(
92
+ tmp_path,
93
+ """
94
+ model: openai:gpt-5
95
+ api_key: sk-yaml
96
+ max_steps: 77
97
+ """.strip(),
98
+ )
99
+
100
+ with patch.dict(
101
+ "os.environ",
102
+ {
103
+ "BUB_HOME": str(tmp_path),
104
+ "BUB_MODEL": "anthropic:claude-3-7-sonnet",
105
+ "BUB_API_KEY": "sk-env",
106
+ "BUB_MAX_STEPS": "12",
107
+ },
108
+ clear=True,
109
+ ):
110
+ settings = AgentSettings()
111
+
112
+ assert settings.model == "anthropic:claude-3-7-sonnet"
113
+ assert settings.api_key == "sk-env"
114
+ assert settings.max_steps == 12
115
+
116
+
117
+ def test_load_settings_reads_yaml_from_bub_home(tmp_path: Path) -> None:
118
+ _write_config(
119
+ tmp_path,
120
+ """
121
+ model: openrouter:qwen/qwen3-coder-next
122
+ api_format: responses
123
+ """.strip(),
124
+ )
125
+
126
+ load_settings.cache_clear()
127
+ try:
128
+ with patch.dict("os.environ", {"BUB_HOME": str(tmp_path)}, clear=True):
129
+ settings = load_settings()
130
+ finally:
131
+ load_settings.cache_clear()
132
+
133
+ assert settings.model == "openrouter:qwen/qwen3-coder-next"
134
+ assert settings.api_format == "responses"
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock
6
6
  import pytest
7
7
 
8
8
  from bub.builtin.tools import run_subagent
9
+ from bub.tools import REGISTRY, tool
9
10
 
10
11
 
11
12
  class FakeContext:
@@ -94,3 +95,50 @@ async def test_subagent_default_session_when_missing() -> None:
94
95
 
95
96
  call_kwargs = agent.run.call_args.kwargs
96
97
  assert call_kwargs["session_id"] == "temp/unknown"
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_subagent_empty_allowed_tools_defaults_to_all_non_subagent_tools() -> None:
102
+ tool_name = "tests.allowed_tool_default"
103
+ REGISTRY.pop(tool_name, None)
104
+
105
+ @tool(name=tool_name)
106
+ def allowed_tool_default() -> str:
107
+ return "ok"
108
+
109
+ agent = FakeAgent()
110
+ ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"})
111
+
112
+ await run_subagent.run(prompt="task", allowed_tools=[], context=ctx)
113
+
114
+ allowed_tools = agent.run.call_args.kwargs["allowed_tools"]
115
+ assert tool_name in allowed_tools
116
+ assert "subagent" not in allowed_tools
117
+
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_subagent_resolves_model_tool_aliases_to_runtime_names() -> None:
121
+ tool_name = "tests.resolve_subagent"
122
+ REGISTRY.pop(tool_name, None)
123
+
124
+ @tool(name=tool_name)
125
+ def resolve_subagent() -> str:
126
+ return "ok"
127
+
128
+ agent = FakeAgent()
129
+ ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"})
130
+
131
+ await run_subagent.run(prompt="task", allowed_tools=[" tests_resolve_subagent "], context=ctx)
132
+
133
+ assert agent.run.call_args.kwargs["allowed_tools"] == {tool_name}
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_subagent_rejects_unknown_allowed_tools() -> None:
138
+ agent = FakeAgent()
139
+ ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"})
140
+
141
+ with pytest.raises(ValueError, match="tests_missing_tool"):
142
+ await run_subagent.run(prompt="task", allowed_tools=[" tests_missing_tool "], context=ctx)
143
+
144
+ agent.run.assert_not_called()
@@ -6,7 +6,7 @@ import pytest
6
6
  from loguru import logger
7
7
  from pydantic import BaseModel
8
8
 
9
- from bub.tools import REGISTRY, model_tools, render_tools_prompt, tool
9
+ from bub.tools import REGISTRY, model_tools, render_tools_prompt, resolve_tool_names, tool
10
10
 
11
11
 
12
12
  class EchoInput(BaseModel):
@@ -123,3 +123,33 @@ def test_render_tools_prompt_renders_available_tools_block() -> None:
123
123
 
124
124
  def test_render_tools_prompt_returns_empty_string_for_empty_input() -> None:
125
125
  assert render_tools_prompt([]) == ""
126
+
127
+
128
+ def test_resolve_tool_names_accepts_runtime_names_and_model_aliases() -> None:
129
+ dotted_name = "tests.resolve_alias"
130
+ underscored_name = "tests_with_underscore"
131
+ REGISTRY.pop(dotted_name, None)
132
+ REGISTRY.pop(underscored_name, None)
133
+
134
+ @tool(name=dotted_name)
135
+ def resolve_alias() -> str:
136
+ return "alias"
137
+
138
+ @tool(name=underscored_name)
139
+ def resolve_runtime_name() -> str:
140
+ return "runtime"
141
+
142
+ assert resolve_tool_names([" tests_resolve_alias ", " tests_with_underscore "], exclude={" subagent "}) == {
143
+ dotted_name,
144
+ underscored_name,
145
+ }
146
+ assert dotted_name not in resolve_tool_names(None, exclude={" tests_resolve_alias "})
147
+ assert resolve_tool_names(None, exclude={" tests_resolve_alias "}) >= {underscored_name}
148
+
149
+
150
+ def test_resolve_tool_names_rejects_unknown_names() -> None:
151
+ with pytest.raises(ValueError, match="tests_missing_tool"):
152
+ resolve_tool_names([" tests_missing_tool "])
153
+
154
+ with pytest.raises(ValueError, match="tests_missing_tool"):
155
+ resolve_tool_names(None, exclude={" tests_missing_tool "})
@@ -1,63 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import pathlib
5
- import re
6
- from typing import Literal
7
-
8
- from pydantic import Field
9
- from pydantic_settings import BaseSettings, SettingsConfigDict
10
-
11
- DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next"
12
- DEFAULT_MAX_TOKENS = 1024
13
- DEFAULT_HOME = pathlib.Path.home() / ".bub"
14
-
15
-
16
- class AgentSettings(BaseSettings):
17
- """Configuration settings for the Agent, loaded from environment variables with prefix BUB_ or from a .env file."""
18
-
19
- model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore", env_file=".env")
20
-
21
- home: pathlib.Path = Field(default=DEFAULT_HOME)
22
-
23
- model: str = DEFAULT_MODEL
24
- fallback_models: list[str] | None = None
25
- api_key: str | dict[str, str] | None = None
26
- api_base: str | dict[str, str] | None = None
27
- api_format: Literal["completion", "responses", "messages"] = "completion"
28
- max_steps: int = 50
29
- max_tokens: int = DEFAULT_MAX_TOKENS
30
- model_timeout_seconds: int | None = None
31
- verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
32
-
33
- @classmethod
34
- def from_env(cls) -> AgentSettings:
35
- from dotenv import dotenv_values
36
-
37
- key_regex = re.compile(r"^BUB_(.+)_API_KEY$")
38
- base_regex = re.compile(r"^BUB_(.+)_API_BASE$")
39
-
40
- loaded_env = dotenv_values(".env")
41
- loaded_env.update(os.environ)
42
-
43
- api_key: str | dict[str, str] | None = loaded_env.get("BUB_API_KEY")
44
- api_base: str | dict[str, str] | None = loaded_env.get("BUB_API_BASE")
45
- if api_key and api_base:
46
- return cls()
47
-
48
- if api_key is None:
49
- api_key = {}
50
- if api_base is None:
51
- api_base = {}
52
-
53
- for key, value in loaded_env.items():
54
- if value is None:
55
- continue
56
- if isinstance(api_key, dict) and (match := key_regex.match(key)):
57
- provider = match.group(1).lower()
58
- api_key[provider] = value
59
- if isinstance(api_base, dict) and (match := base_regex.match(key)):
60
- provider = match.group(1).lower()
61
- api_base[provider] = value
62
-
63
- return cls(api_key=api_key or None, api_base=api_base or None)
@@ -1,92 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from unittest.mock import patch
4
-
5
- from bub.builtin.settings import AgentSettings
6
-
7
-
8
- def _from_env_with(env: dict[str, str]) -> AgentSettings:
9
- """Call AgentSettings.from_env() with a controlled env, bypassing .env file and real os.environ."""
10
- with (
11
- patch("dotenv.dotenv_values", return_value={}),
12
- patch.dict("os.environ", env, clear=True),
13
- ):
14
- return AgentSettings.from_env()
15
-
16
-
17
- def test_from_env_single_api_key_and_base() -> None:
18
- """When BUB_API_KEY and BUB_API_BASE are both set, return plain AgentSettings."""
19
- settings = _from_env_with({"BUB_API_KEY": "sk-test", "BUB_API_BASE": "https://api.example.com"})
20
-
21
- assert isinstance(settings.api_key, str)
22
- assert isinstance(settings.api_base, str)
23
-
24
-
25
- def test_from_env_per_provider_keys() -> None:
26
- """When per-provider BUB_<PROVIDER>_API_KEY vars are set, build a dict."""
27
- settings = _from_env_with({
28
- "BUB_OPENAI_API_KEY": "sk-openai",
29
- "BUB_OPENAI_API_BASE": "https://api.openai.com",
30
- "BUB_ANTHROPIC_API_KEY": "sk-anthropic",
31
- })
32
-
33
- assert isinstance(settings.api_key, dict)
34
- assert settings.api_key["openai"] == "sk-openai"
35
- assert settings.api_key["anthropic"] == "sk-anthropic"
36
- assert isinstance(settings.api_base, dict)
37
- assert settings.api_base["openai"] == "https://api.openai.com"
38
-
39
-
40
- def test_from_env_no_keys_returns_none() -> None:
41
- """When no API key env vars are present, api_key and api_base are None."""
42
- settings = _from_env_with({})
43
-
44
- assert settings.api_key is None
45
- assert settings.api_base is None
46
-
47
-
48
- def test_from_env_provider_names_are_lowercased() -> None:
49
- """Provider names extracted from env vars should be lowercased."""
50
- settings = _from_env_with({"BUB_OPENROUTER_API_KEY": "sk-or"})
51
-
52
- assert isinstance(settings.api_key, dict)
53
- assert "openrouter" in settings.api_key
54
-
55
-
56
- def test_from_env_mixed_single_key_with_per_provider_base() -> None:
57
- """When BUB_API_KEY is set but BUB_API_BASE is not, key stays string, base becomes dict."""
58
- settings = _from_env_with({
59
- "BUB_API_KEY": "sk-global",
60
- "BUB_OPENAI_API_BASE": "https://api.openai.com",
61
- })
62
-
63
- # api_key is a plain string (from BUB_API_KEY), base is a dict
64
- assert settings.api_key == "sk-global"
65
- assert isinstance(settings.api_base, dict)
66
- assert settings.api_base["openai"] == "https://api.openai.com"
67
-
68
-
69
- def test_from_env_dotenv_file_values_used() -> None:
70
- """Values from .env file are used when present."""
71
- dotenv_data = {"BUB_OPENAI_API_KEY": "sk-from-dotenv"}
72
- with (
73
- patch("dotenv.dotenv_values", return_value=dotenv_data),
74
- patch.dict("os.environ", {}, clear=True),
75
- ):
76
- settings = AgentSettings.from_env()
77
-
78
- assert isinstance(settings.api_key, dict)
79
- assert settings.api_key["openai"] == "sk-from-dotenv"
80
-
81
-
82
- def test_from_env_os_environ_overrides_dotenv() -> None:
83
- """os.environ should override values from .env file."""
84
- dotenv_data = {"BUB_OPENAI_API_KEY": "sk-from-dotenv"}
85
- with (
86
- patch("dotenv.dotenv_values", return_value=dotenv_data),
87
- patch.dict("os.environ", {"BUB_OPENAI_API_KEY": "sk-from-env"}, clear=True),
88
- ):
89
- settings = AgentSettings.from_env()
90
-
91
- assert isinstance(settings.api_key, dict)
92
- assert settings.api_key["openai"] == "sk-from-env"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes