bub 0.4.2__tar.gz → 0.4.4__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.4.2 → bub-0.4.4}/PKG-INFO +27 -7
- {bub-0.4.2 → bub-0.4.4}/README.md +24 -5
- {bub-0.4.2 → bub-0.4.4}/pyproject.toml +1 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/__main__.py +11 -4
- {bub-0.4.2 → bub-0.4.4}/src/bub/_version.py +2 -2
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/agent.py +9 -10
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/cli.py +47 -2
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/hook_impl.py +95 -9
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/model_runner.py +65 -3
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/settings.py +2 -1
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/shell_manager.py +48 -12
- bub-0.4.4/src/bub/builtin/spill.py +303 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/tools.py +8 -8
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/cli/__init__.py +41 -7
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/cli/renderer.py +19 -19
- bub-0.4.4/src/bub/channels/cli/writers.py +76 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/manager.py +17 -4
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/message.py +29 -1
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/telegram.py +4 -4
- {bub-0.4.2 → bub-0.4.4}/src/bub/framework.py +19 -4
- bub-0.4.4/src/bub/gateway_installer.py +227 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/hooks/interception.py +3 -3
- {bub-0.4.2 → bub-0.4.4}/src/bub/hooks/specs.py +22 -4
- bub-0.4.4/src/bub/sidecars.py +20 -0
- bub-0.4.4/src/bub/store.py +560 -0
- bub-0.4.4/src/bub/tape.py +535 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/tools.py +11 -9
- {bub-0.4.2 → bub-0.4.4}/tests/test_agent_hooks.py +15 -2
- {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_agent.py +42 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_cli.py +118 -2
- {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_hook_impl.py +67 -5
- {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_model_runner.py +73 -5
- {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_tools.py +55 -2
- {bub-0.4.2 → bub-0.4.4}/tests/test_channels.py +194 -15
- bub-0.4.4/tests/test_cli_help.py +30 -0
- bub-0.4.4/tests/test_cli_renderer.py +50 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_file_tape_store_entry_ids.py +12 -2
- {bub-0.4.2 → bub-0.4.4}/tests/test_fork_store_merge_back.py +2 -2
- {bub-0.4.2 → bub-0.4.4}/tests/test_framework.py +59 -0
- bub-0.4.4/tests/test_gateway_installer.py +159 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_image_message.py +122 -4
- bub-0.4.4/tests/test_install_scripts.py +406 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_settings.py +15 -0
- bub-0.4.4/tests/test_spill.py +209 -0
- bub-0.4.2/tests/test_builtin_tape.py → bub-0.4.4/tests/test_tape.py +41 -3
- bub-0.4.2/src/bub/builtin/store.py +0 -284
- bub-0.4.2/src/bub/builtin/tape.py +0 -250
- bub-0.4.2/src/bub/channels/cli/writers.py +0 -42
- bub-0.4.2/src/bub/tape.py +0 -366
- bub-0.4.2/tests/test_cli_help.py +0 -16
- bub-0.4.2/tests/test_cli_renderer.py +0 -32
- {bub-0.4.2 → bub-0.4.4}/.gitignore +0 -0
- {bub-0.4.2 → bub-0.4.4}/LICENSE +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/__init__.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/__init__.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/auth.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/codex_provider.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/context.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/steering.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/__init__.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/admission.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/base.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/cli/ansi_bridge.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/cli/terminal_output.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/contracts.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/channels/handler.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/configure.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/envelope.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/errors.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/hooks/__init__.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/hooks/runtime.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/inquirer.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/model_selection.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/skills.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/streaming.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/turn.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/types.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/bub/utils.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/README.md +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/gh/SKILL.md +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/telegram/SKILL.md +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/telegram/scripts/telegram_rich.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/src/skills/telegram/scripts/telegram_send.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/conftest.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_codex.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_configure.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_envelope.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_hook_runtime.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_skills.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_subagent_tool.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_tape_search_output.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_tools.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_types.py +0 -0
- {bub-0.4.2 → bub-0.4.4}/tests/test_utils.py +0 -0
{bub-0.4.2 → bub-0.4.4}/PKG-INFO
RENAMED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: bub
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
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
|
|
7
7
|
Project-URL: Documentation, https://bub.build
|
|
8
8
|
Author-email: Chojan Shang <psiace@apache.org>, Frost Ming <me@frostming.com>, Yihong <zouzou0208@gmail.com>
|
|
9
|
+
License: Apache-2.0
|
|
9
10
|
License-File: LICENSE
|
|
10
11
|
Classifier: Intended Audience :: Developers
|
|
11
12
|
Classifier: Programming Language :: Python
|
|
@@ -56,8 +57,26 @@ Built on [agents.md](https://agents.md/) and [Agent Skills](https://agentskills.
|
|
|
56
57
|
|
|
57
58
|
## Quick Start
|
|
58
59
|
|
|
60
|
+
macOS and Linux:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
curl -fsSL https://bub.build/install.sh | bash
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Windows PowerShell:
|
|
67
|
+
|
|
68
|
+
```powershell
|
|
69
|
+
powershell -ExecutionPolicy ByPass -c "irm https://bub.build/install.ps1 | iex"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The interactive installer uses a colored preset picker, accepts additional plugin dependencies, and runs `bub onboard` after installation. For automation, select a preset explicitly; non-interactive installs skip onboarding:
|
|
73
|
+
|
|
59
74
|
```bash
|
|
60
|
-
|
|
75
|
+
curl -fsSL https://bub.build/install.sh | bash -s -- --preset recommended --dependency extra-plugin
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
powershell -ExecutionPolicy ByPass -c "& ([scriptblock]::Create((irm https://bub.build/install.ps1))) --preset recommended --dependency extra-plugin"
|
|
61
80
|
```
|
|
62
81
|
|
|
63
82
|
Or from source:
|
|
@@ -71,9 +90,9 @@ uv sync # enough to run Bub from source
|
|
|
71
90
|
For local development, use `make install` instead so the website toolchain and `prek` hooks are installed too.
|
|
72
91
|
|
|
73
92
|
```bash
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
93
|
+
bub chat # interactive session
|
|
94
|
+
bub run "summarize this repo" # one-shot task
|
|
95
|
+
bub gateway # channel listener mode
|
|
77
96
|
```
|
|
78
97
|
|
|
79
98
|
## Why Bub
|
|
@@ -154,9 +173,10 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
154
173
|
| `BUB_API_BASE` | — | Custom provider endpoint |
|
|
155
174
|
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
|
|
156
175
|
| `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
|
|
157
|
-
| `BUB_MAX_STEPS` |
|
|
176
|
+
| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
|
|
158
177
|
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
159
178
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
179
|
+
| `BUB_SPILL_THRESHOLD` | `4096` | Estimated tokens before tool output spills; `0` disables |
|
|
160
180
|
|
|
161
181
|
## Background
|
|
162
182
|
|
|
@@ -19,8 +19,26 @@ Built on [agents.md](https://agents.md/) and [Agent Skills](https://agentskills.
|
|
|
19
19
|
|
|
20
20
|
## Quick Start
|
|
21
21
|
|
|
22
|
+
macOS and Linux:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
curl -fsSL https://bub.build/install.sh | bash
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Windows PowerShell:
|
|
29
|
+
|
|
30
|
+
```powershell
|
|
31
|
+
powershell -ExecutionPolicy ByPass -c "irm https://bub.build/install.ps1 | iex"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The interactive installer uses a colored preset picker, accepts additional plugin dependencies, and runs `bub onboard` after installation. For automation, select a preset explicitly; non-interactive installs skip onboarding:
|
|
35
|
+
|
|
22
36
|
```bash
|
|
23
|
-
|
|
37
|
+
curl -fsSL https://bub.build/install.sh | bash -s -- --preset recommended --dependency extra-plugin
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
powershell -ExecutionPolicy ByPass -c "& ([scriptblock]::Create((irm https://bub.build/install.ps1))) --preset recommended --dependency extra-plugin"
|
|
24
42
|
```
|
|
25
43
|
|
|
26
44
|
Or from source:
|
|
@@ -34,9 +52,9 @@ uv sync # enough to run Bub from source
|
|
|
34
52
|
For local development, use `make install` instead so the website toolchain and `prek` hooks are installed too.
|
|
35
53
|
|
|
36
54
|
```bash
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
bub chat # interactive session
|
|
56
|
+
bub run "summarize this repo" # one-shot task
|
|
57
|
+
bub gateway # channel listener mode
|
|
40
58
|
```
|
|
41
59
|
|
|
42
60
|
## Why Bub
|
|
@@ -117,9 +135,10 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
117
135
|
| `BUB_API_BASE` | — | Custom provider endpoint |
|
|
118
136
|
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
|
|
119
137
|
| `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
|
|
120
|
-
| `BUB_MAX_STEPS` |
|
|
138
|
+
| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
|
|
121
139
|
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
122
140
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
141
|
+
| `BUB_SPILL_THRESHOLD` | `4096` | Estimated tokens before tool output spills; `0` disables |
|
|
123
142
|
|
|
124
143
|
## Background
|
|
125
144
|
|
|
@@ -6,28 +6,35 @@ import sys
|
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
+
from bub.builtin.settings import load_settings
|
|
9
10
|
from bub.framework import BubFramework
|
|
10
11
|
|
|
12
|
+
LOG_LEVEL = {
|
|
13
|
+
0: "INFO",
|
|
14
|
+
1: "DEBUG",
|
|
15
|
+
2: "TRACE",
|
|
16
|
+
}
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
|
|
19
|
+
def _instrument_bub(level: str) -> None:
|
|
13
20
|
from loguru import logger
|
|
14
21
|
|
|
15
22
|
logger.remove()
|
|
16
|
-
logger.add(sys.stderr, colorize=True)
|
|
23
|
+
logger.add(sys.stderr, level=level, colorize=True, diagnose=False)
|
|
17
24
|
|
|
18
25
|
try:
|
|
19
26
|
import logfire
|
|
20
27
|
from logfire.integrations.loguru import LogfireHandler
|
|
21
28
|
|
|
22
29
|
logfire.configure()
|
|
23
|
-
logger.add(LogfireHandler(), format="{message}")
|
|
30
|
+
logger.add(LogfireHandler(), level=level, format="{message}", diagnose=False)
|
|
24
31
|
except Exception as exc:
|
|
25
32
|
logger.debug("logfire instrumentation disabled: {}", exc)
|
|
26
33
|
|
|
27
34
|
|
|
28
35
|
def create_cli_app() -> typer.Typer:
|
|
29
|
-
_instrument_bub()
|
|
30
36
|
framework = BubFramework()
|
|
37
|
+
_instrument_bub(LOG_LEVEL[load_settings().verbose])
|
|
31
38
|
framework.load_hooks()
|
|
32
39
|
app = framework.create_cli_app()
|
|
33
40
|
|
|
@@ -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.4.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 4,
|
|
21
|
+
__version__ = version = '0.4.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -22,12 +22,12 @@ from bub.builtin.model_runner import (
|
|
|
22
22
|
is_context_length_error,
|
|
23
23
|
)
|
|
24
24
|
from bub.builtin.settings import load_settings
|
|
25
|
-
from bub.builtin.tape import Tape
|
|
26
25
|
from bub.envelope import field_of
|
|
27
26
|
from bub.framework import BubFramework
|
|
28
27
|
from bub.skills import discover_skills, render_skills_prompt
|
|
28
|
+
from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore, is_async_tape_store
|
|
29
29
|
from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
|
|
30
|
-
from bub.tape import
|
|
30
|
+
from bub.tape import Tape
|
|
31
31
|
from bub.tools import (
|
|
32
32
|
REGISTRY,
|
|
33
33
|
Tool,
|
|
@@ -37,7 +37,6 @@ from bub.tools import (
|
|
|
37
37
|
from bub.turn import TurnState
|
|
38
38
|
from bub.utils import workspace_from_state
|
|
39
39
|
|
|
40
|
-
CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
41
40
|
HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
|
|
42
41
|
MAX_AUTO_HANDOFF_RETRIES = 1
|
|
43
42
|
|
|
@@ -59,7 +58,12 @@ class Agent:
|
|
|
59
58
|
tape_store = InMemoryTapeStore()
|
|
60
59
|
if not is_async_tape_store(tape_store):
|
|
61
60
|
tape_store = AsyncTapeStoreAdapter(tape_store)
|
|
62
|
-
return Tape(
|
|
61
|
+
return Tape(
|
|
62
|
+
bub.home / "tapes",
|
|
63
|
+
tape_store,
|
|
64
|
+
self.framework.build_tape_context(),
|
|
65
|
+
sidecars=self.framework.get_tape_sidecars(),
|
|
66
|
+
)
|
|
63
67
|
|
|
64
68
|
@staticmethod
|
|
65
69
|
def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents:
|
|
@@ -291,7 +295,7 @@ class Agent:
|
|
|
291
295
|
)
|
|
292
296
|
return
|
|
293
297
|
|
|
294
|
-
next_prompt = self.
|
|
298
|
+
next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state)
|
|
295
299
|
await tape.append_event(
|
|
296
300
|
"loop.step",
|
|
297
301
|
{
|
|
@@ -398,11 +402,6 @@ class Agent:
|
|
|
398
402
|
blocks.append(skills_prompt)
|
|
399
403
|
return "\n\n".join(blocks)
|
|
400
404
|
|
|
401
|
-
def _continue_prompt(self, tape: Tape) -> str:
|
|
402
|
-
if "context" in tape.context.state:
|
|
403
|
-
return f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
|
|
404
|
-
return CONTINUE_PROMPT
|
|
405
|
-
|
|
406
405
|
def _has_steering_messages(self, state: TurnState) -> bool:
|
|
407
406
|
steering_inbox = self.framework.get_steering_inbox()
|
|
408
407
|
return bool(steering_inbox and steering_inbox.message_count(state) > 0)
|
|
@@ -80,12 +80,27 @@ def list_hooks(ctx: typer.Context) -> None:
|
|
|
80
80
|
def gateway(
|
|
81
81
|
ctx: typer.Context,
|
|
82
82
|
enable_channels: list[str] = typer.Option([], "--enable-channel", help="Channels to enable for CLI (default: all)"),
|
|
83
|
+
install: bool = typer.Option(False, "--install", help="Install and start the gateway as a per-user service"),
|
|
84
|
+
uninstall: bool = typer.Option(False, "--uninstall", help="Stop and uninstall the per-user gateway service"),
|
|
83
85
|
) -> None:
|
|
84
86
|
"""Start message listeners(like telegram)."""
|
|
85
|
-
from bub.channels.manager import ChannelManager
|
|
86
|
-
|
|
87
87
|
framework = ctx.ensure_object(BubFramework)
|
|
88
88
|
|
|
89
|
+
if install and uninstall:
|
|
90
|
+
raise typer.BadParameter("--install and --uninstall cannot be used together")
|
|
91
|
+
if uninstall:
|
|
92
|
+
from bub.gateway_installer import GatewayServiceError, uninstall_gateway
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
removed = uninstall_gateway()
|
|
96
|
+
except GatewayServiceError as exc:
|
|
97
|
+
typer.secho(f"Gateway uninstallation failed: {exc}", err=True, fg="red")
|
|
98
|
+
raise typer.Exit(1) from exc
|
|
99
|
+
typer.echo(f"Stopped and uninstalled the gateway from {removed.backend}: {removed.destination}")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
from bub.channels.manager import ChannelManager
|
|
103
|
+
|
|
89
104
|
manager = ChannelManager(framework, enabled_channels=enable_channels or None)
|
|
90
105
|
if not manager.enabled_channels():
|
|
91
106
|
typer.secho(
|
|
@@ -94,6 +109,16 @@ def gateway(
|
|
|
94
109
|
fg="red",
|
|
95
110
|
)
|
|
96
111
|
raise typer.Exit(1)
|
|
112
|
+
if install:
|
|
113
|
+
from bub.gateway_installer import GatewayServiceError, install_gateway
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
installed = install_gateway(framework.workspace, enable_channels)
|
|
117
|
+
except GatewayServiceError as exc:
|
|
118
|
+
typer.secho(f"Gateway installation failed: {exc}", err=True, fg="red")
|
|
119
|
+
raise typer.Exit(1) from exc
|
|
120
|
+
typer.echo(f"Installed and started the gateway with {installed.backend}: {installed.destination}")
|
|
121
|
+
return
|
|
97
122
|
asyncio.run(manager.listen_and_run())
|
|
98
123
|
|
|
99
124
|
|
|
@@ -134,6 +159,26 @@ def onboard(ctx: typer.Context) -> None:
|
|
|
134
159
|
|
|
135
160
|
typer.echo(f"Saved config to {framework.config_file}")
|
|
136
161
|
|
|
162
|
+
from bub import inquirer as bub_inquirer
|
|
163
|
+
from bub.gateway_installer import GatewayServiceError, install_gateway, is_gateway_service_supported
|
|
164
|
+
|
|
165
|
+
enabled_channels = str(config_data.get("enabled_channels", "")).strip()
|
|
166
|
+
if not enabled_channels or not is_gateway_service_supported():
|
|
167
|
+
return
|
|
168
|
+
if not bub_inquirer.ask_confirm("Install gateway as a background service?", default=False):
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
installed = install_gateway(framework.workspace, [])
|
|
173
|
+
except GatewayServiceError as exc:
|
|
174
|
+
typer.secho(
|
|
175
|
+
f"Gateway installation failed: {exc}. Configuration remains saved at {framework.config_file}.",
|
|
176
|
+
err=True,
|
|
177
|
+
fg="red",
|
|
178
|
+
)
|
|
179
|
+
raise typer.Exit(1) from exc
|
|
180
|
+
typer.echo(f"Installed and started the gateway with {installed.backend}: {installed.destination}")
|
|
181
|
+
|
|
137
182
|
|
|
138
183
|
@lru_cache(maxsize=1)
|
|
139
184
|
def _find_uv() -> str:
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
from datetime import datetime
|
|
3
|
+
from difflib import get_close_matches
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from typing import cast
|
|
5
|
+
from typing import Any, cast
|
|
5
6
|
|
|
6
7
|
import typer
|
|
7
8
|
from loguru import logger
|
|
@@ -14,13 +15,16 @@ from bub.builtin.steering import InMemorySteeringInbox
|
|
|
14
15
|
from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot
|
|
15
16
|
from bub.channels.base import Channel
|
|
16
17
|
from bub.channels.contracts import MessageHandler
|
|
17
|
-
from bub.channels.message import ChannelMessage, MediaItem
|
|
18
|
+
from bub.channels.message import ChannelMessage, MediaItem, audio_format_from_mime_type
|
|
18
19
|
from bub.envelope import Envelope, content_of, field_of
|
|
19
20
|
from bub.framework import BubFramework
|
|
20
21
|
from bub.hooks import hookimpl
|
|
22
|
+
from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult
|
|
21
23
|
from bub.model_selection import ModelChoice, ModelOptions
|
|
22
|
-
from bub.
|
|
23
|
-
from bub.
|
|
24
|
+
from bub.sidecars import TapeSidecar
|
|
25
|
+
from bub.store import TapeStore
|
|
26
|
+
from bub.streaming import AsyncStreamEvents, StreamState
|
|
27
|
+
from bub.tape import Tape, TapeContext
|
|
24
28
|
from bub.turn import TurnState
|
|
25
29
|
|
|
26
30
|
AGENTS_FILE_NAME = "AGENTS.md"
|
|
@@ -57,13 +61,24 @@ When responding to a channel message, you MUST:
|
|
|
57
61
|
Excessively long context may cause model call failures. In this case, you MAY use tape.info to retrieve the token usage and you SHOULD use tape.handoff tool to shorten the retrieved history.
|
|
58
62
|
</context_contract>
|
|
59
63
|
"""
|
|
64
|
+
DEFAULT_CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _input_audio_part(data_url: str, mime_type: str) -> dict[str, Any] | None:
|
|
68
|
+
prefix, separator, data = data_url.partition("base64,")
|
|
69
|
+
if not separator or not prefix.startswith("data:audio/") or not data:
|
|
70
|
+
return None
|
|
71
|
+
return {
|
|
72
|
+
"type": "input_audio",
|
|
73
|
+
"input_audio": {"data": data, "format": audio_format_from_mime_type(mime_type)},
|
|
74
|
+
}
|
|
60
75
|
|
|
61
76
|
|
|
62
77
|
class BuiltinImpl:
|
|
63
78
|
"""Default hook implementations for basic runtime operations."""
|
|
64
79
|
|
|
65
80
|
def __init__(self, framework: BubFramework) -> None:
|
|
66
|
-
from bub.builtin import tools # noqa: F401
|
|
81
|
+
from bub.builtin import spill, tools # noqa: F401
|
|
67
82
|
|
|
68
83
|
self.framework = framework
|
|
69
84
|
self._agent: Agent | None = None
|
|
@@ -194,13 +209,18 @@ class BuiltinImpl:
|
|
|
194
209
|
media_parts: list[dict] = []
|
|
195
210
|
for item in cast("list[MediaItem]", media):
|
|
196
211
|
match item.type:
|
|
197
|
-
case "image":
|
|
212
|
+
case "image" | "video":
|
|
198
213
|
data_url = await item.get_url()
|
|
199
214
|
if not data_url:
|
|
200
215
|
continue
|
|
201
|
-
|
|
216
|
+
part_type = f"{item.type}_url"
|
|
217
|
+
media_parts.append({"type": part_type, part_type: {"url": data_url}})
|
|
218
|
+
case "audio":
|
|
219
|
+
data_url = await item.get_url()
|
|
220
|
+
if data_url and (audio_part := _input_audio_part(data_url, item.mime_type)):
|
|
221
|
+
media_parts.append(audio_part)
|
|
202
222
|
case _:
|
|
203
|
-
pass
|
|
223
|
+
pass
|
|
204
224
|
if media_parts:
|
|
205
225
|
return [{"type": "text", "text": text}, *media_parts]
|
|
206
226
|
return text
|
|
@@ -214,6 +234,13 @@ class BuiltinImpl:
|
|
|
214
234
|
model=state.get("model"),
|
|
215
235
|
)
|
|
216
236
|
|
|
237
|
+
@hookimpl
|
|
238
|
+
def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
|
|
239
|
+
del prompt, state
|
|
240
|
+
if "context" in tape.context.state:
|
|
241
|
+
return f"{DEFAULT_CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
|
|
242
|
+
return DEFAULT_CONTINUE_PROMPT
|
|
243
|
+
|
|
217
244
|
@hookimpl
|
|
218
245
|
def register_cli_commands(self, app: typer.Typer) -> None:
|
|
219
246
|
from bub.builtin import cli
|
|
@@ -355,10 +382,17 @@ class BuiltinImpl:
|
|
|
355
382
|
@hookimpl
|
|
356
383
|
def provide_tape_store(self) -> TapeStore:
|
|
357
384
|
import bub
|
|
358
|
-
from bub.
|
|
385
|
+
from bub.store import FileTapeStore
|
|
359
386
|
|
|
360
387
|
return FileTapeStore(directory=bub.home / "tapes")
|
|
361
388
|
|
|
389
|
+
@hookimpl
|
|
390
|
+
def provide_tape_sidecar(self) -> TapeSidecar:
|
|
391
|
+
from bub.builtin.spill import SpillSettings, SpillStore
|
|
392
|
+
from bub.configure import ensure_config
|
|
393
|
+
|
|
394
|
+
return SpillStore(ensure_config(SpillSettings))
|
|
395
|
+
|
|
362
396
|
@hookimpl
|
|
363
397
|
def build_tape_context(self) -> TapeContext:
|
|
364
398
|
return default_tape_context()
|
|
@@ -378,3 +412,55 @@ class BuiltinImpl:
|
|
|
378
412
|
if channel_router is None:
|
|
379
413
|
return None
|
|
380
414
|
return await channel_router.admit_channel_message(session_id=session_id, message=message, turn=turn)
|
|
415
|
+
|
|
416
|
+
@hookimpl
|
|
417
|
+
async def before_tool_call(
|
|
418
|
+
self,
|
|
419
|
+
call: ToolCall,
|
|
420
|
+
state: TurnState,
|
|
421
|
+
) -> ToolCallDecision | None:
|
|
422
|
+
"""Recover hallucinated/unknown tool names without interrupting the turn.
|
|
423
|
+
|
|
424
|
+
When the model invokes a tool outside the current model-facing tool set,
|
|
425
|
+
replace it with a guidance ``tool_result`` so the model can re-issue a
|
|
426
|
+
valid call on the next step.
|
|
427
|
+
"""
|
|
428
|
+
from bub.tools import REGISTRY, model_tools
|
|
429
|
+
|
|
430
|
+
available_tools = tuple(tool_item.name for tool_item in model_tools(REGISTRY.values()))
|
|
431
|
+
if call.tool in available_tools:
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
matches = get_close_matches(call.tool, available_tools, n=3, cutoff=0.6)
|
|
435
|
+
if matches:
|
|
436
|
+
suggestions = "\n".join(f"- {name}" for name in matches)
|
|
437
|
+
guidance = f"Tool `{call.tool}` does not exist. Did you mean one of the following?\n{suggestions}"
|
|
438
|
+
elif "skill" in available_tools:
|
|
439
|
+
guidance = f"Tool `{call.tool}` does not exist. Invoke the `skill` tool to list available skills."
|
|
440
|
+
else:
|
|
441
|
+
guidance = f"Tool `{call.tool}` does not exist. No similar tool is available."
|
|
442
|
+
return ToolCallDecision.replace(guidance)
|
|
443
|
+
|
|
444
|
+
@hookimpl
|
|
445
|
+
async def after_tool_call(
|
|
446
|
+
self,
|
|
447
|
+
call: ToolCall,
|
|
448
|
+
result: ToolCallResult,
|
|
449
|
+
state: TurnState,
|
|
450
|
+
) -> None:
|
|
451
|
+
from bub.builtin.spill import SPILL_SIDECAR_NAME, SpillStore
|
|
452
|
+
|
|
453
|
+
if result.error is not None or not isinstance(result.result, str):
|
|
454
|
+
return
|
|
455
|
+
tape = state.get("_runtime_tape")
|
|
456
|
+
if tape is None:
|
|
457
|
+
return
|
|
458
|
+
spill = tape.get_sidecar(SPILL_SIDECAR_NAME)
|
|
459
|
+
if not isinstance(spill, SpillStore):
|
|
460
|
+
return
|
|
461
|
+
result.result = await spill.spill_tool_result(
|
|
462
|
+
tape,
|
|
463
|
+
result.result,
|
|
464
|
+
tool=call.tool,
|
|
465
|
+
run_id=call.run_id,
|
|
466
|
+
)
|
|
@@ -7,6 +7,7 @@ import re
|
|
|
7
7
|
from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Iterator
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from datetime import UTC, datetime
|
|
10
|
+
from time import monotonic
|
|
10
11
|
from typing import Any, Literal, cast
|
|
11
12
|
|
|
12
13
|
from any_llm import AnyLLM
|
|
@@ -28,7 +29,7 @@ from pydantic import TypeAdapter, ValidationError
|
|
|
28
29
|
|
|
29
30
|
from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider
|
|
30
31
|
from bub.builtin.settings import AgentSettings, ModelCandidate
|
|
31
|
-
from bub.
|
|
32
|
+
from bub.channels.message import audio_mime_type_from_format
|
|
32
33
|
from bub.errors import BubError, ErrorKind
|
|
33
34
|
from bub.hooks.interception import (
|
|
34
35
|
AgentHooks,
|
|
@@ -37,6 +38,7 @@ from bub.hooks.interception import (
|
|
|
37
38
|
LlmCallResult,
|
|
38
39
|
)
|
|
39
40
|
from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
|
|
41
|
+
from bub.tape import Tape
|
|
40
42
|
from bub.tools import Tool, ToolContext, ToolExecutor
|
|
41
43
|
|
|
42
44
|
CONTEXT_LENGTH_PATTERNS = re.compile(
|
|
@@ -45,6 +47,7 @@ CONTEXT_LENGTH_PATTERNS = re.compile(
|
|
|
45
47
|
)
|
|
46
48
|
TOOL_ARGUMENTS_ADAPTER = TypeAdapter(dict[str, Any])
|
|
47
49
|
CompletionResult = ChatCompletion | ParsedChatCompletion[Any] | AsyncIterator[ChatCompletionChunk]
|
|
50
|
+
GOOGLE_FILE_CONTENT_PROVIDERS = frozenset({LLMProvider.GEMINI, LLMProvider.VERTEXAI})
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
def _extra_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any]:
|
|
@@ -56,6 +59,48 @@ def _extra_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any]:
|
|
|
56
59
|
return {}
|
|
57
60
|
|
|
58
61
|
|
|
62
|
+
def _adapt_messages_for_provider(messages: list[dict[str, Any]], provider: LLMProvider) -> list[dict[str, Any]]:
|
|
63
|
+
"""Translate canonical multimodal blocks when a provider uses a different wire format."""
|
|
64
|
+
if provider not in GOOGLE_FILE_CONTENT_PROVIDERS:
|
|
65
|
+
return messages
|
|
66
|
+
|
|
67
|
+
adapted_messages: list[dict[str, Any]] = []
|
|
68
|
+
for message in messages:
|
|
69
|
+
content = message.get("content")
|
|
70
|
+
if not isinstance(content, list):
|
|
71
|
+
adapted_messages.append(message)
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
adapted_content: list[Any] = []
|
|
75
|
+
changed = False
|
|
76
|
+
for part in content:
|
|
77
|
+
if not isinstance(part, dict):
|
|
78
|
+
adapted_content.append(part)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if part.get("type") == "video_url":
|
|
82
|
+
video_url = part.get("video_url")
|
|
83
|
+
url = video_url.get("url") if isinstance(video_url, dict) else None
|
|
84
|
+
if isinstance(url, str) and url:
|
|
85
|
+
adapted_content.append({"type": "file", "file": {"file_data": url}})
|
|
86
|
+
changed = True
|
|
87
|
+
continue
|
|
88
|
+
elif part.get("type") == "input_audio":
|
|
89
|
+
input_audio = part.get("input_audio")
|
|
90
|
+
data = input_audio.get("data") if isinstance(input_audio, dict) else None
|
|
91
|
+
audio_format = input_audio.get("format") if isinstance(input_audio, dict) else None
|
|
92
|
+
if isinstance(data, str) and data and isinstance(audio_format, str) and audio_format:
|
|
93
|
+
mime_type = audio_mime_type_from_format(audio_format)
|
|
94
|
+
file_data = f"data:{mime_type};base64,{data}"
|
|
95
|
+
adapted_content.append({"type": "file", "file": {"file_data": file_data}})
|
|
96
|
+
changed = True
|
|
97
|
+
continue
|
|
98
|
+
adapted_content.append(part)
|
|
99
|
+
|
|
100
|
+
adapted_messages.append({**message, "content": adapted_content} if changed else message)
|
|
101
|
+
return adapted_messages
|
|
102
|
+
|
|
103
|
+
|
|
59
104
|
class ModelRunner:
|
|
60
105
|
def __init__(self, settings: AgentSettings, hooks: AgentHooks | None = None) -> None:
|
|
61
106
|
self.settings = settings
|
|
@@ -90,12 +135,12 @@ class ModelRunner:
|
|
|
90
135
|
reasoning_effort: str | None = None,
|
|
91
136
|
) -> CompletionResult:
|
|
92
137
|
tool_payloads = [tool.to_schema() for tool in tools] or None
|
|
93
|
-
completion_messages: list[dict[str, Any] | ChatCompletionMessage] = list(messages)
|
|
94
138
|
clients = list(self.iter_llm_clients(model))
|
|
95
139
|
completion_error: Exception | None = None
|
|
96
140
|
for index, (candidate, llm) in enumerate(clients):
|
|
97
141
|
try:
|
|
98
142
|
streaming = llm.SUPPORTS_COMPLETION_STREAMING
|
|
143
|
+
completion_messages = _adapt_messages_for_provider(messages, candidate.provider)
|
|
99
144
|
completion_kwargs = {
|
|
100
145
|
**self.settings.completion_args,
|
|
101
146
|
**_extra_options(llm, stream=streaming),
|
|
@@ -175,6 +220,7 @@ class ModelRunner:
|
|
|
175
220
|
await self._fire_after_llm_call(request, output, state, llm_started, tape, error=error)
|
|
176
221
|
|
|
177
222
|
try:
|
|
223
|
+
completion_started = monotonic()
|
|
178
224
|
async with asyncio.timeout(self.settings.model_timeout_seconds):
|
|
179
225
|
completion = await self.completion_response(
|
|
180
226
|
model=request.model,
|
|
@@ -185,6 +231,7 @@ class ModelRunner:
|
|
|
185
231
|
)
|
|
186
232
|
async for event in self._completion_events(completion, state, output):
|
|
187
233
|
yield event
|
|
234
|
+
completion_elapsed = monotonic() - completion_started
|
|
188
235
|
except Exception as exc:
|
|
189
236
|
# Cancellation / consumer close (BaseException) intentionally
|
|
190
237
|
# bypasses after_llm_call: only real completions and failures
|
|
@@ -193,6 +240,8 @@ class ModelRunner:
|
|
|
193
240
|
raise
|
|
194
241
|
await fire_after()
|
|
195
242
|
|
|
243
|
+
yield StreamEvent("usage", {"usage": state.usage, "elapsed_seconds": completion_elapsed})
|
|
244
|
+
|
|
196
245
|
tool_calls = output.tool_calls
|
|
197
246
|
if tool_calls:
|
|
198
247
|
tool_map = {tool_item.name: tool_item for tool_item in tools}
|
|
@@ -461,10 +510,23 @@ def tool_invocation_from_native(
|
|
|
461
510
|
tool_call: ChatCompletionMessageToolCall,
|
|
462
511
|
tool_map: dict[str, Tool],
|
|
463
512
|
) -> tuple[Tool, dict[str, Any]]:
|
|
513
|
+
"""Resolve a model tool call to (runtime tool, arguments).
|
|
514
|
+
|
|
515
|
+
An unknown tool name is not treated as a fatal error: it is surfaced as a
|
|
516
|
+
placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and
|
|
517
|
+
builtin hooks (e.g. ``before_tool_call``) can recover it into a guidance
|
|
518
|
+
``tool_result`` instead of interrupting the turn. If no hook replaces the
|
|
519
|
+
call, the placeholder raises a clear tool error rather than succeeding with
|
|
520
|
+
an empty result.
|
|
521
|
+
"""
|
|
464
522
|
tool_name, arguments = parse_native_function_call(tool_call)
|
|
465
523
|
tool_obj = tool_map.get(tool_name)
|
|
466
524
|
if tool_obj is None:
|
|
467
|
-
|
|
525
|
+
|
|
526
|
+
def raise_unknown_tool(**_: Any) -> None:
|
|
527
|
+
raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.")
|
|
528
|
+
|
|
529
|
+
return Tool(name=tool_name, handler=raise_unknown_tool), arguments
|
|
468
530
|
return tool_obj, arguments
|
|
469
531
|
|
|
470
532
|
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import os
|
|
4
4
|
import pathlib
|
|
5
5
|
import re
|
|
6
|
+
import sys
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
@@ -56,7 +57,7 @@ class AgentSettings(Settings):
|
|
|
56
57
|
fallback_models: list[str] | None = None
|
|
57
58
|
api_key: str | dict[str, str] | None = None
|
|
58
59
|
api_base: str | dict[str, str] | None = None
|
|
59
|
-
max_steps: int =
|
|
60
|
+
max_steps: int = Field(default=sys.maxsize, gt=0)
|
|
60
61
|
max_tokens: int = DEFAULT_MAX_TOKENS
|
|
61
62
|
model_timeout_seconds: int | None = None
|
|
62
63
|
client_args: dict[str, Any] = Field(default_factory=dict)
|