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.
Files changed (99) hide show
  1. {bub-0.4.2 → bub-0.4.4}/PKG-INFO +27 -7
  2. {bub-0.4.2 → bub-0.4.4}/README.md +24 -5
  3. {bub-0.4.2 → bub-0.4.4}/pyproject.toml +1 -0
  4. {bub-0.4.2 → bub-0.4.4}/src/bub/__main__.py +11 -4
  5. {bub-0.4.2 → bub-0.4.4}/src/bub/_version.py +2 -2
  6. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/agent.py +9 -10
  7. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/cli.py +47 -2
  8. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/hook_impl.py +95 -9
  9. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/model_runner.py +65 -3
  10. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/settings.py +2 -1
  11. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/shell_manager.py +48 -12
  12. bub-0.4.4/src/bub/builtin/spill.py +303 -0
  13. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/tools.py +8 -8
  14. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/cli/__init__.py +41 -7
  15. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/cli/renderer.py +19 -19
  16. bub-0.4.4/src/bub/channels/cli/writers.py +76 -0
  17. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/manager.py +17 -4
  18. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/message.py +29 -1
  19. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/telegram.py +4 -4
  20. {bub-0.4.2 → bub-0.4.4}/src/bub/framework.py +19 -4
  21. bub-0.4.4/src/bub/gateway_installer.py +227 -0
  22. {bub-0.4.2 → bub-0.4.4}/src/bub/hooks/interception.py +3 -3
  23. {bub-0.4.2 → bub-0.4.4}/src/bub/hooks/specs.py +22 -4
  24. bub-0.4.4/src/bub/sidecars.py +20 -0
  25. bub-0.4.4/src/bub/store.py +560 -0
  26. bub-0.4.4/src/bub/tape.py +535 -0
  27. {bub-0.4.2 → bub-0.4.4}/src/bub/tools.py +11 -9
  28. {bub-0.4.2 → bub-0.4.4}/tests/test_agent_hooks.py +15 -2
  29. {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_agent.py +42 -0
  30. {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_cli.py +118 -2
  31. {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_hook_impl.py +67 -5
  32. {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_model_runner.py +73 -5
  33. {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_tools.py +55 -2
  34. {bub-0.4.2 → bub-0.4.4}/tests/test_channels.py +194 -15
  35. bub-0.4.4/tests/test_cli_help.py +30 -0
  36. bub-0.4.4/tests/test_cli_renderer.py +50 -0
  37. {bub-0.4.2 → bub-0.4.4}/tests/test_file_tape_store_entry_ids.py +12 -2
  38. {bub-0.4.2 → bub-0.4.4}/tests/test_fork_store_merge_back.py +2 -2
  39. {bub-0.4.2 → bub-0.4.4}/tests/test_framework.py +59 -0
  40. bub-0.4.4/tests/test_gateway_installer.py +159 -0
  41. {bub-0.4.2 → bub-0.4.4}/tests/test_image_message.py +122 -4
  42. bub-0.4.4/tests/test_install_scripts.py +406 -0
  43. {bub-0.4.2 → bub-0.4.4}/tests/test_settings.py +15 -0
  44. bub-0.4.4/tests/test_spill.py +209 -0
  45. bub-0.4.2/tests/test_builtin_tape.py → bub-0.4.4/tests/test_tape.py +41 -3
  46. bub-0.4.2/src/bub/builtin/store.py +0 -284
  47. bub-0.4.2/src/bub/builtin/tape.py +0 -250
  48. bub-0.4.2/src/bub/channels/cli/writers.py +0 -42
  49. bub-0.4.2/src/bub/tape.py +0 -366
  50. bub-0.4.2/tests/test_cli_help.py +0 -16
  51. bub-0.4.2/tests/test_cli_renderer.py +0 -32
  52. {bub-0.4.2 → bub-0.4.4}/.gitignore +0 -0
  53. {bub-0.4.2 → bub-0.4.4}/LICENSE +0 -0
  54. {bub-0.4.2 → bub-0.4.4}/src/bub/__init__.py +0 -0
  55. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/__init__.py +0 -0
  56. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/auth.py +0 -0
  57. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/codex_provider.py +0 -0
  58. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/context.py +0 -0
  59. {bub-0.4.2 → bub-0.4.4}/src/bub/builtin/steering.py +0 -0
  60. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/__init__.py +0 -0
  61. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/admission.py +0 -0
  62. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/base.py +0 -0
  63. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/cli/ansi_bridge.py +0 -0
  64. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/cli/terminal_output.py +0 -0
  65. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/contracts.py +0 -0
  66. {bub-0.4.2 → bub-0.4.4}/src/bub/channels/handler.py +0 -0
  67. {bub-0.4.2 → bub-0.4.4}/src/bub/configure.py +0 -0
  68. {bub-0.4.2 → bub-0.4.4}/src/bub/envelope.py +0 -0
  69. {bub-0.4.2 → bub-0.4.4}/src/bub/errors.py +0 -0
  70. {bub-0.4.2 → bub-0.4.4}/src/bub/hooks/__init__.py +0 -0
  71. {bub-0.4.2 → bub-0.4.4}/src/bub/hooks/runtime.py +0 -0
  72. {bub-0.4.2 → bub-0.4.4}/src/bub/inquirer.py +0 -0
  73. {bub-0.4.2 → bub-0.4.4}/src/bub/model_selection.py +0 -0
  74. {bub-0.4.2 → bub-0.4.4}/src/bub/skills.py +0 -0
  75. {bub-0.4.2 → bub-0.4.4}/src/bub/streaming.py +0 -0
  76. {bub-0.4.2 → bub-0.4.4}/src/bub/turn.py +0 -0
  77. {bub-0.4.2 → bub-0.4.4}/src/bub/types.py +0 -0
  78. {bub-0.4.2 → bub-0.4.4}/src/bub/utils.py +0 -0
  79. {bub-0.4.2 → bub-0.4.4}/src/skills/README.md +0 -0
  80. {bub-0.4.2 → bub-0.4.4}/src/skills/gh/SKILL.md +0 -0
  81. {bub-0.4.2 → bub-0.4.4}/src/skills/skill-creator/SKILL.md +0 -0
  82. {bub-0.4.2 → bub-0.4.4}/src/skills/skill-creator/license.txt +0 -0
  83. {bub-0.4.2 → bub-0.4.4}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  84. {bub-0.4.2 → bub-0.4.4}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  85. {bub-0.4.2 → bub-0.4.4}/src/skills/telegram/SKILL.md +0 -0
  86. {bub-0.4.2 → bub-0.4.4}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  87. {bub-0.4.2 → bub-0.4.4}/src/skills/telegram/scripts/telegram_rich.py +0 -0
  88. {bub-0.4.2 → bub-0.4.4}/src/skills/telegram/scripts/telegram_send.py +0 -0
  89. {bub-0.4.2 → bub-0.4.4}/tests/conftest.py +0 -0
  90. {bub-0.4.2 → bub-0.4.4}/tests/test_builtin_codex.py +0 -0
  91. {bub-0.4.2 → bub-0.4.4}/tests/test_configure.py +0 -0
  92. {bub-0.4.2 → bub-0.4.4}/tests/test_envelope.py +0 -0
  93. {bub-0.4.2 → bub-0.4.4}/tests/test_hook_runtime.py +0 -0
  94. {bub-0.4.2 → bub-0.4.4}/tests/test_skills.py +0 -0
  95. {bub-0.4.2 → bub-0.4.4}/tests/test_subagent_tool.py +0 -0
  96. {bub-0.4.2 → bub-0.4.4}/tests/test_tape_search_output.py +0 -0
  97. {bub-0.4.2 → bub-0.4.4}/tests/test_tools.py +0 -0
  98. {bub-0.4.2 → bub-0.4.4}/tests/test_types.py +0 -0
  99. {bub-0.4.2 → bub-0.4.4}/tests/test_utils.py +0 -0
@@ -1,11 +1,12 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: bub
3
- Version: 0.4.2
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
- 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"
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
- uv run bub chat # interactive session
75
- uv run bub run "summarize this repo" # one-shot task
76
- 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
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` | `50` | Max tool-use loop iterations |
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
- 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
@@ -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` | `50` | Max tool-use loop iterations |
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
 
@@ -9,6 +9,7 @@ authors = [
9
9
  ]
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.12,<4.0"
12
+ license = { text = "Apache-2.0" }
12
13
  classifiers = [
13
14
  "Intended Audience :: Developers",
14
15
  "Programming Language :: Python",
@@ -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.2'
22
- __version_tuple__ = version_tuple = (0, 4, 2)
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 AsyncTapeStoreAdapter, InMemoryTapeStore, is_async_tape_store
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(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
+ )
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._continue_prompt(tape)
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.streaming import AsyncStreamEvents
23
- from bub.tape import TapeContext, TapeStore
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
- 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)
202
222
  case _:
203
- pass # TODO: Not supported for now
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.builtin.store import FileTapeStore
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.builtin.tape import Tape
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
- raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.")
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 = 50
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)