bub 0.4.3__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.3 → bub-0.4.4}/PKG-INFO +24 -5
- {bub-0.4.3 → bub-0.4.4}/README.md +23 -4
- {bub-0.4.3 → bub-0.4.4}/src/bub/__main__.py +11 -4
- {bub-0.4.3 → bub-0.4.4}/src/bub/_version.py +2 -2
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/agent.py +6 -1
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/cli.py +47 -2
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/hook_impl.py +54 -7
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/model_runner.py +50 -1
- {bub-0.4.3 → 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.3 → bub-0.4.4}/src/bub/builtin/tools.py +8 -8
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/__init__.py +24 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/manager.py +17 -4
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/message.py +29 -1
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/telegram.py +4 -4
- {bub-0.4.3 → bub-0.4.4}/src/bub/framework.py +9 -3
- bub-0.4.4/src/bub/gateway_installer.py +227 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/hooks/interception.py +3 -3
- {bub-0.4.3 → bub-0.4.4}/src/bub/hooks/specs.py +10 -2
- bub-0.4.4/src/bub/sidecars.py +20 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/store.py +44 -21
- {bub-0.4.3 → bub-0.4.4}/src/bub/tape.py +139 -7
- {bub-0.4.3 → bub-0.4.4}/src/bub/tools.py +10 -8
- {bub-0.4.3 → bub-0.4.4}/tests/test_agent_hooks.py +13 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_agent.py +1 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_cli.py +118 -2
- {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_model_runner.py +55 -2
- {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_tools.py +53 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_channels.py +96 -1
- bub-0.4.4/tests/test_cli_help.py +30 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_file_tape_store_entry_ids.py +10 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_framework.py +29 -0
- bub-0.4.4/tests/test_gateway_installer.py +159 -0
- {bub-0.4.3 → 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.3 → bub-0.4.4}/tests/test_settings.py +15 -0
- bub-0.4.4/tests/test_spill.py +209 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_tape.py +20 -0
- bub-0.4.3/tests/test_cli_help.py +0 -16
- {bub-0.4.3 → bub-0.4.4}/.gitignore +0 -0
- {bub-0.4.3 → bub-0.4.4}/LICENSE +0 -0
- {bub-0.4.3 → bub-0.4.4}/pyproject.toml +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/__init__.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/__init__.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/auth.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/codex_provider.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/context.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/settings.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/steering.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/__init__.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/admission.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/base.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/ansi_bridge.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/renderer.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/terminal_output.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/writers.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/contracts.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/channels/handler.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/configure.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/envelope.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/errors.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/hooks/__init__.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/hooks/runtime.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/inquirer.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/model_selection.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/skills.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/streaming.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/turn.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/types.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/bub/utils.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/README.md +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/gh/SKILL.md +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/telegram/SKILL.md +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/telegram/scripts/telegram_rich.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/src/skills/telegram/scripts/telegram_send.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/conftest.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_codex.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_hook_impl.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_cli_renderer.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_configure.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_envelope.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_fork_store_merge_back.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_hook_runtime.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_skills.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_subagent_tool.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_tape_search_output.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_tools.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_types.py +0 -0
- {bub-0.4.3 → bub-0.4.4}/tests/test_utils.py +0 -0
{bub-0.4.3 → bub-0.4.4}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
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
|
|
@@ -57,8 +57,26 @@ Built on [agents.md](https://agents.md/) and [Agent Skills](https://agentskills.
|
|
|
57
57
|
|
|
58
58
|
## Quick Start
|
|
59
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
|
+
|
|
60
74
|
```bash
|
|
61
|
-
|
|
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"
|
|
62
80
|
```
|
|
63
81
|
|
|
64
82
|
Or from source:
|
|
@@ -72,9 +90,9 @@ uv sync # enough to run Bub from source
|
|
|
72
90
|
For local development, use `make install` instead so the website toolchain and `prek` hooks are installed too.
|
|
73
91
|
|
|
74
92
|
```bash
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
bub chat # interactive session
|
|
94
|
+
bub run "summarize this repo" # one-shot task
|
|
95
|
+
bub gateway # channel listener mode
|
|
78
96
|
```
|
|
79
97
|
|
|
80
98
|
## Why Bub
|
|
@@ -158,6 +176,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
158
176
|
| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
|
|
159
177
|
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
160
178
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
179
|
+
| `BUB_SPILL_THRESHOLD` | `4096` | Estimated tokens before tool output spills; `0` disables |
|
|
161
180
|
|
|
162
181
|
## Background
|
|
163
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
|
|
@@ -120,6 +138,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
120
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
|
|
@@ -58,7 +58,12 @@ class Agent:
|
|
|
58
58
|
tape_store = InMemoryTapeStore()
|
|
59
59
|
if not is_async_tape_store(tape_store):
|
|
60
60
|
tape_store = AsyncTapeStoreAdapter(tape_store)
|
|
61
|
-
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
|
+
)
|
|
62
67
|
|
|
63
68
|
@staticmethod
|
|
64
69
|
def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents:
|
|
@@ -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:
|
|
@@ -2,7 +2,7 @@ import sys
|
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
from difflib import get_close_matches
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import cast
|
|
5
|
+
from typing import Any, cast
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
from loguru import logger
|
|
@@ -15,12 +15,13 @@ from bub.builtin.steering import InMemorySteeringInbox
|
|
|
15
15
|
from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot
|
|
16
16
|
from bub.channels.base import Channel
|
|
17
17
|
from bub.channels.contracts import MessageHandler
|
|
18
|
-
from bub.channels.message import ChannelMessage, MediaItem
|
|
18
|
+
from bub.channels.message import ChannelMessage, MediaItem, audio_format_from_mime_type
|
|
19
19
|
from bub.envelope import Envelope, content_of, field_of
|
|
20
20
|
from bub.framework import BubFramework
|
|
21
21
|
from bub.hooks import hookimpl
|
|
22
|
-
from bub.hooks.interception import ToolCall, ToolCallDecision
|
|
22
|
+
from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult
|
|
23
23
|
from bub.model_selection import ModelChoice, ModelOptions
|
|
24
|
+
from bub.sidecars import TapeSidecar
|
|
24
25
|
from bub.store import TapeStore
|
|
25
26
|
from bub.streaming import AsyncStreamEvents, StreamState
|
|
26
27
|
from bub.tape import Tape, TapeContext
|
|
@@ -63,11 +64,21 @@ Excessively long context may cause model call failures. In this case, you MAY us
|
|
|
63
64
|
DEFAULT_CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
64
65
|
|
|
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
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
66
77
|
class BuiltinImpl:
|
|
67
78
|
"""Default hook implementations for basic runtime operations."""
|
|
68
79
|
|
|
69
80
|
def __init__(self, framework: BubFramework) -> None:
|
|
70
|
-
from bub.builtin import tools # noqa: F401
|
|
81
|
+
from bub.builtin import spill, tools # noqa: F401
|
|
71
82
|
|
|
72
83
|
self.framework = framework
|
|
73
84
|
self._agent: Agent | None = None
|
|
@@ -198,13 +209,18 @@ class BuiltinImpl:
|
|
|
198
209
|
media_parts: list[dict] = []
|
|
199
210
|
for item in cast("list[MediaItem]", media):
|
|
200
211
|
match item.type:
|
|
201
|
-
case "image":
|
|
212
|
+
case "image" | "video":
|
|
202
213
|
data_url = await item.get_url()
|
|
203
214
|
if not data_url:
|
|
204
215
|
continue
|
|
205
|
-
|
|
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)
|
|
206
222
|
case _:
|
|
207
|
-
pass
|
|
223
|
+
pass
|
|
208
224
|
if media_parts:
|
|
209
225
|
return [{"type": "text", "text": text}, *media_parts]
|
|
210
226
|
return text
|
|
@@ -370,6 +386,13 @@ class BuiltinImpl:
|
|
|
370
386
|
|
|
371
387
|
return FileTapeStore(directory=bub.home / "tapes")
|
|
372
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
|
+
|
|
373
396
|
@hookimpl
|
|
374
397
|
def build_tape_context(self) -> TapeContext:
|
|
375
398
|
return default_tape_context()
|
|
@@ -417,3 +440,27 @@ class BuiltinImpl:
|
|
|
417
440
|
else:
|
|
418
441
|
guidance = f"Tool `{call.tool}` does not exist. No similar tool is available."
|
|
419
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,6 +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
|
|
32
|
+
from bub.channels.message import audio_mime_type_from_format
|
|
31
33
|
from bub.errors import BubError, ErrorKind
|
|
32
34
|
from bub.hooks.interception import (
|
|
33
35
|
AgentHooks,
|
|
@@ -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}
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import contextlib
|
|
5
5
|
import os
|
|
6
6
|
import shutil
|
|
7
|
+
import signal
|
|
7
8
|
import uuid
|
|
8
9
|
from dataclasses import dataclass, field
|
|
9
10
|
|
|
@@ -33,6 +34,8 @@ class ManagedShell:
|
|
|
33
34
|
|
|
34
35
|
class ShellManager:
|
|
35
36
|
SHELL = shutil.which("bash") or shutil.which("sh") if os.name != "nt" else None
|
|
37
|
+
TERMINATE_TIMEOUT = 3.0
|
|
38
|
+
DRAIN_TIMEOUT = 1.0
|
|
36
39
|
|
|
37
40
|
def __init__(self) -> None:
|
|
38
41
|
self._shells: dict[str, ManagedShell] = {}
|
|
@@ -44,6 +47,7 @@ class ShellManager:
|
|
|
44
47
|
stdout=asyncio.subprocess.PIPE,
|
|
45
48
|
stderr=asyncio.subprocess.PIPE,
|
|
46
49
|
executable=self.SHELL,
|
|
50
|
+
start_new_session=os.name != "nt",
|
|
47
51
|
)
|
|
48
52
|
shell = ManagedShell(
|
|
49
53
|
shell_id=f"bash-{uuid.uuid4().hex[:8]}",
|
|
@@ -70,20 +74,52 @@ class ShellManager:
|
|
|
70
74
|
|
|
71
75
|
async def terminate(self, shell_id: str) -> ManagedShell:
|
|
72
76
|
shell = self.get(shell_id)
|
|
73
|
-
|
|
74
|
-
await self._finalize_shell(shell)
|
|
75
|
-
return shell
|
|
76
|
-
|
|
77
|
-
shell.process.terminate()
|
|
77
|
+
self._signal_shell(shell, kill=False)
|
|
78
78
|
try:
|
|
79
|
-
async with asyncio.timeout(
|
|
80
|
-
|
|
79
|
+
async with asyncio.timeout(self.TERMINATE_TIMEOUT):
|
|
80
|
+
# The shell may exit before its children, even when they have
|
|
81
|
+
# closed their output pipes. Wait for the group, not just its leader.
|
|
82
|
+
while self._is_running(shell):
|
|
83
|
+
await asyncio.sleep(0.05)
|
|
81
84
|
except TimeoutError:
|
|
82
|
-
shell
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
self._signal_shell(shell, kill=True)
|
|
86
|
+
try:
|
|
87
|
+
async with asyncio.timeout(self.DRAIN_TIMEOUT):
|
|
88
|
+
await self.wait_closed(shell_id)
|
|
89
|
+
except TimeoutError:
|
|
90
|
+
# A descendant can escape the group and retain a pipe. Do not let
|
|
91
|
+
# waiting for EOF make termination unbounded.
|
|
92
|
+
for task in shell.read_tasks:
|
|
93
|
+
task.cancel()
|
|
94
|
+
await asyncio.gather(*shell.read_tasks, return_exceptions=True)
|
|
95
|
+
self._shells.pop(shell_id, None)
|
|
85
96
|
return shell
|
|
86
97
|
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _signal_shell(shell: ManagedShell, *, kill: bool) -> None:
|
|
100
|
+
with contextlib.suppress(ProcessLookupError):
|
|
101
|
+
if os.name != "nt":
|
|
102
|
+
os.killpg(shell.process.pid, signal.SIGKILL if kill else signal.SIGTERM)
|
|
103
|
+
elif shell.returncode is None:
|
|
104
|
+
if kill:
|
|
105
|
+
shell.process.kill()
|
|
106
|
+
else:
|
|
107
|
+
shell.process.terminate()
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _is_running(shell: ManagedShell) -> bool:
|
|
111
|
+
if os.name == "nt":
|
|
112
|
+
return shell.returncode is None
|
|
113
|
+
try:
|
|
114
|
+
os.killpg(shell.process.pid, 0)
|
|
115
|
+
except ProcessLookupError:
|
|
116
|
+
return False
|
|
117
|
+
except PermissionError:
|
|
118
|
+
# EPERM does not establish that the group is gone (in particular
|
|
119
|
+
# while its leader is exiting on macOS).
|
|
120
|
+
return True
|
|
121
|
+
return True
|
|
122
|
+
|
|
87
123
|
async def terminate_session(self, session_id: str) -> int:
|
|
88
124
|
shell_ids = [shell.shell_id for shell in self._shells.values() if shell.session_id == session_id]
|
|
89
125
|
for shell_id in shell_ids:
|
|
@@ -100,8 +136,8 @@ class ShellManager:
|
|
|
100
136
|
|
|
101
137
|
async def _finalize_shell(self, shell: ManagedShell) -> None:
|
|
102
138
|
for task in shell.read_tasks:
|
|
103
|
-
|
|
104
|
-
|
|
139
|
+
# A caller timing out must not cancel a reader or swallow cancellation.
|
|
140
|
+
await asyncio.shield(task)
|
|
105
141
|
self._shells.pop(shell.shell_id, None)
|
|
106
142
|
|
|
107
143
|
async def _drain_stream(
|