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.
Files changed (94) hide show
  1. {bub-0.4.3 → bub-0.4.4}/PKG-INFO +24 -5
  2. {bub-0.4.3 → bub-0.4.4}/README.md +23 -4
  3. {bub-0.4.3 → bub-0.4.4}/src/bub/__main__.py +11 -4
  4. {bub-0.4.3 → bub-0.4.4}/src/bub/_version.py +2 -2
  5. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/agent.py +6 -1
  6. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/cli.py +47 -2
  7. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/hook_impl.py +54 -7
  8. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/model_runner.py +50 -1
  9. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/shell_manager.py +48 -12
  10. bub-0.4.4/src/bub/builtin/spill.py +303 -0
  11. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/tools.py +8 -8
  12. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/__init__.py +24 -0
  13. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/manager.py +17 -4
  14. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/message.py +29 -1
  15. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/telegram.py +4 -4
  16. {bub-0.4.3 → bub-0.4.4}/src/bub/framework.py +9 -3
  17. bub-0.4.4/src/bub/gateway_installer.py +227 -0
  18. {bub-0.4.3 → bub-0.4.4}/src/bub/hooks/interception.py +3 -3
  19. {bub-0.4.3 → bub-0.4.4}/src/bub/hooks/specs.py +10 -2
  20. bub-0.4.4/src/bub/sidecars.py +20 -0
  21. {bub-0.4.3 → bub-0.4.4}/src/bub/store.py +44 -21
  22. {bub-0.4.3 → bub-0.4.4}/src/bub/tape.py +139 -7
  23. {bub-0.4.3 → bub-0.4.4}/src/bub/tools.py +10 -8
  24. {bub-0.4.3 → bub-0.4.4}/tests/test_agent_hooks.py +13 -0
  25. {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_agent.py +1 -0
  26. {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_cli.py +118 -2
  27. {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_model_runner.py +55 -2
  28. {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_tools.py +53 -0
  29. {bub-0.4.3 → bub-0.4.4}/tests/test_channels.py +96 -1
  30. bub-0.4.4/tests/test_cli_help.py +30 -0
  31. {bub-0.4.3 → bub-0.4.4}/tests/test_file_tape_store_entry_ids.py +10 -0
  32. {bub-0.4.3 → bub-0.4.4}/tests/test_framework.py +29 -0
  33. bub-0.4.4/tests/test_gateway_installer.py +159 -0
  34. {bub-0.4.3 → bub-0.4.4}/tests/test_image_message.py +122 -4
  35. bub-0.4.4/tests/test_install_scripts.py +406 -0
  36. {bub-0.4.3 → bub-0.4.4}/tests/test_settings.py +15 -0
  37. bub-0.4.4/tests/test_spill.py +209 -0
  38. {bub-0.4.3 → bub-0.4.4}/tests/test_tape.py +20 -0
  39. bub-0.4.3/tests/test_cli_help.py +0 -16
  40. {bub-0.4.3 → bub-0.4.4}/.gitignore +0 -0
  41. {bub-0.4.3 → bub-0.4.4}/LICENSE +0 -0
  42. {bub-0.4.3 → bub-0.4.4}/pyproject.toml +0 -0
  43. {bub-0.4.3 → bub-0.4.4}/src/bub/__init__.py +0 -0
  44. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/__init__.py +0 -0
  45. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/auth.py +0 -0
  46. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/codex_provider.py +0 -0
  47. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/context.py +0 -0
  48. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/settings.py +0 -0
  49. {bub-0.4.3 → bub-0.4.4}/src/bub/builtin/steering.py +0 -0
  50. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/__init__.py +0 -0
  51. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/admission.py +0 -0
  52. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/base.py +0 -0
  53. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/ansi_bridge.py +0 -0
  54. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/renderer.py +0 -0
  55. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/terminal_output.py +0 -0
  56. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/cli/writers.py +0 -0
  57. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/contracts.py +0 -0
  58. {bub-0.4.3 → bub-0.4.4}/src/bub/channels/handler.py +0 -0
  59. {bub-0.4.3 → bub-0.4.4}/src/bub/configure.py +0 -0
  60. {bub-0.4.3 → bub-0.4.4}/src/bub/envelope.py +0 -0
  61. {bub-0.4.3 → bub-0.4.4}/src/bub/errors.py +0 -0
  62. {bub-0.4.3 → bub-0.4.4}/src/bub/hooks/__init__.py +0 -0
  63. {bub-0.4.3 → bub-0.4.4}/src/bub/hooks/runtime.py +0 -0
  64. {bub-0.4.3 → bub-0.4.4}/src/bub/inquirer.py +0 -0
  65. {bub-0.4.3 → bub-0.4.4}/src/bub/model_selection.py +0 -0
  66. {bub-0.4.3 → bub-0.4.4}/src/bub/skills.py +0 -0
  67. {bub-0.4.3 → bub-0.4.4}/src/bub/streaming.py +0 -0
  68. {bub-0.4.3 → bub-0.4.4}/src/bub/turn.py +0 -0
  69. {bub-0.4.3 → bub-0.4.4}/src/bub/types.py +0 -0
  70. {bub-0.4.3 → bub-0.4.4}/src/bub/utils.py +0 -0
  71. {bub-0.4.3 → bub-0.4.4}/src/skills/README.md +0 -0
  72. {bub-0.4.3 → bub-0.4.4}/src/skills/gh/SKILL.md +0 -0
  73. {bub-0.4.3 → bub-0.4.4}/src/skills/skill-creator/SKILL.md +0 -0
  74. {bub-0.4.3 → bub-0.4.4}/src/skills/skill-creator/license.txt +0 -0
  75. {bub-0.4.3 → bub-0.4.4}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  76. {bub-0.4.3 → bub-0.4.4}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  77. {bub-0.4.3 → bub-0.4.4}/src/skills/telegram/SKILL.md +0 -0
  78. {bub-0.4.3 → bub-0.4.4}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  79. {bub-0.4.3 → bub-0.4.4}/src/skills/telegram/scripts/telegram_rich.py +0 -0
  80. {bub-0.4.3 → bub-0.4.4}/src/skills/telegram/scripts/telegram_send.py +0 -0
  81. {bub-0.4.3 → bub-0.4.4}/tests/conftest.py +0 -0
  82. {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_codex.py +0 -0
  83. {bub-0.4.3 → bub-0.4.4}/tests/test_builtin_hook_impl.py +0 -0
  84. {bub-0.4.3 → bub-0.4.4}/tests/test_cli_renderer.py +0 -0
  85. {bub-0.4.3 → bub-0.4.4}/tests/test_configure.py +0 -0
  86. {bub-0.4.3 → bub-0.4.4}/tests/test_envelope.py +0 -0
  87. {bub-0.4.3 → bub-0.4.4}/tests/test_fork_store_merge_back.py +0 -0
  88. {bub-0.4.3 → bub-0.4.4}/tests/test_hook_runtime.py +0 -0
  89. {bub-0.4.3 → bub-0.4.4}/tests/test_skills.py +0 -0
  90. {bub-0.4.3 → bub-0.4.4}/tests/test_subagent_tool.py +0 -0
  91. {bub-0.4.3 → bub-0.4.4}/tests/test_tape_search_output.py +0 -0
  92. {bub-0.4.3 → bub-0.4.4}/tests/test_tools.py +0 -0
  93. {bub-0.4.3 → bub-0.4.4}/tests/test_types.py +0 -0
  94. {bub-0.4.3 → bub-0.4.4}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bub
3
- Version: 0.4.3
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
- pip install bub
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
- uv run bub chat # interactive session
76
- uv run bub run "summarize this repo" # one-shot task
77
- uv run bub gateway # channel listener mode
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
- pip install bub
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
- uv run bub chat # interactive session
38
- uv run bub run "summarize this repo" # one-shot task
39
- uv run bub gateway # channel listener mode
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
- def _instrument_bub() -> None:
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.3'
22
- __version_tuple__ = version_tuple = (0, 4, 3)
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(bub.home / "tapes", tape_store, self.framework.build_tape_context())
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
- media_parts.append({"type": "image_url", "image_url": {"url": data_url}})
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 # TODO: Not supported for now
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
- if shell.returncode is not None:
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(3):
80
- await shell.process.wait()
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.process.kill()
83
- await shell.process.wait()
84
- await self._finalize_shell(shell)
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
- with contextlib.suppress(asyncio.CancelledError):
104
- await task
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(