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.
- {bub-0.3.7 → bub-0.3.8}/.gitignore +2 -0
- {bub-0.3.7 → bub-0.3.8}/PKG-INFO +14 -10
- {bub-0.3.7 → bub-0.3.8}/README.md +13 -9
- {bub-0.3.7 → bub-0.3.8}/src/bub/__main__.py +9 -4
- {bub-0.3.7 → bub-0.3.8}/src/bub/_version.py +2 -2
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/agent.py +1 -1
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/cli.py +9 -2
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/settings.py +1 -1
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/shell_manager.py +16 -2
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/tools.py +9 -3
- {bub-0.3.7 → bub-0.3.8}/src/bub/channels/cli/__init__.py +2 -2
- {bub-0.3.7 → bub-0.3.8}/src/bub/channels/manager.py +29 -20
- {bub-0.3.7 → bub-0.3.8}/src/bub/channels/telegram.py +2 -2
- {bub-0.3.7 → bub-0.3.8}/src/bub/configure.py +47 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/framework.py +21 -2
- {bub-0.3.7 → bub-0.3.8}/src/bub/skills.py +27 -3
- {bub-0.3.7 → bub-0.3.8}/src/skills/skill-creator/SKILL.md +2 -2
- {bub-0.3.7 → bub-0.3.8}/src/skills/telegram/SKILL.md +6 -4
- {bub-0.3.7 → bub-0.3.8}/tests/test_builtin_cli.py +71 -8
- {bub-0.3.7 → bub-0.3.8}/tests/test_builtin_tools.py +68 -3
- {bub-0.3.7 → bub-0.3.8}/tests/test_channels.py +124 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_configure.py +56 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_framework.py +32 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_skills.py +53 -0
- {bub-0.3.7 → bub-0.3.8}/LICENSE +0 -0
- {bub-0.3.7 → bub-0.3.8}/pyproject.toml +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/__init__.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/__init__.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/auth.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/context.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/hook_impl.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/store.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/builtin/tape.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/channels/__init__.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/channels/base.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/channels/cli/renderer.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/channels/handler.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/channels/message.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/envelope.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/hook_runtime.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/hookspecs.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/inquirer.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/tools.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/types.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/bub/utils.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/skills/README.md +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/skills/gh/SKILL.md +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/src/skills/telegram/scripts/telegram_send.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/conftest.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_builtin_agent.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_builtin_hook_impl.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_cli_help.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_envelope.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_file_tape_store_entry_ids.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_fork_store_merge_back.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_hook_runtime.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_image_message.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_settings.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_subagent_tool.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_tape_search_output.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_tools.py +0 -0
- {bub-0.3.7 → bub-0.3.8}/tests/test_utils.py +0 -0
{bub-0.3.7 → bub-0.3.8}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bub
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
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:
|
|
127
|
+
echo = "my_package.plugin:echo_plugin"
|
|
124
128
|
```
|
|
125
129
|
|
|
126
|
-
See the [
|
|
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` | `
|
|
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
|
-
- [
|
|
170
|
-
- [Channels](https://bub.build/docs/
|
|
171
|
-
- [Skills](https://bub.build/docs/
|
|
172
|
-
- [
|
|
173
|
-
- [Deployment](https://bub.build/docs/
|
|
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
|
|
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:
|
|
91
|
+
echo = "my_package.plugin:echo_plugin"
|
|
88
92
|
```
|
|
89
93
|
|
|
90
|
-
See the [
|
|
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` | `
|
|
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
|
-
- [
|
|
134
|
-
- [Channels](https://bub.build/docs/
|
|
135
|
-
- [Skills](https://bub.build/docs/
|
|
136
|
-
- [
|
|
137
|
-
- [Deployment](https://bub.build/docs/
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
"""
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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)
|
|
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.
|
|
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
|
-
|
|
39
|
+
template_content = self.location.read_text(encoding="utf-8").strip()
|
|
37
40
|
except OSError:
|
|
38
41
|
return ""
|
|
39
|
-
|
|
40
|
-
|
|
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/.
|
|
24
|
-
2. Global: `~/.
|
|
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
|
-
|
|
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
|
```
|