From e0af82c30e0b32805c3e04737a73f85c1578fee9 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 15:29:27 +0800 Subject: [PATCH 1/2] refactor!: ship the agent kernel as the separate bubling package Move bub.core into packages/bubling, a uv workspace member published as the `bubling` distribution in lockstep with bub, so users who only embed the agent loop no longer install bub's CLI, channel and plugin dependencies. bub depends on bubling. BREAKING CHANGE: `bub.core` and `bub.prompt` (only in 0.6.0a1) are removed; import from `bubling` instead. The aliases released in 0.5.x (`bub.errors`, `bub.tracing`, `bub.streaming`, `bub.store`, `bub.tape`, `bub.tools`, `bub.sidecars`) now point at bubling's modules. - bubling's tests live in packages/bubling/tests and CI runs them with only bubling installed; the import-linter contracts now guard bubling. - `make build` and the release workflow build both distributions. - bubling ships py.typed so bub and downstream users keep its types. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 23 +- .github/workflows/on-release-main.yml | 4 +- .pre-commit-config.yaml | 4 +- AGENTS.md | 14 +- Dockerfile | 1 + Makefile | 10 +- README.md | 2 +- packages/bubling/LICENSE | 202 ++++++++++++++++++ packages/bubling/README.md | 40 ++++ packages/bubling/pyproject.toml | 56 +++++ .../bubling/src/bubling}/__init__.py | 16 +- .../bubling/src/bubling}/agent.py | 34 +-- .../bubling/src/bubling}/commands.py | 0 .../bubling/src/bubling}/context.py | 4 +- .../bubling/src/bubling}/errors.py | 0 .../bubling/src/bubling}/events.py | 10 +- .../bubling/src/bubling}/model.py | 0 .../bubling/src/bubling}/prompt.py | 4 +- packages/bubling/src/bubling/py.typed | 0 .../bubling/src/bubling}/recorder.py | 4 +- .../bubling/src/bubling}/sidecars.py | 0 .../bubling/src/bubling}/store.py | 4 +- .../bubling/src/bubling}/streaming.py | 4 +- .../bubling/src/bubling}/system_prompt.py | 10 +- .../bubling/src/bubling}/tape.py | 16 +- .../bubling/src/bubling}/tools.py | 10 +- .../bubling/src/bubling}/tracing.py | 0 .../bubling/src/bubling}/turn.py | 0 packages/bubling/tests/conftest.py | 7 + packages/bubling/tests/provider_fakes.py | 52 +++++ .../bubling/tests/test_agent.py | 24 +-- .../bubling/tests}/test_errors.py | 2 +- .../tests}/test_file_tape_store_entry_ids.py | 6 +- .../tests}/test_fork_store_merge_back.py | 4 +- .../bubling/tests}/test_tools.py | 12 +- pyproject.toml | 31 +-- src/bub/__init__.py | 8 +- src/bub/__main__.py | 2 +- src/bub/builtin/agent.py | 14 +- src/bub/builtin/archive.py | 6 +- src/bub/builtin/codemode/__init__.py | 6 +- src/bub/builtin/codemode/code_runner.py | 2 +- src/bub/builtin/codemode/republic_bridge.py | 4 +- src/bub/builtin/hook_impl.py | 22 +- src/bub/builtin/model.py | 2 +- src/bub/builtin/settings.py | 6 +- src/bub/builtin/spill.py | 6 +- src/bub/builtin/store.py | 6 +- src/bub/builtin/tools.py | 4 +- src/bub/channels/base.py | 2 +- src/bub/channels/cli/__init__.py | 10 +- src/bub/channels/contracts.py | 2 +- src/bub/channels/manager.py | 2 +- src/bub/channels/telegram.py | 2 +- src/bub/errors.py | 6 +- src/bub/framework.py | 12 +- src/bub/hooks/interception.py | 6 +- src/bub/hooks/runtime.py | 4 +- src/bub/hooks/specs.py | 22 +- src/bub/prompt.py | 8 - src/bub/sidecars.py | 6 +- src/bub/store.py | 6 +- src/bub/streaming.py | 6 +- src/bub/tape.py | 6 +- src/bub/tools.py | 6 +- src/bub/tracing.py | 6 +- src/bub/turn.py | 4 +- src/bub/utils.py | 2 +- tests/model_fakes.py | 10 +- tests/test_agent_hooks.py | 10 +- ...ore_boundary.py => test_bubling_compat.py} | 29 ++- tests/test_builtin_agent.py | 14 +- tests/test_builtin_cli.py | 2 +- tests/test_builtin_codex.py | 10 +- tests/test_builtin_hook_impl.py | 8 +- tests/test_builtin_tools.py | 8 +- tests/test_channels.py | 12 +- tests/test_codemode.py | 18 +- tests/test_core_agent_step.py | 12 +- tests/test_environment.py | 6 +- tests/test_framework.py | 2 +- tests/test_hook_runtime.py | 2 +- tests/test_image_message.py | 2 +- tests/test_otlp.py | 8 +- tests/test_provider_messages.py | 18 +- tests/test_sdk_isolation.py | 12 +- tests/test_session_settings.py | 6 +- tests/test_settings.py | 6 +- tests/test_spill.py | 12 +- tests/test_subagent_tool.py | 4 +- tests/test_tape.py | 6 +- tests/test_tape_search_output.py | 2 +- tests/test_tool_loading.py | 2 +- tests/test_tracing.py | 16 +- tox.ini | 2 +- uv.lock | 53 +++-- website/src/content/docs/docs/build/sdk.md | 28 +-- .../docs/docs/concepts/tape-and-context.mdx | 2 +- .../docs/docs/concepts/turn-pipeline.mdx | 2 +- .../src/content/docs/docs/reference/hooks.mdx | 8 +- .../src/content/docs/docs/reference/types.mdx | 10 +- .../content/docs/docs/tutorials/fastapi.mdx | 10 +- .../src/content/docs/docs/tutorials/index.mdx | 2 +- .../src/content/docs/zh-cn/docs/build/sdk.md | 28 +-- .../zh-cn/docs/concepts/tape-and-context.mdx | 2 +- .../zh-cn/docs/concepts/turn-pipeline.mdx | 2 +- .../docs/zh-cn/docs/reference/hooks.mdx | 8 +- .../docs/zh-cn/docs/reference/types.mdx | 10 +- .../docs/zh-cn/docs/tutorials/fastapi.mdx | 10 +- .../docs/zh-cn/docs/tutorials/index.mdx | 2 +- 110 files changed, 815 insertions(+), 417 deletions(-) create mode 100644 packages/bubling/LICENSE create mode 100644 packages/bubling/README.md create mode 100644 packages/bubling/pyproject.toml rename {src/bub/core => packages/bubling/src/bubling}/__init__.py (75%) rename {src/bub/core => packages/bubling/src/bubling}/agent.py (96%) rename {src/bub/core => packages/bubling/src/bubling}/commands.py (100%) rename {src/bub/core => packages/bubling/src/bubling}/context.py (96%) rename {src/bub/core => packages/bubling/src/bubling}/errors.py (100%) rename {src/bub/core => packages/bubling/src/bubling}/events.py (97%) rename {src/bub/core => packages/bubling/src/bubling}/model.py (100%) rename {src/bub/core => packages/bubling/src/bubling}/prompt.py (96%) create mode 100644 packages/bubling/src/bubling/py.typed rename {src/bub/core => packages/bubling/src/bubling}/recorder.py (94%) rename {src/bub/core => packages/bubling/src/bubling}/sidecars.py (100%) rename {src/bub/core => packages/bubling/src/bubling}/store.py (99%) rename {src/bub/core => packages/bubling/src/bubling}/streaming.py (97%) rename {src/bub/core => packages/bubling/src/bubling}/system_prompt.py (93%) rename {src/bub/core => packages/bubling/src/bubling}/tape.py (98%) rename {src/bub/core => packages/bubling/src/bubling}/tools.py (98%) rename {src/bub/core => packages/bubling/src/bubling}/tracing.py (100%) rename {src/bub/core => packages/bubling/src/bubling}/turn.py (100%) create mode 100644 packages/bubling/tests/conftest.py create mode 100644 packages/bubling/tests/provider_fakes.py rename tests/test_core_agent.py => packages/bubling/tests/test_agent.py (94%) rename {tests => packages/bubling/tests}/test_errors.py (92%) rename {tests => packages/bubling/tests}/test_file_tape_store_entry_ids.py (86%) rename {tests => packages/bubling/tests}/test_fork_store_merge_back.py (96%) rename {tests => packages/bubling/tests}/test_tools.py (96%) delete mode 100644 src/bub/prompt.py rename tests/{test_core_boundary.py => test_bubling_compat.py} (57%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a4fe6a1b..05f346e3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,11 +35,32 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run tests - run: uv run python -m pytest tests + run: uv run python -m pytest - name: Check typing run: uv run mypy + bubling-standalone: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13", "3.14"] + fail-fast: false + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + with: + python-version: ${{ matrix.python-version }} + + # Install only bubling, without bub, so the kernel's tests prove it stands alone. + - name: Run bubling tests without bub + run: >- + uv run --isolated --no-project --python ${{ matrix.python-version }} + --with ./packages/bubling --with pytest --with pytest-asyncio + pytest packages/bubling/tests diff --git a/.github/workflows/on-release-main.yml b/.github/workflows/on-release-main.yml index 801b8fe0..b83ad194 100644 --- a/.github/workflows/on-release-main.yml +++ b/.github/workflows/on-release-main.yml @@ -42,8 +42,8 @@ jobs: - name: Set up the environment uses: ./.github/actions/setup-python-env - - name: Build package - run: uv build + - name: Build packages + run: uv build --all-packages - name: Publish package run: uv publish --trusted-publishing always diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 824583c5..1e946cc4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,8 +32,8 @@ repos: - id: import-linter name: import-linter # Contracts live in pyproject.toml; grimp finds the packages through PYTHONPATH without installing them. - entry: env PYTHONPATH=src lint-imports + entry: env PYTHONPATH=src:packages/bubling/src lint-imports language: python additional_dependencies: ["import-linter==2.15"] pass_filenames: false - files: ^(src/.*\.py|pyproject\.toml)$ + files: ^((src|packages/[^/]+/src)/.*\.py|pyproject\.toml)$ diff --git a/AGENTS.md b/AGENTS.md index 528bd144..24ebe5e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,16 @@ ## Project Structure & Module Organization -Core code lives under `src/`: +Core code lives under `src/` and `packages/`: - `src/bub/__main__.py`: Typer CLI entrypoint. -- `src/bub/core/`: agent kernel with no settings or plugin dependencies: agent loop over a tape store (`agent.py`), model protocol and Republic chat model adapter (`model.py`), agent events and their bus (`events.py`), the tape recorder handler (`recorder.py`), system prompt sections (`system_prompt.py`), command prompts (`commands.py`), tools, tape, store, and tape context, plus the kernel vocabulary it shares with the framework (`errors.py`, `prompt.py`, `streaming.py`, `tracing.py`, `turn.py`). Nothing in `bub.core` imports the rest of `bub`; the old top-level paths (`bub.errors`, `bub.store`, ...) are compatibility aliases. +- `packages/bubling/`: the agent kernel, published as the separate `bubling` distribution and released in lockstep with `bub`; it has no settings or plugin dependencies: agent loop over a tape store (`agent.py`), model protocol and Republic chat model adapter (`model.py`), agent events and their bus (`events.py`), the tape recorder handler (`recorder.py`), system prompt sections (`system_prompt.py`), command prompts (`commands.py`), tools, tape, store, and tape context, plus the kernel vocabulary it shares with the framework (`errors.py`, `prompt.py`, `streaming.py`, `tracing.py`, `turn.py`). Nothing in `bubling` imports `bub`. Its tests live in `packages/bubling/tests/` and must run with only `bubling` installed. +- `src/bub/{errors,tracing,streaming,store,tape,tools,sidecars}.py`: compatibility aliases of the `bubling` modules of the same name. - `src/bub/framework.py`: turn orchestration and outbound routing. - `src/bub/hooks/`: hook specifications, execution helpers, and interception contracts. - `src/bub/channels/`: channel bases, routing contracts, admission policy, and lifecycle manager. - `src/bub/{envelope,turn,model_selection}.py`: small framework vocabulary owned by each concern. -- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.core.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, tape services, and the fuzzy-searching file tape store. +- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bubling.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, tape services, and the fuzzy-searching file tape store. - `src/bub/channels/`: channel abstractions plus CLI and Telegram adapters. - `src/bub/skills.py` / `src/bub/tools.py`: skill discovery and tool registry. - `src/skills/`: bundled skills shipped with Bub. @@ -26,10 +27,12 @@ Tests live in `tests/`. The website and docs runtime lives in `website/`. - `uv run bub run "hello"`: run one inbound message through the full framework pipeline. - `uv run bub hooks`: inspect discovered hook bindings. - `uv run ruff check .`: run Ruff directly when you only want lint feedback. -- `uv run mypy src`: run mypy directly against `src/`. -- `uv run prek run import-linter -a`: check the import contracts in `pyproject.toml` (`bub.core` must not import the rest of `bub` or application dependencies); `prek` and `make check` run it too. +- `uv run mypy`: run mypy against `src/` and `packages/bubling/src/`. +- `uv run prek run import-linter -a`: check the import contracts in `pyproject.toml` (`bubling` must not import `bub` or application dependencies); `prek` and `make check` run it too. - `uv run pytest -q`: run the main test suite without doctests. - `make test`: run pytest with doctests enabled. +- `uv run --isolated --no-project --with ./packages/bubling --with pytest --with pytest-asyncio pytest packages/bubling/tests`: run `bubling`'s tests with only `bubling` installed, as CI does. +- `make build`: build the distributions of both `bub` and `bubling`. - `make check`: lock validation, `prek` (including import contracts), and typing. - `make docs` / `make docs-test` / `make docs-preview`: serve, build, or preview the Astro website/docs in `website/`. @@ -43,6 +46,7 @@ Tests live in `tests/`. The website and docs runtime lives in `website/`. ## Testing Guidelines - Framework: `pytest`. - Name test files `tests/test_.py`. +- Tests that need only the kernel go in `packages/bubling/tests/` and must not import `bub` or `tests`. - Prefer behavior-oriented test names such as `test_gateway_uses_enabled_channels_only`. - Cover hook precedence, turn lifecycle, CLI/channel behavior, and tape persistence when changing runtime behavior. - Update or add tests in the same change when behavior moves. diff --git a/Dockerfile b/Dockerfile index bf6f90c3..c682aad3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,7 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md LICENSE entrypoint.sh ./ # Copy the full source and install COPY src ./src +COPY packages ./packages RUN uv sync --locked --no-dev --no-editable && \ chmod +x /app/entrypoint.sh diff --git a/Makefile b/Makefile index 315de3ff..8003214a 100644 --- a/Makefile +++ b/Makefile @@ -13,13 +13,13 @@ install: ## Sync Python deps, install website deps, and install prek hooks @echo "==> Installing prek hooks" uv run prek install -check: ## Validate lockfile, run prek across the repo, and type-check src +check: ## Validate lockfile, run prek across the repo, and type-check @echo "==> Verifying uv.lock matches pyproject.toml" uv lock --locked @echo "==> Running prek hooks" uv run prek run -a - @echo "==> Running mypy on src" - uv run mypy src + @echo "==> Running mypy" + uv run mypy vulture: ## Run the optional unused-code check @echo "==> Running vulture via prek" @@ -33,9 +33,9 @@ clean-build: ## Remove local build artifacts @echo "==> Removing dist/" rm -rf dist -build: clean-build ## Build source and wheel distributions +build: clean-build ## Build source and wheel distributions of bub and bubling @echo "==> Building package distributions" - uv build + uv build --all-packages publish: ## Publish the contents of dist/ with uv @echo "==> Publishing dist/ with uv" diff --git a/README.md b/README.md index be728b6e..e25cec2b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ See [Getting Started](https://bub.build/docs/getting-started/) for installer pre - **One runtime across surfaces.** The same inbound pipeline runs across CLI, Telegram, and custom channels. Adapters change the surface, not the runtime model. - **Batteries included.** CLI, Telegram, tools, skills, and model execution ship with the core runtime. Use the defaults first, replace them later. - **Operator equivalence.** Humans and agents work inside the same runtime boundaries, with the same evidence trail and handoff model. -- **Embeddable Python SDK.** `bub.core` exposes the agent loop on its own — bring your own model, tools, prompts, and tape store, with no settings or plugins required. +- **Embeddable Python SDK.** The [`bubling`](packages/bubling) package exposes the agent loop on its own — bring your own model, tools, prompts, and tape store, with no settings or plugins required. ## How It Works diff --git a/packages/bubling/LICENSE b/packages/bubling/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/packages/bubling/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/bubling/README.md b/packages/bubling/README.md new file mode 100644 index 00000000..be786f99 --- /dev/null +++ b/packages/bubling/README.md @@ -0,0 +1,40 @@ +# bubling + +The event-driven agent kernel of [Bub](https://bub.build). Its `Agent` runs the agent loop (call the model, +execute the tools it asks for, repeat until it answers) over components you supply: tools, system prompt +sections, and a tape store, plus the [Republic](https://github.com/bubbuild/republic) model it calls. + +It reads no settings and loads no plugins, so it embeds in any application: + +```bash +uv add bubling +``` + +```python +import asyncio + +import republic + +from bubling import Agent, Tool + + +async def lookup_order(order_id: str) -> dict[str, str]: + """Look up the status of an order.""" + return {"order_id": order_id, "status": "shipped"} + + +async def main() -> None: + agent = Agent(tools=[Tool.from_callable(lookup_order)], system_prompt=["You are an order assistant."]) + async with republic.get_model("openai:gpt-5-mini") as model: + async with agent.run("Where is order A123?", model=model, tape="customer-42") as stream: + async for event in stream: + if event.kind == "text": + print(event.data.get("delta", ""), end="", flush=True) + + +asyncio.run(main()) +``` + +See the [Python SDK guide](https://bub.build/docs/build/sdk/) for the full API. Install +[`bub`](https://pypi.org/project/bub/) for the complete runtime: CLI, channels, hooks, skills and the builtin +agent, built on this package. diff --git a/packages/bubling/pyproject.toml b/packages/bubling/pyproject.toml new file mode 100644 index 00000000..7c4d0100 --- /dev/null +++ b/packages/bubling/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "bubling" +dynamic = ["version"] +description = "Bub's event-driven agent kernel: agent loop, model protocol, tools and tape." +authors = [ + { name = "Chojan Shang", email = "psiace@apache.org" }, + { name = "Frost Ming", email = "me@frostming.com" }, + { name = "Yihong", email = "zouzou0208@gmail.com" }, +] +readme = "README.md" +requires-python = ">=3.12,<4.0" +license = { text = "Apache-2.0" } +classifiers = [ + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +dependencies = [ + "republic>=0.6.0a1", + "pydantic>=2.12.5", + "loguru>=0.7.3", + "typing-extensions>=4.15.0", +] + +[project.urls] +Homepage = "https://bub.build" +Repository = "https://github.com/bubbuild/bub" +Documentation = "https://bub.build" + +[project.optional-dependencies] +trace = [ + "opentelemetry-api>=1.39.0", + "opentelemetry-sdk>=1.39.0", + "opentelemetry-exporter-otlp-proto-http>=1.39.0", +] + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" +fallback-version = "0.3.0" +# Released in lockstep with bub from the same repository tag. +raw-options = { root = "../.." } + +[tool.hatch.build.targets.sdist] +only-include = ["src/bubling", "tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/bubling"] diff --git a/src/bub/core/__init__.py b/packages/bubling/src/bubling/__init__.py similarity index 75% rename from src/bub/core/__init__.py rename to packages/bubling/src/bubling/__init__.py index 10b135db..6505085a 100644 --- a/src/bub/core/__init__.py +++ b/packages/bubling/src/bubling/__init__.py @@ -4,9 +4,9 @@ to registered handlers; the framework and the builtin agent assemble these components. """ -from bub.core.agent import Agent, events_from -from bub.core.context import default_tape_context -from bub.core.events import ( +from bubling.agent import Agent, events_from +from bubling.context import default_tape_context +from bubling.events import ( AfterLlmCall, AfterToolCall, AgentEvent, @@ -27,11 +27,11 @@ ToolCallDecision, ToolCallResult, ) -from bub.core.model import ChatModelClient, Model, ModelClient -from bub.core.recorder import TapeRecorder -from bub.core.system_prompt import PromptContext, PromptSection, SystemPrompt -from bub.core.tape import Tape, TapeContext, session_tape_name -from bub.core.tools import Tool, ToolContext, tool +from bubling.model import ChatModelClient, Model, ModelClient +from bubling.recorder import TapeRecorder +from bubling.system_prompt import PromptContext, PromptSection, SystemPrompt +from bubling.tape import Tape, TapeContext, session_tape_name +from bubling.tools import Tool, ToolContext, tool __all__ = [ "AfterLlmCall", diff --git a/src/bub/core/agent.py b/packages/bubling/src/bubling/agent.py similarity index 96% rename from src/bub/core/agent.py rename to packages/bubling/src/bubling/agent.py index a5bf187b..a580ac26 100644 --- a/src/bub/core/agent.py +++ b/packages/bubling/src/bubling/agent.py @@ -16,10 +16,10 @@ from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady from republic.tools import BuiltinTool -from bub.core.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix -from bub.core.context import default_tape_context -from bub.core.errors import BubError, ErrorKind -from bub.core.events import ( +from bubling.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix +from bubling.context import default_tape_context +from bubling.errors import BubError, ErrorKind +from bubling.events import ( AfterLlmCall, AgentEvent, BeforeLlmCall, @@ -33,17 +33,17 @@ StepStarted, TapeEvent, ) -from bub.core.model import Model, ModelClient, is_context_length_error, model_client -from bub.core.prompt import UserContent -from bub.core.recorder import TapeRecorder -from bub.core.sidecars import TapeSidecar -from bub.core.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.core.system_prompt import PromptContext, PromptSection, SystemPrompt -from bub.core.tape import Tape, TapeContext -from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools -from bub.core.tracing import Span, current_span -from bub.core.turn import TurnState +from bubling.model import Model, ModelClient, is_context_length_error, model_client +from bubling.prompt import UserContent +from bubling.recorder import TapeRecorder +from bubling.sidecars import TapeSidecar +from bubling.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store +from bubling.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bubling.system_prompt import PromptContext, PromptSection, SystemPrompt +from bubling.tape import Tape, TapeContext +from bubling.tools import Tool, ToolContext, ToolExecutor, model_tools +from bubling.tracing import Span, current_span +from bubling.turn import TurnState MAX_AUTO_HANDOFF_RETRIES = 1 @@ -56,7 +56,7 @@ class Agent: The agent owns no configuration or session. A run calls the model given to it, or else the agent's ``model``: a ``republic.ChatModel`` or a - :class:`~bub.core.model.ModelClient`. The agent is assembled from its components: + :class:`~bubling.model.ModelClient`. The agent is assembled from its components: Args: model: Default model of runs not given one; None requires each run to give one. @@ -76,7 +76,7 @@ class Agent: command_prefix: When set, a text prompt starting with it runs as a command (see :meth:`run_command`) instead of the agent loop. None disables commands. - The loop reports its progress as events (:mod:`bub.core.events`) on ``events``; + The loop reports its progress as events (:mod:`bubling.events`) on ``events``; register handlers with ``agent.events.on``. A :class:`TapeRecorder`, registered first, records them on the run's tape. ``BeforeLlmCall`` and ``BeforeToolCall`` handlers may change or short-circuit the call. diff --git a/src/bub/core/commands.py b/packages/bubling/src/bubling/commands.py similarity index 100% rename from src/bub/core/commands.py rename to packages/bubling/src/bubling/commands.py diff --git a/src/bub/core/context.py b/packages/bubling/src/bubling/context.py similarity index 96% rename from src/bub/core/context.py rename to packages/bubling/src/bubling/context.py index 3e649c24..d79a5ece 100644 --- a/src/bub/core/context.py +++ b/packages/bubling/src/bubling/context.py @@ -8,8 +8,8 @@ import republic -from bub.core.tape import TapeContext, TapeEntry, to_message -from bub.core.tools import result_content +from bubling.tape import TapeContext, TapeEntry, to_message +from bubling.tools import result_content def default_tape_context() -> TapeContext: diff --git a/src/bub/core/errors.py b/packages/bubling/src/bubling/errors.py similarity index 100% rename from src/bub/core/errors.py rename to packages/bubling/src/bubling/errors.py diff --git a/src/bub/core/events.py b/packages/bubling/src/bubling/events.py similarity index 97% rename from src/bub/core/events.py rename to packages/bubling/src/bubling/events.py index 90cc2cf1..4e3c4a40 100644 --- a/src/bub/core/events.py +++ b/packages/bubling/src/bubling/events.py @@ -10,13 +10,13 @@ import republic -from bub.core.errors import BubError -from bub.core.prompt import UserContent -from bub.core.turn import TurnState +from bubling.errors import BubError +from bubling.prompt import UserContent +from bubling.turn import TurnState if TYPE_CHECKING: - from bub.core.tape import Tape - from bub.core.tools import ToolContext + from bubling.tape import Tape + from bubling.tools import ToolContext @dataclass(frozen=True) diff --git a/src/bub/core/model.py b/packages/bubling/src/bubling/model.py similarity index 100% rename from src/bub/core/model.py rename to packages/bubling/src/bubling/model.py diff --git a/src/bub/core/prompt.py b/packages/bubling/src/bubling/prompt.py similarity index 96% rename from src/bub/core/prompt.py rename to packages/bubling/src/bubling/prompt.py index 9b1a5378..8f8c571d 100644 --- a/src/bub/core/prompt.py +++ b/packages/bubling/src/bubling/prompt.py @@ -6,7 +6,7 @@ import republic -from bub.core.errors import BubError, ErrorKind +from bubling.errors import BubError, ErrorKind type UserContent = str | republic.Image | republic.Audio | republic.Video type LegacyPrompt = str | list[dict[str, Any]] @@ -17,7 +17,7 @@ def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: """Return user content, converting text and legacy content blocks by their value.""" - from bub.core.tape import to_message + from bubling.tape import to_message if isinstance(prompt, str): return [prompt] diff --git a/packages/bubling/src/bubling/py.typed b/packages/bubling/src/bubling/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/bub/core/recorder.py b/packages/bubling/src/bubling/recorder.py similarity index 94% rename from src/bub/core/recorder.py rename to packages/bubling/src/bubling/recorder.py index ddc14f21..bdef7daa 100644 --- a/src/bub/core/recorder.py +++ b/packages/bubling/src/bubling/recorder.py @@ -7,8 +7,8 @@ import republic -from bub.core.events import ChatCompleted, CommandFinished, EventBus, RunStarted, StepFinished, StepStarted -from bub.core.prompt import UserContent +from bubling.events import ChatCompleted, CommandFinished, EventBus, RunStarted, StepFinished, StepStarted +from bubling.prompt import UserContent class TapeRecorder: diff --git a/src/bub/core/sidecars.py b/packages/bubling/src/bubling/sidecars.py similarity index 100% rename from src/bub/core/sidecars.py rename to packages/bubling/src/bubling/sidecars.py diff --git a/src/bub/core/store.py b/packages/bubling/src/bubling/store.py similarity index 99% rename from src/bub/core/store.py rename to packages/bubling/src/bubling/store.py index cd90dd1d..a0757fc3 100644 --- a/src/bub/core/store.py +++ b/packages/bubling/src/bubling/store.py @@ -15,8 +15,8 @@ from loguru import logger from typing_extensions import TypeIs -from bub.core.errors import BubError, ErrorKind -from bub.core.tape import TapeEntry +from bubling.errors import BubError, ErrorKind +from bubling.tape import TapeEntry class TapeStore(Protocol): diff --git a/src/bub/core/streaming.py b/packages/bubling/src/bubling/streaming.py similarity index 97% rename from src/bub/core/streaming.py rename to packages/bubling/src/bubling/streaming.py index 4ccf06c6..81dda810 100644 --- a/src/bub/core/streaming.py +++ b/packages/bubling/src/bubling/streaming.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from typing import Any, Literal -from bub.core.errors import BubError -from bub.core.tracing import Span +from bubling.errors import BubError +from bubling.tracing import Span @dataclass diff --git a/src/bub/core/system_prompt.py b/packages/bubling/src/bubling/system_prompt.py similarity index 93% rename from src/bub/core/system_prompt.py rename to packages/bubling/src/bubling/system_prompt.py index 21b605b3..c71219f2 100644 --- a/src/bub/core/system_prompt.py +++ b/packages/bubling/src/bubling/system_prompt.py @@ -8,13 +8,13 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, TypeVar -from bub.core.prompt import UserContent -from bub.core.turn import TurnState +from bubling.prompt import UserContent +from bubling.turn import TurnState if TYPE_CHECKING: - from bub.core.agent import Agent - from bub.core.tape import Tape - from bub.core.tools import Tool + from bubling.agent import Agent + from bubling.tape import Tape + from bubling.tools import Tool @dataclass(frozen=True) diff --git a/src/bub/core/tape.py b/packages/bubling/src/bubling/tape.py similarity index 98% rename from src/bub/core/tape.py rename to packages/bubling/src/bubling/tape.py index 6d5c4a33..90981bb9 100644 --- a/src/bub/core/tape.py +++ b/packages/bubling/src/bubling/tape.py @@ -14,9 +14,9 @@ import republic -from bub.core import tracing -from bub.core.errors import BubError, ErrorKind -from bub.core.sidecars import TapeSidecar, sidecar_tape_name +from bubling import tracing +from bubling.errors import BubError, ErrorKind +from bubling.sidecars import TapeSidecar, sidecar_tape_name __all__ = [ "LAST_ANCHOR", @@ -44,7 +44,7 @@ ] if TYPE_CHECKING: - from bub.core.store import ( + from bubling.store import ( AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryQueryMixin, @@ -72,7 +72,7 @@ def __getattr__(name: str) -> Any: if name not in _STORE_EXPORTS: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - from bub.core import store + from bubling import store return getattr(store, name) @@ -195,7 +195,7 @@ def to_message( data = dict(payload) raw_content = data.pop("content", None) if role == "tool" and raw_content is not None and not isinstance(raw_content, str | list): - from bub.core.tools import render_result + from bubling.tools import render_result raw_content = render_result(raw_content) content = _content_parts(raw_content) @@ -311,7 +311,7 @@ def scoped(self, name: str, context: TapeContext | None = None) -> Tape: return replace(self, context=context or self.context, _name=name) def query(self) -> TapeQuery[AsyncTapeStore]: - from bub.core.store import TapeQuery + from bubling.store import TapeQuery return TapeQuery(tape=self.name, store=self.store) @@ -509,7 +509,7 @@ def session_tape(self, session_id: str, workspace: Path, context: TapeContext | @contextlib.asynccontextmanager async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[Tape, None]: - from bub.core.store import ForkTapeStore + from bubling.store import ForkTapeStore managed_sidecars = tuple(sidecar_tape_name(self.name, sidecar.name) for sidecar in self.sidecars) fork_store = ForkTapeStore(self.store, self.name, sidecars=managed_sidecars) diff --git a/src/bub/core/tools.py b/packages/bubling/src/bubling/tools.py similarity index 98% rename from src/bub/core/tools.py rename to packages/bubling/src/bubling/tools.py index 7441a993..d5b3656a 100644 --- a/src/bub/core/tools.py +++ b/packages/bubling/src/bubling/tools.py @@ -12,11 +12,11 @@ from loguru import logger from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call -from bub.core.errors import BubError, ErrorKind -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult -from bub.core.prompt import UserContent -from bub.core.tape import Tape -from bub.core.tracing import Span +from bubling.errors import BubError, ErrorKind +from bubling.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult +from bubling.prompt import UserContent +from bubling.tape import Tape +from bubling.tracing import Span @dataclass(frozen=True) diff --git a/src/bub/core/tracing.py b/packages/bubling/src/bubling/tracing.py similarity index 100% rename from src/bub/core/tracing.py rename to packages/bubling/src/bubling/tracing.py diff --git a/src/bub/core/turn.py b/packages/bubling/src/bubling/turn.py similarity index 100% rename from src/bub/core/turn.py rename to packages/bubling/src/bubling/turn.py diff --git a/packages/bubling/tests/conftest.py b/packages/bubling/tests/conftest.py new file mode 100644 index 00000000..78a52255 --- /dev/null +++ b/packages/bubling/tests/conftest.py @@ -0,0 +1,7 @@ +import pytest +from provider_fakes import ProviderService + + +@pytest.fixture +def provider_service() -> ProviderService: + return ProviderService() diff --git a/packages/bubling/tests/provider_fakes.py b/packages/bubling/tests/provider_fakes.py new file mode 100644 index 00000000..ec764629 --- /dev/null +++ b/packages/bubling/tests/provider_fakes.py @@ -0,0 +1,52 @@ +"""An HTTP provider that records requests and replays queued chat completion streams.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx2 + + +def sse(events: list[Any]) -> httpx2.Response: + return httpx2.Response( + 200, + headers={"content-type": "text/event-stream"}, + text="".join(f"data: {event if isinstance(event, str) else json.dumps(event)}\n\n" for event in events), + ) + + +def chat_events(text: str = "done") -> list[Any]: + return [ + {"choices": [{"delta": {"content": text}, "finish_reason": "stop"}]}, + {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}}, + "[DONE]", + ] + + +def tool_events(calls: list[dict[str, Any]], text: str = "") -> list[Any]: + return [ + {"choices": [{"delta": {"content": text, "tool_calls": calls}, "finish_reason": "tool_calls"}]}, + "[DONE]", + ] + + +class ProviderService: + """Record HTTP requests and return queued provider responses.""" + + def __init__(self) -> None: + self.requests: list[httpx2.Request] = [] + self.responses: list[httpx2.Response] = [] + + def reply(self, response: httpx2.Response) -> None: + self.responses.append(response) + + def body(self, index: int = -1) -> Any: + return json.loads(self.requests[index].content) + + def client(self) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(self._respond)) + + def _respond(self, request: httpx2.Request) -> httpx2.Response: + self.requests.append(request) + return self.responses.pop(0) diff --git a/tests/test_core_agent.py b/packages/bubling/tests/test_agent.py similarity index 94% rename from tests/test_core_agent.py rename to packages/bubling/tests/test_agent.py index bd4452fd..2b34a15b 100644 --- a/tests/test_core_agent.py +++ b/packages/bubling/tests/test_agent.py @@ -1,15 +1,13 @@ from __future__ import annotations -import ast from dataclasses import replace -from pathlib import Path from typing import Any import pytest import republic +from provider_fakes import ProviderService, chat_events, sse, tool_events -import bub.core -from bub.core import ( +from bubling import ( AfterLlmCall, AfterToolCall, Agent, @@ -26,9 +24,8 @@ Tool, ToolCallDecision, ) -from bub.core.errors import BubError -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from tests.model_fakes import ProviderService, chat_events, sse, tool_events +from bubling.errors import BubError +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore def _model(client: Any) -> republic.ChatModel: @@ -196,19 +193,6 @@ def reroute(event: BeforeLlmCall) -> None: assert provider_service.body()["model"] == "other" -def test_core_does_not_depend_on_settings_or_plugins() -> None: - forbidden = ("bub.builtin", "bub.configure", "bub.framework", "bub.hooks", "bub.channels", "pluggy") - for path in Path(bub.core.__file__).parent.glob("*.py"): - for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): - if isinstance(node, ast.ImportFrom): - names = [node.module or ""] - elif isinstance(node, ast.Import): - names = [alias.name for alias in node.names] - else: - continue - assert not [name for name in names if name.startswith(forbidden)], f"{path.name} imports {names}" - - def test_tapes_share_the_agent_store() -> None: store = InMemoryTapeStore() agent = Agent(store=store) diff --git a/tests/test_errors.py b/packages/bubling/tests/test_errors.py similarity index 92% rename from tests/test_errors.py rename to packages/bubling/tests/test_errors.py index 78030580..9f63f90c 100644 --- a/tests/test_errors.py +++ b/packages/bubling/tests/test_errors.py @@ -3,7 +3,7 @@ import pytest -from bub.core.errors import BubError, ErrorKind +from bubling.errors import BubError, ErrorKind def test_error_survives_generator_context_manager_with_identity_and_details() -> None: diff --git a/tests/test_file_tape_store_entry_ids.py b/packages/bubling/tests/test_file_tape_store_entry_ids.py similarity index 86% rename from tests/test_file_tape_store_entry_ids.py rename to packages/bubling/tests/test_file_tape_store_entry_ids.py index 1f046fe9..0fb6e463 100644 --- a/tests/test_file_tape_store_entry_ids.py +++ b/packages/bubling/tests/test_file_tape_store_entry_ids.py @@ -2,9 +2,9 @@ import pytest -from bub.core.sidecars import sidecar_tape_name -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore -from bub.core.tape import TapeEntry +from bubling.sidecars import sidecar_tape_name +from bubling.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore +from bubling.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_fork_store_merge_back.py b/packages/bubling/tests/test_fork_store_merge_back.py similarity index 96% rename from tests/test_fork_store_merge_back.py rename to packages/bubling/tests/test_fork_store_merge_back.py index 8e756c70..67733ff1 100644 --- a/tests/test_fork_store_merge_back.py +++ b/packages/bubling/tests/test_fork_store_merge_back.py @@ -2,8 +2,8 @@ import pytest -from bub.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery -from bub.core.tape import TapeEntry +from bubling.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery +from bubling.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_tools.py b/packages/bubling/tests/test_tools.py similarity index 96% rename from tests/test_tools.py rename to packages/bubling/tests/test_tools.py index dd0e2fb9..7e89ca72 100644 --- a/tests/test_tools.py +++ b/packages/bubling/tests/test_tools.py @@ -7,11 +7,11 @@ from loguru import logger from pydantic import BaseModel -from bub.core.errors import BubError -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool +from bubling.errors import BubError +from bubling.events import AfterToolCall, BeforeToolCall, EventBus +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.tape import Tape, TapeContext +from bubling.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool class EchoInput(BaseModel): @@ -129,7 +129,7 @@ def info(self, message: str, *args: Any) -> None: def error(self, message: str, *args: Any) -> None: errors.append((message.format(*args), self.exception)) - monkeypatch.setattr("bub.core.tools.logger", Recorder()) + monkeypatch.setattr("bubling.tools.logger", Recorder()) def failing_tool() -> str: raise RuntimeError("boom") diff --git a/pyproject.toml b/pyproject.toml index 8b92770a..4ec8baf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ ] dependencies = [ + "bubling>=0.6.0a1", "pydantic>=2.12.5", "pydantic-settings>=2.14.2", "pyyaml>=6.0.3", @@ -48,9 +49,7 @@ bub = "bub.__main__:app" [project.optional-dependencies] trace = [ - "opentelemetry-api>=1.39.0", - "opentelemetry-sdk>=1.39.0", - "opentelemetry-exporter-otlp-proto-http>=1.39.0", + "bubling[trace]", ] logfire = [ "bub[trace]", @@ -68,6 +67,12 @@ dev = [ "pytest-asyncio>=1.3.0", ] +[tool.uv.workspace] +members = ["packages/*"] + +[tool.uv.sources] +bubling = { workspace = true } + [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" @@ -88,10 +93,10 @@ only-include = ["src/bub", "src/skills"] [tool.vulture] ignore_names = ["test_*", "Test*"] -paths = ["src"] +paths = ["src", "packages/bubling/src"] [tool.mypy] -files = ["src"] +files = ["src", "packages/bubling/src"] disallow_untyped_defs = false disallow_any_unimported = false no_implicit_optional = true @@ -105,22 +110,22 @@ exclude = [ ] [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests", "packages/bubling/tests"] [tool.importlinter] -root_packages = ["bub"] +root_packages = ["bub", "bubling"] include_external_packages = true [[tool.importlinter.contracts]] -name = "bub.core is independent of the rest of bub" +name = "bubling is independent of bub" type = "forbidden" -source_modules = ["bub.core"] -forbidden_modules = ["bub.*"] +source_modules = ["bubling"] +forbidden_modules = ["bub"] [[tool.importlinter.contracts]] -name = "bub.core does not use application dependencies" +name = "bubling does not use application dependencies" type = "forbidden" -source_modules = ["bub.core"] +source_modules = ["bubling"] forbidden_modules = [ "aiohttp", "dotenv", @@ -137,6 +142,7 @@ forbidden_modules = [ [tool.ruff] target-version = "py312" +src = ["src", "packages/bubling/src"] line-length = 120 fix = true extend-exclude = [ @@ -189,6 +195,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101"] +"packages/*/tests/*" = ["S101"] [tool.ruff.format] preview = true diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 429b1842..15a084df 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,7 +1,7 @@ """Bub framework package. -The top-level names are imported on first access, so ``import bub.core`` does not load -the framework, its settings or the CLI. +The top-level names are imported on first access, so importing a submodule such as +``bub.envelope`` does not load the framework, its settings or the CLI. """ from __future__ import annotations @@ -15,9 +15,9 @@ if TYPE_CHECKING: from bub.configure import Settings, config, ensure_config - from bub.core.tools import tool from bub.framework import BubFramework from bub.hooks import hookimpl + from bubling.tools import tool home: Path @@ -50,7 +50,7 @@ def __getattr__(name: str) -> Any: if name == "tool": - from bub.core.tools import tool + from bubling.tools import tool return tool if name != "home" and name not in _EXPORTS: diff --git a/src/bub/__main__.py b/src/bub/__main__.py index 4b275a05..a86484b2 100644 --- a/src/bub/__main__.py +++ b/src/bub/__main__.py @@ -24,7 +24,7 @@ def _instrument_bub(level: str) -> None: logger.add(sys.stderr, level=level, colorize=True, diagnose=False) if os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): - from bub.core.tracing import configure_otlp + from bubling.tracing import configure_otlp try: configure_otlp() diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index c6a4006e..f4731f74 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -7,18 +7,18 @@ from collections.abc import Collection from pathlib import Path -import bub.core as core +import bubling as core from bub.builtin.settings import load_settings -from bub.core.prompt import LegacyPrompt, UserContent, prompt_text, to_content -from bub.core.store import AsyncTapeStore, TapeStore -from bub.core.streaming import AsyncStreamEvents, StreamState -from bub.core.tape import Tape, session_tape_name -from bub.core.tools import REGISTRY, Tool, model_tools from bub.envelope import field_of from bub.framework import BubFramework from bub.skills import discover_skills, render_skills_prompt from bub.turn import TurnState from bub.utils import workspace_from_state +from bubling.prompt import LegacyPrompt, UserContent, prompt_text, to_content +from bubling.store import AsyncTapeStore, TapeStore +from bubling.streaming import AsyncStreamEvents, StreamState +from bubling.tape import Tape, session_tape_name +from bubling.tools import REGISTRY, Tool, model_tools HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") ALLOWED_SKILLS_STATE_KEY = "allowed_skills" @@ -28,7 +28,7 @@ class BubAgent(core.Agent): """Bub's agent: the core loop configured by settings and the framework's hooks. Adds commands, skills, code mode, deferred tools, steering and the - ``continue_prompt`` hook to :class:`bub.core.Agent`. System prompt sections, in + ``continue_prompt`` hook to :class:`bubling.Agent`. System prompt sections, in order: the framework's ``system_prompt`` hooks, code mode, deferred tools, skills, then sections added with ``system_prompt.add``. The framework's interception hooks handle events before handlers added with ``events.on``. diff --git a/src/bub/builtin/archive.py b/src/bub/builtin/archive.py index 9e190514..004a7d8b 100644 --- a/src/bub/builtin/archive.py +++ b/src/bub/builtin/archive.py @@ -8,9 +8,9 @@ from pathlib import Path from typing import Any -from bub.core.sidecars import sidecar_tape_name -from bub.core.store import TapeQuery -from bub.core.tape import Tape +from bubling.sidecars import sidecar_tape_name +from bubling.store import TapeQuery +from bubling.tape import Tape def archive_dir() -> Path: diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py index 02060a2f..a1211ef6 100644 --- a/src/bub/builtin/codemode/__init__.py +++ b/src/bub/builtin/codemode/__init__.py @@ -19,10 +19,10 @@ from bub.builtin.codemode.republic_bridge import REPUBLIC_CALL_PREFIX, RepublicSession, decode_value, encode_value from bub.builtin.environment import environment_from_state from bub.builtin.settings import set_session_setting -from bub.core.errors import BubError, ErrorKind -from bub.core.prompt import UserContent -from bub.core.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool from bub.environment import CallTool, CodeFailed +from bubling.errors import BubError, ErrorKind +from bubling.prompt import UserContent +from bubling.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool RUN_CODE_TOOL_NAME = "run_code" CODE_MODE_STATE_KEY = "code_mode" diff --git a/src/bub/builtin/codemode/code_runner.py b/src/bub/builtin/codemode/code_runner.py index 52eb0226..43d92238 100644 --- a/src/bub/builtin/codemode/code_runner.py +++ b/src/bub/builtin/codemode/code_runner.py @@ -18,8 +18,8 @@ from pydantic_core import to_jsonable_python from bub.builtin.codemode import code_runner_child -from bub.core.errors import BubError, ErrorKind from bub.environment import CodeFailed +from bubling.errors import BubError, ErrorKind if TYPE_CHECKING: from bub.environment import CallTool, Environment, Process diff --git a/src/bub/builtin/codemode/republic_bridge.py b/src/bub/builtin/codemode/republic_bridge.py index 8eb1406d..fbfd7aa9 100644 --- a/src/bub/builtin/codemode/republic_bridge.py +++ b/src/bub/builtin/codemode/republic_bridge.py @@ -29,8 +29,8 @@ UsageDelta, ) -from bub.core.errors import BubError, ErrorKind -from bub.core.tools import ToolContext +from bubling.errors import BubError, ErrorKind +from bubling.tools import ToolContext REPUBLIC_CALL_PREFIX = "republic." REPUBLIC_KEY = "$republic" diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 1885f59b..3ace0474 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -18,22 +18,22 @@ from bub.channels.base import Channel from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage, MediaItem -from bub.core.commands import strip_command_prefix -from bub.core.context import default_tape_context -from bub.core.errors import BubError -from bub.core.events import ToolCall, ToolCallDecision, ToolCallResult -from bub.core.prompt import UserContent -from bub.core.sidecars import TapeSidecar -from bub.core.store import TapeStore -from bub.core.streaming import AsyncStreamEvents -from bub.core.tape import TapeContext, session_tape_name -from bub.core.tools import is_content_result, render_result from bub.envelope import Envelope, content_of, field_of from bub.environment import Environment from bub.framework import BubFramework from bub.hooks import hookimpl from bub.model_selection import ModelChoice, ModelOptions from bub.turn import TurnState +from bubling.commands import strip_command_prefix +from bubling.context import default_tape_context +from bubling.errors import BubError +from bubling.events import ToolCall, ToolCallDecision, ToolCallResult +from bubling.prompt import UserContent +from bubling.sidecars import TapeSidecar +from bubling.store import TapeStore +from bubling.streaming import AsyncStreamEvents +from bubling.tape import TapeContext, session_tape_name +from bubling.tools import is_content_result, render_result AGENTS_FILE_NAME = "AGENTS.md" DEFAULT_SYSTEM_PROMPT = """\ @@ -340,7 +340,7 @@ async def before_tool_call( """ from bub.builtin.codemode import CODE_TOOLS_STATE_KEY from bub.builtin.tools import TOOL_DESCRIBE_TOOL_NAME - from bub.core.tools import model_tools + from bubling.tools import model_tools if call.context is not None and call.context.command: return None diff --git a/src/bub/builtin/model.py b/src/bub/builtin/model.py index 2d1452cc..3ed1efb0 100644 --- a/src/bub/builtin/model.py +++ b/src/bub/builtin/model.py @@ -11,7 +11,7 @@ import republic from loguru import logger -from bub.core.tracing import current_span, event +from bubling.tracing import current_span, event @dataclass(frozen=True) diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index ef11fb22..4d8dfa45 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -13,11 +13,11 @@ from bub import Settings, __version__, config, ensure_config from bub.builtin.model import BubModel, ModelCandidate -from bub.core.commands import validate_command_prefix +from bubling.commands import validate_command_prefix if TYPE_CHECKING: - from bub.core.tape import Tape - from bub.core.tools import ToolContext + from bubling.tape import Tape + from bubling.tools import ToolContext DEFAULT_MODEL = "openrouter:openrouter/free" DEFAULT_MAX_TOKENS = 16384 diff --git a/src/bub/builtin/spill.py b/src/bub/builtin/spill.py index 46c614cb..54cf8ee3 100644 --- a/src/bub/builtin/spill.py +++ b/src/bub/builtin/spill.py @@ -12,9 +12,9 @@ from bub import config from bub.configure import Settings -from bub.core.errors import BubError, ErrorKind -from bub.core.tape import Tape, TapeEntry, TapeQuery -from bub.core.tools import ToolContext, tool +from bubling.errors import BubError, ErrorKind +from bubling.tape import Tape, TapeEntry, TapeQuery +from bubling.tools import ToolContext, tool SPILL_READ_TOOL_NAME = "spill.read" SPILL_READ_MODEL_NAME = SPILL_READ_TOOL_NAME.replace(".", "_") diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py index 2da18e2c..db080d3d 100644 --- a/src/bub/builtin/store.py +++ b/src/bub/builtin/store.py @@ -6,9 +6,9 @@ from collections.abc import Iterable from dataclasses import replace -from bub.core.store import FileTapeStore, TapeQuery -from bub.core.tape import TapeEntry from bub.utils import get_entry_text +from bubling.store import FileTapeStore, TapeQuery +from bubling.tape import TapeEntry WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") MIN_FUZZY_QUERY_LENGTH = 3 @@ -17,7 +17,7 @@ class FuzzyFileTapeStore(FileTapeStore): - """:class:`~bub.core.store.FileTapeStore` whose text queries also match approximately.""" + """:class:`~bubling.store.FileTapeStore` whose text queries also match approximately.""" def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: if not query._query: diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 770d9d1d..040dc7cf 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -14,12 +14,12 @@ from bub.builtin.environment import environment_from_state from bub.builtin.settings import load_settings, set_session_setting from bub.builtin.shell_manager import shell_manager -from bub.core.tools import REGISTRY, Tool, ToolContext, tool from bub.skills import discover_skills +from bubling.tools import REGISTRY, Tool, ToolContext, tool if TYPE_CHECKING: from bub.builtin.agent import BubAgent - from bub.core.tape import Tape + from bubling.tape import Tape DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 DEFAULT_HEADERS = {"accept": "text/markdown"} diff --git a/src/bub/channels/base.py b/src/bub/channels/base.py index 36587f9a..5573f2ef 100644 --- a/src/bub/channels/base.py +++ b/src/bub/channels/base.py @@ -5,8 +5,8 @@ from bub.channels.admission import AdmitDecision, TurnSnapshot from bub.channels.message import ChannelMessage -from bub.core.streaming import StreamEvent from bub.envelope import Envelope +from bubling.streaming import StreamEvent class Channel(ABC): diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 9d73b08b..e1472f22 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -38,12 +38,12 @@ from bub.channels.cli.writers import MarkdownWriter from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage -from bub.core.commands import strip_command_prefix -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus -from bub.core.streaming import StreamEvent -from bub.core.tape import TapeInfo, session_tape_name -from bub.core.tools import REGISTRY from bub.envelope import Envelope, field_of +from bubling.commands import strip_command_prefix +from bubling.events import AfterToolCall, BeforeToolCall, EventBus +from bubling.streaming import StreamEvent +from bubling.tape import TapeInfo, session_tape_name +from bubling.tools import REGISTRY _GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment] _PROMPT_REFRESH_INTERVAL: float = SPINNERS["dots"]["interval"] / 1000.0 # type: ignore[operator] diff --git a/src/bub/channels/contracts.py b/src/bub/channels/contracts.py index 05e00bbc..6c06ba72 100644 --- a/src/bub/channels/contracts.py +++ b/src/bub/channels/contracts.py @@ -5,8 +5,8 @@ from collections.abc import AsyncIterable, Callable, Coroutine from typing import TYPE_CHECKING, Any, Protocol -from bub.core.streaming import StreamEvent from bub.envelope import Envelope +from bubling.streaming import StreamEvent if TYPE_CHECKING: from bub.channels.admission import AdmitDecision, TurnSnapshot diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index cf032b1d..3c93f80c 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -15,11 +15,11 @@ from bub.channels.handler import BufferedMessageHandler from bub.channels.message import ChannelMessage from bub.configure import Settings, ensure_config -from bub.core.streaming import StreamEvent from bub.envelope import Envelope, content_of, field_of from bub.framework import BubFramework from bub.turn import TurnState from bub.utils import wait_until_stopped +from bubling.streaming import StreamEvent @config() diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index c455de4a..6fd5a53e 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -19,8 +19,8 @@ from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage, MediaItem, MediaType from bub.configure import Settings, ensure_config -from bub.core.commands import strip_command_prefix, validate_command_prefix from bub.utils import exclude_none +from bubling.commands import strip_command_prefix, validate_command_prefix @config(name="telegram") diff --git a/src/bub/errors.py b/src/bub/errors.py index c5e7e8f2..e0631ba5 100644 --- a/src/bub/errors.py +++ b/src/bub/errors.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.errors`.""" +"""Compatibility alias for :mod:`bubling.errors`.""" import sys -from bub.core import errors as _module -from bub.core.errors import * # noqa: F403 +from bubling import errors as _module +from bubling.errors import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/framework.py b/src/bub/framework.py index 3f8fd3f8..66bb8dc2 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -17,12 +17,6 @@ from bub import configure from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import ChannelRouter, MessageHandler -from bub.core.errors import BubError, ErrorKind -from bub.core.prompt import UserContent, to_content, to_legacy_prompt -from bub.core.sidecars import TapeSidecar -from bub.core.store import AsyncTapeStore, TapeStore -from bub.core.streaming import StreamState -from bub.core.tape import Tape, TapeContext from bub.envelope import Envelope, content_of, field_of, unpack_batch from bub.environment import ENVIRONMENT_STATE_KEY, Environment from bub.hooks.interception import PluginAgentHooks @@ -31,6 +25,12 @@ from bub.model_selection import ModelOptions from bub.turn import TurnResult, TurnState from bub.utils import maybe_context_manager +from bubling.errors import BubError, ErrorKind +from bubling.prompt import UserContent, to_content, to_legacy_prompt +from bubling.sidecars import TapeSidecar +from bubling.store import AsyncTapeStore, TapeStore +from bubling.streaming import StreamState +from bubling.tape import Tape, TapeContext if TYPE_CHECKING: from bub.channels.base import Channel diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index a16c9c74..47ef9da4 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -7,7 +7,9 @@ from loguru import logger -from bub.core.events import ( +from bub.hooks.runtime import _SKIP_VALUE, HookRuntime +from bub.turn import TurnState +from bubling.events import ( AfterLlmCall, AfterToolCall, BeforeLlmCall, @@ -20,8 +22,6 @@ ToolCallDecision, ToolCallResult, ) -from bub.hooks.runtime import _SKIP_VALUE, HookRuntime -from bub.turn import TurnState __all__ = [ "LlmCallDecision", diff --git a/src/bub/hooks/runtime.py b/src/bub/hooks/runtime.py index 8c3ce927..237ccab4 100644 --- a/src/bub/hooks/runtime.py +++ b/src/bub/hooks/runtime.py @@ -10,10 +10,10 @@ import pluggy from loguru import logger -from bub.core.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.envelope import Envelope from bub.turn import TurnState +from bubling.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt +from bubling.streaming import AsyncStreamEvents, StreamEvent, StreamState class HookRuntime: diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index dbdf2126..07baacd6 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -10,7 +10,11 @@ from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import MessageHandler -from bub.core.events import ( +from bub.envelope import Envelope +from bub.environment import Environment +from bub.model_selection import ModelOptions +from bub.turn import TurnState +from bubling.events import ( LlmCallDecision, LlmCallRequest, LlmCallResult, @@ -18,15 +22,11 @@ ToolCallDecision, ToolCallResult, ) -from bub.core.prompt import LegacyPrompt, UserContent -from bub.core.sidecars import TapeSidecar -from bub.core.store import AsyncTapeStore, TapeStore -from bub.core.streaming import AsyncStreamEvents, StreamState -from bub.core.tape import Tape, TapeContext -from bub.envelope import Envelope -from bub.environment import Environment -from bub.model_selection import ModelOptions -from bub.turn import TurnState +from bubling.prompt import LegacyPrompt, UserContent +from bubling.sidecars import TapeSidecar +from bubling.store import AsyncTapeStore, TapeStore +from bubling.streaming import AsyncStreamEvents, StreamState +from bubling.tape import Tape, TapeContext if TYPE_CHECKING: from bub.channels.base import Channel @@ -43,7 +43,7 @@ class BubHookSpecs: ``republic.Audio`` and ``republic.Video``; legacy text and content-block results are still converted by inspecting the returned value. Hooks that receive a prompt get the legacy form: text without media, otherwise content - blocks with media URLs (see ``bub.core.prompt.to_legacy_prompt``). + blocks with media URLs (see ``bubling.prompt.to_legacy_prompt``). """ @hookspec(firstresult=True) diff --git a/src/bub/prompt.py b/src/bub/prompt.py deleted file mode 100644 index 68680793..00000000 --- a/src/bub/prompt.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.prompt`.""" - -import sys - -from bub.core import prompt as _module -from bub.core.prompt import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/src/bub/sidecars.py b/src/bub/sidecars.py index 88e59846..927fbebd 100644 --- a/src/bub/sidecars.py +++ b/src/bub/sidecars.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.sidecars`.""" +"""Compatibility alias for :mod:`bubling.sidecars`.""" import sys -from bub.core import sidecars as _module -from bub.core.sidecars import * # noqa: F403 +from bubling import sidecars as _module +from bubling.sidecars import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/store.py b/src/bub/store.py index 3fd8aeca..e10b28d6 100644 --- a/src/bub/store.py +++ b/src/bub/store.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.store`.""" +"""Compatibility alias for :mod:`bubling.store`.""" import sys -from bub.core import store as _module -from bub.core.store import * # noqa: F403 +from bubling import store as _module +from bubling.store import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/streaming.py b/src/bub/streaming.py index f3aff141..fbe4e512 100644 --- a/src/bub/streaming.py +++ b/src/bub/streaming.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.streaming`.""" +"""Compatibility alias for :mod:`bubling.streaming`.""" import sys -from bub.core import streaming as _module -from bub.core.streaming import * # noqa: F403 +from bubling import streaming as _module +from bubling.streaming import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/tape.py b/src/bub/tape.py index 965d1d18..9d7d7007 100644 --- a/src/bub/tape.py +++ b/src/bub/tape.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.tape`.""" +"""Compatibility alias for :mod:`bubling.tape`.""" import sys -from bub.core import tape as _module -from bub.core.tape import * # noqa: F403 +from bubling import tape as _module +from bubling.tape import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/tools.py b/src/bub/tools.py index 76d4ee66..e173a485 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.tools`.""" +"""Compatibility alias for :mod:`bubling.tools`.""" import sys -from bub.core import tools as _module -from bub.core.tools import * # noqa: F403 +from bubling import tools as _module +from bubling.tools import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/tracing.py b/src/bub/tracing.py index a713df6f..d81b076c 100644 --- a/src/bub/tracing.py +++ b/src/bub/tracing.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.tracing`.""" +"""Compatibility alias for :mod:`bubling.tracing`.""" import sys -from bub.core import tracing as _module -from bub.core.tracing import * # noqa: F403 +from bubling import tracing as _module +from bubling.tracing import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/turn.py b/src/bub/turn.py index c759553b..b6895ce9 100644 --- a/src/bub/turn.py +++ b/src/bub/turn.py @@ -5,11 +5,11 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING -from bub.core.turn import TurnState from bub.envelope import Envelope +from bubling.turn import TurnState if TYPE_CHECKING: - from bub.core.prompt import UserContent + from bubling.prompt import UserContent __all__ = ["TurnResult", "TurnState"] diff --git a/src/bub/utils.py b/src/bub/utils.py index 73882cd3..20a789a7 100644 --- a/src/bub/utils.py +++ b/src/bub/utils.py @@ -4,8 +4,8 @@ from pathlib import Path from typing import Any -from bub.core.tape import TapeEntry from bub.turn import TurnState +from bubling.tape import TapeEntry def exclude_none(d: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/model_fakes.py b/tests/model_fakes.py index df915e28..938df0a3 100644 --- a/tests/model_fakes.py +++ b/tests/model_fakes.py @@ -9,12 +9,12 @@ import httpx2 from bub.builtin.settings import AgentSettings -from bub.core import Agent as CoreAgent -from bub.core.prompt import UserContent -from bub.core.streaming import AsyncStreamEvents -from bub.core.tape import Tape -from bub.core.tools import Tool from bub.hooks.interception import PluginAgentHooks +from bubling import Agent as CoreAgent +from bubling.prompt import UserContent +from bubling.streaming import AsyncStreamEvents +from bubling.tape import Tape +from bubling.tools import Tool class StepRunner: diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 5b235bdb..a4e2ffe7 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -12,11 +12,6 @@ from republic.errors import StreamIncompleteError from bub.builtin.settings import AgentSettings -from bub.core.errors import BubError -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import Tool, ToolExecutor from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import ( LlmCallDecision, @@ -28,6 +23,11 @@ ToolCallResult, ) from bub.hooks.runtime import HookRuntime +from bubling.errors import BubError +from bubling.prompt import to_content +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.tape import Tape, TapeContext +from bubling.tools import Tool, ToolExecutor from tests.model_fakes import ProviderService, StepRunner, sse diff --git a/tests/test_core_boundary.py b/tests/test_bubling_compat.py similarity index 57% rename from tests/test_core_boundary.py rename to tests/test_bubling_compat.py index da6c01a6..888677e3 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_bubling_compat.py @@ -4,37 +4,36 @@ import pytest from bub.builtin.store import FuzzyFileTapeStore -from bub.core.store import FileTapeStore, TapeQuery -from bub.core.tape import TapeEntry +from bubling.store import FileTapeStore, TapeQuery +from bubling.tape import TapeEntry APP_MODULES = ("typer", "pluggy", "pydantic_settings", "dotenv", "yaml", "rapidfuzz", "telegram", "rich") -def test_importing_core_loads_no_framework_or_app_dependencies() -> None: +def test_importing_bubling_loads_no_bub_or_app_dependencies() -> None: script = ( - "import sys, bub.core\n" + "import sys, bubling\n" "loaded = {name.split('.')[0] for name in sys.modules}\n" - f"print(sorted(loaded & set({APP_MODULES!r})))\n" - "print(sorted(n for n in sys.modules if n.startswith('bub.') and not n.startswith('bub.core')))\n" + f"print(sorted(loaded & set({APP_MODULES!r} + ('bub',))))\n" ) result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True) - assert result.stdout.splitlines() == ["[]", "['bub._version']"] + assert result.stdout.splitlines() == ["[]"] -@pytest.mark.parametrize("name", ["errors", "prompt", "tracing", "streaming", "store", "tape", "tools", "sidecars"]) -def test_legacy_top_level_modules_alias_core_modules(name: str) -> None: +@pytest.mark.parametrize("name", ["errors", "tracing", "streaming", "store", "tape", "tools", "sidecars"]) +def test_legacy_top_level_modules_alias_bubling_modules(name: str) -> None: __import__(f"bub.{name}") - __import__(f"bub.core.{name}") + __import__(f"bubling.{name}") - assert sys.modules[f"bub.{name}"] is sys.modules[f"bub.core.{name}"] + assert sys.modules[f"bub.{name}"] is sys.modules[f"bubling.{name}"] -def test_turn_state_is_shared_with_core() -> None: - from bub.core.turn import TurnState as CoreTurnState +def test_turn_state_is_shared_with_bubling() -> None: from bub.turn import TurnState + from bubling.turn import TurnState as BublingTurnState - assert TurnState is CoreTurnState + assert TurnState is BublingTurnState def _search(store: FileTapeStore, text: str) -> list[str]: @@ -46,7 +45,7 @@ def _write_messages(store: FileTapeStore) -> None: store.append("tape", TapeEntry.message({"role": "user", "content": content})) -def test_core_file_store_matches_text_in_order(tmp_path) -> None: +def test_bubling_file_store_matches_text_in_order(tmp_path) -> None: store = FileTapeStore(tmp_path) _write_messages(store) diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index cf3f11ae..2aee1063 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -14,13 +14,13 @@ from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox -from bub.core import PromptContext, StepFinished -from bub.core.context import default_tape_context -from bub.core.prompt import UserContent -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.core.tape import Tape, session_tape_name -from bub.core.tools import REGISTRY, tool +from bubling import PromptContext, StepFinished +from bubling.context import default_tape_context +from bubling.prompt import UserContent +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bubling.tape import Tape, session_tape_name +from bubling.tools import REGISTRY, tool from tests.model_fakes import ProviderService # --------------------------------------------------------------------------- diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index 878ce94c..b71d803b 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -409,7 +409,7 @@ def test_run_help_uses_the_configured_prefix( framework.workspace = tmp_path framework.load_builtin_hooks() monkeypatch.setattr( - "bub.core.agent.Agent.run_once", + "bubling.agent.Agent.run_once", lambda *args, **kwargs: pytest.fail("help must not invoke the model"), ) diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 62ddefcc..749f44d9 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -11,12 +11,12 @@ from bub.builtin.auth import app from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem -from bub.core.context import default_tape_context -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext, TapeEntry -from bub.core.tools import Tool from bub.framework import BubFramework +from bubling.context import default_tape_context +from bubling.prompt import to_content +from bubling.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bubling.tape import Tape, TapeContext, TapeEntry +from bubling.tools import Tool from tests.model_fakes import ProviderService, StepRunner, sse diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index 5736c76e..320010b3 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -10,11 +10,11 @@ from bub.builtin.hook_impl import AGENTS_FILE_NAME, DEFAULT_SYSTEM_PROMPT, BuiltinImpl from bub.builtin.store import FuzzyFileTapeStore from bub.channels.message import ChannelMessage -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.streaming import AsyncStreamEvents, StreamEvent -from bub.core.tape import Tape, TapeContext, session_tape_name -from bub.core.tools import REGISTRY from bub.framework import BubFramework +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.streaming import AsyncStreamEvents, StreamEvent +from bubling.tape import Tape, TapeContext, session_tape_name +from bubling.tools import REGISTRY class RecordingLifespan: diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 75a2b5a9..3b16bcbe 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -26,10 +26,10 @@ set_reasoning_effort, tape_info, ) -from bub.core.errors import ErrorKind -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool +from bubling.errors import ErrorKind +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.tape import Tape, TapeContext +from bubling.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool def _tool_context(tmp_path, **state) -> ToolContext: diff --git a/tests/test_channels.py b/tests/test_channels.py index 4e912d4b..f8ec73e2 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -26,8 +26,8 @@ from bub.channels.manager import ChannelManager from bub.channels.message import ChannelMessage from bub.channels.telegram import BubMessageFilter, TelegramChannel, TelegramMessageParser -from bub.core.streaming import StreamEvent from bub.turn import TurnResult +from bubling.streaming import StreamEvent ANSI_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[()][A-Za-z])") @@ -1333,8 +1333,8 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp @pytest.mark.asyncio async def test_cli_tool_renderer_finishes_before_next_model_output() -> None: from bub.channels.cli import _CliToolCallRenderer - from bub.core.events import EventBus - from bub.core.tools import Tool, ToolExecutor + from bubling.events import EventBus + from bubling.tools import Tool, ToolExecutor events: list[str] = [] @@ -1366,8 +1366,8 @@ def ordered_tool() -> str: @pytest.mark.asyncio async def test_cli_tool_renderer_shows_the_error_the_tool_raised() -> None: from bub.channels.cli import _CliToolCallRenderer - from bub.core.events import EventBus - from bub.core.tools import Tool, ToolExecutor + from bubling.events import EventBus + from bubling.tools import Tool, ToolExecutor errors: list[BaseException] = [] @@ -1507,7 +1507,7 @@ def test_cli_stream_output_does_not_overlap_active_pty_prompt() -> None: import bub.channels.cli as cli_module from bub.channels.cli import CliChannel, _StreamPrinter from bub.channels.cli.terminal_output import TerminalPresenter, create_synchronized_output - from bub.core.streaming import StreamEvent + from bubling.streaming import StreamEvent async def main(): diff --git a/tests/test_codemode.py b/tests/test_codemode.py index 118f5727..8f31ff0b 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -23,13 +23,13 @@ write_tool_stub, ) from bub.builtin.environment import LocalEnvironment, LocalProcess -from bub.core.errors import BubError, ErrorKind -from bub.core.events import BeforeToolCall, EventBus -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, CodeFailed from bub.hooks.interception import ToolCallDecision +from bubling.errors import BubError, ErrorKind +from bubling.events import BeforeToolCall, EventBus +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.tape import Tape, TapeContext +from bubling.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool class Order(TypedDict): @@ -469,7 +469,7 @@ async def test_run_code_attaches_media_to_its_result(tmp_path: Path) -> None: import republic from bub.builtin.codemode import attach_audio, attach_image, attach_video - from bub.core.tools import result_content + from bubling.tools import result_content (tmp_path / "charts").mkdir() (tmp_path / "charts" / "chart.png").write_bytes(PNG_BYTES) @@ -526,9 +526,9 @@ async def test_media_tools_only_work_inside_run_code(tmp_path: Path) -> None: def test_media_tool_results_become_multimodal_tool_messages(tmp_path: Path) -> None: import republic - from bub.core.context import default_tape_context - from bub.core.tape import TapeEntry - from bub.core.tools import content_result + from bubling.context import default_tape_context + from bubling.tape import TapeEntry + from bubling.tools import content_result image = republic.Image("image/png", data=PNG_BYTES) entries = [ diff --git a/tests/test_core_agent_step.py b/tests/test_core_agent_step.py index d76fb25a..b58770ea 100644 --- a/tests/test_core_agent_step.py +++ b/tests/test_core_agent_step.py @@ -10,12 +10,12 @@ from republic.errors import APIStatusError, StreamIncompleteError from bub.builtin.settings import AgentSettings -from bub.core.context import default_tape_context -from bub.core.errors import BubError, ErrorKind -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext, TapeEntry -from bub.core.tools import Tool +from bubling.context import default_tape_context +from bubling.errors import BubError, ErrorKind +from bubling.prompt import to_content +from bubling.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bubling.tape import Tape, TapeContext, TapeEntry +from bubling.tools import Tool from tests.model_fakes import ProviderService, StepRunner, chat_events, sse, tool_events diff --git a/tests/test_environment.py b/tests/test_environment.py index 4d5b066d..3143be5c 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -8,12 +8,12 @@ import bub.builtin.tools as builtin_tools from bub.builtin.environment import LocalEnvironment, LocalProcess, environment_from_state from bub.builtin.shell_manager import ShellManager -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import ToolContext from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, Environment, Process from bub.framework import BubFramework from bub.hooks import hookimpl +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.tape import Tape, TapeContext +from bubling.tools import ToolContext class MemoryEnvironment(Environment): diff --git a/tests/test_framework.py b/tests/test_framework.py index f549b921..44b3596d 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -19,10 +19,10 @@ from bub.channels.message import ChannelMessage from bub.channels.telegram import TelegramSettings from bub.configure import ensure_config -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.framework import BubFramework from bub.hooks import hookimpl from bub.model_selection import ModelChoice, ModelOptions +from bubling.streaming import AsyncStreamEvents, StreamEvent, StreamState def make_named_channel(name: str, label: str) -> Channel: diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py index 1c839c37..53c28359 100644 --- a/tests/test_hook_runtime.py +++ b/tests/test_hook_runtime.py @@ -2,9 +2,9 @@ import pytest import republic -from bub.core.streaming import AsyncStreamEvents, StreamEvent from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.runtime import HookRuntime +from bubling.streaming import AsyncStreamEvents, StreamEvent def _runtime_with_plugins(*plugins: tuple[str, object]) -> HookRuntime: diff --git a/tests/test_image_message.py b/tests/test_image_message.py index 2e95e350..d3cfbfa4 100644 --- a/tests/test_image_message.py +++ b/tests/test_image_message.py @@ -11,8 +11,8 @@ from bub.builtin.hook_impl import BuiltinImpl from bub.channels.message import ChannelMessage, MediaItem from bub.channels.telegram import TelegramChannel, TelegramMessageParser, _extract_media_items -from bub.core.prompt import prompt_text, to_content, to_legacy_prompt from bub.framework import BubFramework +from bubling.prompt import prompt_text, to_content, to_legacy_prompt # --------------------------------------------------------------------------- # MediaItem & ChannelMessage diff --git a/tests/test_otlp.py b/tests/test_otlp.py index d1db5599..f8573841 100644 --- a/tests/test_otlp.py +++ b/tests/test_otlp.py @@ -74,7 +74,7 @@ def unexpected_configuration(*args, **kwargs): raise SystemExit('OTLP must not configure Logfire') logfire.configure = unexpected_configuration from bub.__main__ import _instrument_bub -from bub.core.tracing import Span +from bubling.tracing import Span _instrument_bub('INFO') # CLI import already configured it; repeat must not duplicate exports. root = Span('invoke_agent bub', {'gen_ai.operation.name': 'invoke_agent'}) with root.activate(): @@ -125,7 +125,7 @@ def find_spec(self, fullname, path=None, target=None): run_python( blocker + """ -from bub.core.tracing import Span, configure_otlp +from bubling.tracing import Span, configure_otlp configure_otlp() span = Span('noop') assert not span.recording @@ -144,7 +144,7 @@ def test_otlp_preserves_application_provider(tmp_path: Path) -> None: from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from bub.core.tracing import Span, configure_otlp +from bubling.tracing import Span, configure_otlp provider = TracerProvider() exporter = InMemorySpanExporter() provider.add_span_processor(SimpleSpanProcessor(exporter)) @@ -163,7 +163,7 @@ def test_otlp_rejects_unsupported_protocol(tmp_path: Path) -> None: pytest.importorskip("opentelemetry.sdk.trace") run_python( """ -from bub.core.tracing import configure_otlp +from bubling.tracing import configure_otlp try: configure_otlp() except ValueError as exc: diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index 901e170c..3a3ecd40 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -11,13 +11,13 @@ from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem -from bub.core.context import default_tape_context -from bub.core.errors import BubError, ErrorKind -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import Tool from bub.framework import BubFramework +from bubling.context import default_tape_context +from bubling.errors import BubError, ErrorKind +from bubling.prompt import to_content +from bubling.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bubling.tape import Tape, TapeContext +from bubling.tools import Tool from tests.model_fakes import ProviderService, StepRunner, chat_events, sse @@ -174,11 +174,11 @@ async def test_fresh_process_replays_tool_signatures_to_the_provider( import asyncio, json, sys from pathlib import Path import httpx2 -from bub.core.context import default_tape_context +from bubling.context import default_tape_context from tests.model_fakes import StepRunner from bub.builtin.settings import AgentSettings -from bub.core.store import FileTapeStore, AsyncTapeStoreAdapter -from bub.core.tape import Tape +from bubling.store import FileTapeStore, AsyncTapeStoreAdapter +from bubling.tape import Tape async def main(): bodies = [] def respond(request): diff --git a/tests/test_sdk_isolation.py b/tests/test_sdk_isolation.py index 95a5e818..c8c7d66b 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -6,13 +6,13 @@ from bub.builtin.codemode import run_code, set_code_mode from bub.builtin.model import BubModel from bub.builtin.tools import resolve_tool_names, run_subagent, show_help -from bub.core import Agent as CoreAgent -from bub.core.store import InMemoryTapeStore -from bub.core.streaming import AsyncStreamEvents, StreamEvent -from bub.core.tape import Tape, session_tape_name -from bub.core.tools import Tool, ToolContext from bub.framework import BubFramework from bub.hooks import hookimpl +from bubling import Agent as CoreAgent +from bubling.store import InMemoryTapeStore +from bubling.streaming import AsyncStreamEvents, StreamEvent +from bubling.tape import Tape, session_tape_name +from bubling.tools import Tool, ToolContext def _reply() -> AsyncStreamEvents: @@ -54,7 +54,7 @@ async def test_sdk_agents_execute_only_their_own_command_prefix( framework: BubFramework, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("BUB_COMMAND_PREFIX", "!") - monkeypatch.setattr("bub.core.agent.Agent.run_once", lambda *args, **kwargs: _reply()) + monkeypatch.setattr("bubling.agent.Agent.run_once", lambda *args, **kwargs: _reply()) custom = BubAgent(framework, tools=[show_help], skill_dirs=[], command_prefix="::") configured = BubAgent(framework, tools=[show_help], skill_dirs=[]) diff --git a/tests/test_session_settings.py b/tests/test_session_settings.py index c6c19886..3a440904 100644 --- a/tests/test_session_settings.py +++ b/tests/test_session_settings.py @@ -5,9 +5,9 @@ import pytest from bub.builtin.settings import load_session_settings, set_session_setting -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import ToolContext +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.tape import Tape, TapeContext +from bubling.tools import ToolContext def _context(tmp_path: Path) -> ToolContext: diff --git a/tests/test_settings.py b/tests/test_settings.py index f14eaf27..ae5dd2b7 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -11,9 +11,9 @@ from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, load_settings from bub.builtin.spill import SpillSettings from bub.configure import ensure_config -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext +from bubling.prompt import to_content +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.tape import Tape, TapeContext from tests.model_fakes import ProviderService, StepRunner, chat_events, sse diff --git a/tests/test_spill.py b/tests/test_spill.py index 9d96c222..727c34ae 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -20,14 +20,14 @@ spill_read, ) from bub.builtin.tools import render_tools_prompt -from bub.core.context import default_tape_context -from bub.core.events import AfterToolCall, EventBus -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore -from bub.core.tape import Tape -from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import PluginAgentHooks, ToolCall, ToolCallResult from bub.hooks.runtime import HookRuntime +from bubling.context import default_tape_context +from bubling.events import AfterToolCall, EventBus +from bubling.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore +from bubling.tape import Tape +from bubling.tools import Tool, ToolContext, ToolExecutor, model_tools from tests.model_fakes import ProviderService, StepRunner @@ -438,7 +438,7 @@ async def test_result_spill_without_tool_availability_keeps_full_output(tmp_path async def test_spill_bounds_text_of_content_results_and_keeps_media(tmp_path: Path) -> None: import republic - from bub.core.tools import content_result, result_content + from bubling.tools import content_result, result_content image = republic.Image("image/png", data=b"png") root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py index 005bd3d7..deecbb2f 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -6,8 +6,8 @@ import pytest from bub.builtin.tools import run_subagent -from bub.core.streaming import AsyncStreamEvents, StreamEvent -from bub.core.tools import REGISTRY, tool +from bubling.streaming import AsyncStreamEvents, StreamEvent +from bubling.tools import REGISTRY, tool class FakeContext: diff --git a/tests/test_tape.py b/tests/test_tape.py index 2ad6e3d4..a38af0ba 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -5,9 +5,9 @@ import pytest import republic -from bub.core.context import default_tape_context -from bub.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext, TapeEntry, to_messages +from bubling.context import default_tape_context +from bubling.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore +from bubling.tape import Tape, TapeContext, TapeEntry, to_messages def test_tape_reexports_legacy_store_objects() -> None: diff --git a/tests/test_tape_search_output.py b/tests/test_tape_search_output.py index c16fc9f1..84fe07bb 100644 --- a/tests/test_tape_search_output.py +++ b/tests/test_tape_search_output.py @@ -6,7 +6,7 @@ import bub.builtin.tools as builtin_tools from bub.builtin.tools import tape_search -from bub.core.tools import ToolContext +from bubling.tools import ToolContext @dataclass(frozen=True) diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py index e0658693..680fc630 100644 --- a/tests/test_tool_loading.py +++ b/tests/test_tool_loading.py @@ -7,8 +7,8 @@ import pytest from bub.builtin.agent import BubAgent -from bub.core.tools import Tool from bub.framework import BubFramework +from bubling.tools import Tool from tests.model_fakes import ProviderService diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 82d78c69..3fe2dcd0 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -13,16 +13,16 @@ from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings -from bub.core import tracing -from bub.core.context import default_tape_context -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.streaming import AsyncStreamEvents, StreamEvent -from bub.core.tape import Tape, session_tape_name -from bub.core.tools import Tool, ToolContext, ToolExecutor from bub.framework import BubFramework from bub.hooks import hookimpl from bub.hooks.interception import LlmCallDecision, ToolCallDecision, ToolCallResult from bub.utils import workspace_from_state +from bubling import tracing +from bubling.context import default_tape_context +from bubling.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bubling.streaming import AsyncStreamEvents, StreamEvent +from bubling.tape import Tape, session_tape_name +from bubling.tools import Tool, ToolContext, ToolExecutor from tests.model_fakes import ProviderService, chat_events, sse @@ -320,9 +320,9 @@ def find_spec(self, fullname, path=None, target=None): if fullname.split('.')[0] in {'opentelemetry', 'logfire'}: raise ModuleNotFoundError(fullname) sys.meta_path.insert(0, BlockTelemetry()) -from bub.core import tracing +from bubling import tracing from bub.builtin.agent import BubAgent -from bub.core.tools import Tool, ToolExecutor +from bubling.tools import Tool, ToolExecutor assert tracing.otel is None async def main(): span = tracing.Span('noop') diff --git a/tox.ini b/tox.ini index 5d124f01..744532b6 100644 --- a/tox.ini +++ b/tox.ini @@ -13,5 +13,5 @@ passenv = PYTHON_VERSION allowlist_externals = uv commands = uv sync --python {envpython} - uv run python -m pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml + uv run python -m pytest --doctest-modules --cov --cov-config=pyproject.toml --cov-report=xml mypy diff --git a/uv.lock b/uv.lock index 6480ddab..adfc4b68 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,12 @@ resolution-markers = [ "python_full_version < '3.14'", ] +[manifest] +members = [ + "bub", + "bubling", +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -186,6 +192,7 @@ name = "bub" source = { editable = "." } dependencies = [ { name = "aiohttp" }, + { name = "bubling" }, { name = "httpx", extra = ["socks"] }, { name = "inquirer-textual" }, { name = "loguru" }, @@ -204,15 +211,11 @@ dependencies = [ [package.optional-dependencies] logfire = [ + { name = "bubling", extra = ["trace"] }, { name = "logfire" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, ] trace = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, + { name = "bubling", extra = ["trace"] }, ] [package.dev-dependencies] @@ -229,16 +232,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.14.3" }, + { name = "bubling", editable = "packages/bubling" }, + { name = "bubling", extras = ["trace"], marker = "extra == 'logfire'", editable = "packages/bubling" }, + { name = "bubling", extras = ["trace"], marker = "extra == 'trace'", editable = "packages/bubling" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "inquirer-textual", specifier = ">=0.8.0" }, { name = "logfire", marker = "extra == 'logfire'", specifier = ">=4.32.0" }, { name = "loguru", specifier = ">=0.7.3" }, - { name = "opentelemetry-api", marker = "extra == 'logfire'", specifier = ">=1.39.0" }, - { name = "opentelemetry-api", marker = "extra == 'trace'", specifier = ">=1.39.0" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'logfire'", specifier = ">=1.39.0" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'trace'", specifier = ">=1.39.0" }, - { name = "opentelemetry-sdk", marker = "extra == 'logfire'", specifier = ">=1.39.0" }, - { name = "opentelemetry-sdk", marker = "extra == 'trace'", specifier = ">=1.39.0" }, { name = "pluggy", specifier = ">=1.6.0" }, { name = "prompt-toolkit", specifier = ">=3.0.52" }, { name = "pydantic", specifier = ">=2.12.5" }, @@ -264,6 +264,35 @@ dev = [ { name = "types-pyyaml", specifier = ">=6.0.12.20260408" }, ] +[[package]] +name = "bubling" +source = { editable = "packages/bubling" } +dependencies = [ + { name = "loguru" }, + { name = "pydantic" }, + { name = "republic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +trace = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "loguru", specifier = ">=0.7.3" }, + { name = "opentelemetry-api", marker = "extra == 'trace'", specifier = ">=1.39.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'trace'", specifier = ">=1.39.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'trace'", specifier = ">=1.39.0" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "republic", specifier = ">=0.6.0a1" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, +] +provides-extras = ["trace"] + [[package]] name = "cachetools" version = "7.0.5" diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index c7dfe9e1..1e6f184d 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -5,7 +5,7 @@ sidebar: order: 0 --- -`bub.core` is Bub's agent kernel. Its `Agent` runs the agent loop (call the model, execute the tools it asks for, +`bubling` is Bub's agent kernel, published as its own package. Its `Agent` runs the agent loop (call the model, execute the tools it asks for, repeat until it answers) over components you supply: tools, system prompt sections, and a tape store, plus the Republic model it calls, given to the agent or to each run. It reads no Bub settings and loads no plugins, so it embeds in any application. @@ -14,12 +14,14 @@ commands, and steering. ## Install -Install Bub in your Python 3.12+ project: +Install the kernel in your Python 3.12+ project: ```bash -uv add bub +uv add bubling ``` +`bub` depends on `bubling`, so applications that also use Bub's runtime can install `bub` instead. + The API below describes this source checkout; use a release containing these interfaces or install the checkout. ## Create an agent @@ -32,8 +34,8 @@ from pathlib import Path import republic -from bub.core import Agent, Tool -from bub.core.store import FileTapeStore +from bubling import Agent, Tool +from bubling.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: @@ -110,21 +112,21 @@ A `BeforeLlmCall` handler may redirect a call to another `provider:model` spec: the run model's client, and another provider gets a client configured from Republic's environment. For a policy of your own, such as fallbacks, per-provider credentials, or a timeout for the whole call, pass an -object implementing `bub.core.ModelClient` instead: a `spec` (the default `provider:model`), a default +object implementing `bubling.ModelClient` instead: a `spec` (the default `provider:model`), a default `max_tokens` (or `None`), and `open_stream(messages, spec=..., options=...)`, an async context manager yielding the provider name and a `republic.Stream`. Bub's own `bub.builtin.model.BubModel` is such a client. ## Tools `Tool.from_callable()` derives a schema from annotations and a description from the docstring, without registering -the tool globally. The `@tool` decorator from `bub.core` builds the same `Tool` and also adds it to Bub's global +the tool globally. The `@tool` decorator from `bubling` builds the same `Tool` and also adds it to Bub's global registry, which Bub's own agent snapshots. A tool created with `context=True` receives a `ToolContext` with the run's `tape`, turn `state`, `run_id`, and event bus: ```python -from bub.core import ToolContext +from bubling import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -147,7 +149,7 @@ skips the section. `agent.system_prompt.add(section)` appends a section and retu decorator; `agent.system_prompt.remove(section)` removes it. ```python -from bub.core import PromptContext +from bubling import PromptContext @agent.system_prompt.add @@ -212,10 +214,10 @@ Prefixes must be non-empty and contain no whitespace. Prompts with media are nev ## Events -The loop reports its progress as events from `bub.core.events`; register handlers to observe or steer it. +The loop reports its progress as events from `bubling.events`; register handlers to observe or steer it. ```python -from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision +from bubling import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision @agent.events.on(BeforeToolCall) @@ -261,7 +263,7 @@ Inside a tool, the run's bus is `context.events`: ```python from dataclasses import dataclass -from bub.core import AgentEvent, ToolContext +from bubling import AgentEvent, ToolContext @dataclass(kw_only=True) @@ -351,7 +353,7 @@ the turn state: | `continue_prompt(tape, prompt, state)` | `None` | Return a user message for the next step after tool calls. | ```python -from bub.core import Tape +from bubling import Tape class TriageAgent(Agent): diff --git a/website/src/content/docs/docs/concepts/tape-and-context.mdx b/website/src/content/docs/docs/concepts/tape-and-context.mdx index 6b58cd83..fc1f23f2 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -46,7 +46,7 @@ tape_name = f"{workspace_hash}__{session_hash}" This means the same `session_id` produces a different tape in a different workspace, so two repos do not share state by accident. -The default `provide_tape_store` returns a `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `bub.core.store.FileTapeStore` whose text queries return the newest unique matches first and also match approximately. Plugins replace it by providing their own `provide_tape_store` (for example, a SQLite or HTTP-backed store). +The default `provide_tape_store` returns a `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `bubling.store.FileTapeStore` whose text queries return the newest unique matches first and also match approximately. Plugins replace it by providing their own `provide_tape_store` (for example, a SQLite or HTTP-backed store). ### Spill sidecar diff --git a/website/src/content/docs/docs/concepts/turn-pipeline.mdx b/website/src/content/docs/docs/concepts/turn-pipeline.mdx index 1c14cd82..18d4c471 100644 --- a/website/src/content/docs/docs/concepts/turn-pipeline.mdx +++ b/website/src/content/docs/docs/concepts/turn-pipeline.mdx @@ -86,7 +86,7 @@ The default observer (`BuiltinImpl.on_error`) sends an error envelope through `d - `resolve_session` — uses the envelope's `session_id` if present, otherwise `f"{channel}:{chat_id}"`. - `load_state` — opens the inbound's optional `lifespan`, seeds `session_id` and an internal `_runtime_agent`, and restores session settings (model, reasoning effort, code mode) recorded on the session tape. - `build_prompt` — marks command prompts (`,help`, `,skill`, ...) and passes them through unchanged for the agent to run, wraps other text with timestamp and context, and emits multimodal parts when media is attached. -- `run_model_stream` — runs the turn with `BubAgent.run_stream()`, built on the `bub.core` agent loop with [Republic](https://github.com/bubbuild/republic) models, tool use, commands, and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)). The builtin plugin does not implement `run_model`; non-streaming callers get the collected text through the adapter described above. +- `run_model_stream` — runs the turn with `BubAgent.run_stream()`, built on the `bubling` agent loop with [Republic](https://github.com/bubbuild/republic) models, tool use, commands, and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)). The builtin plugin does not implement `run_model`; non-streaming callers get the collected text through the adapter described above. - `save_state` — closes the per-message lifespan with the captured exception info. - `render_outbound` — wraps `model_output` into one `ChannelMessage` carrying the inbound's routing fields. - `dispatch_outbound` — forwards through the bound `ChannelRouter`. diff --git a/website/src/content/docs/docs/reference/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx index 5c6f6dc0..3bf6574a 100644 --- a/website/src/content/docs/docs/reference/hooks.mdx +++ b/website/src/content/docs/docs/reference/hooks.mdx @@ -43,9 +43,9 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi ### Prompt content -`build_prompt` returns `list[UserContent]` from `bub.prompt`, where `UserContent = str | republic.Image | republic.Audio | republic.Video`. Build media with `republic.image(...)`, `republic.audio(...)` or `republic.video(...)`. For compatibility, it may still return the legacy forms: a string, or a list of content-block dicts such as `{"type": "image", "media_type": "image/png", "url": "..."}`. The framework converts them by inspecting the returned value. +`build_prompt` returns `list[UserContent]` from `bubling.prompt`, where `UserContent = str | republic.Image | republic.Audio | republic.Video`. Build media with `republic.image(...)`, `republic.audio(...)` or `republic.video(...)`. For compatibility, it may still return the legacy forms: a string, or a list of content-block dicts such as `{"type": "image", "media_type": "image/png", "url": "..."}`. The framework converts them by inspecting the returned value. -Hooks that receive a prompt (`run_model`, `run_model_stream`, `continue_prompt`, `system_prompt`) still get the legacy form: a string when the prompt has no media, otherwise content blocks whose media use URLs (inline media become `data:` URLs). `bub.prompt.to_content(prompt)` converts it to user content. +Hooks that receive a prompt (`run_model`, `run_model_stream`, `continue_prompt`, `system_prompt`) still get the legacy form: a string when the prompt has no media, otherwise content blocks whose media use URLs (inline media become `data:` URLs). `bubling.prompt.to_content(prompt)` converts it to user content. ## How hooks are invoked @@ -128,13 +128,13 @@ Each `provide_tape_sidecar` implementation contributes one named capability back ### Agent-loop hooks (`PluginAgentHooks`) -The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` events (`bub.core.events`), not `call_first`/`call_many`, with three deliberate differences: +The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` events (`bubling.events`), not `call_first`/`call_many`, with three deliberate differences: - **Chaining** — `before_llm_call` and `before_tool_call` run every implementation in LIFO order and *fold* modifications: each impl receives the request/call as modified by the impls before it. The first short-circuiting decision (`LlmCallDecision.finish`, `ToolCallDecision.replace`/`deny`) stops the chain. - **Fault isolation** — a raising implementation is logged (`hook.agent_hook_failed`) and skipped, never fatal to the turn. Blocking is only expressible through decision objects, so a broken policy plugin cannot veto by crashing. - **Exactly-once terminal handling** — `after_llm_call` and `after_tool_call` fire exactly once per call for real completions: success or `Exception` failure. Cancellation and consumer close (`BaseException`) intentionally bypass after hooks. `result.error` carries the **original exception object** (a `BubError` for tool failures with `kind`/`details` intact). `after_tool_call` implementations share one outcome, so assigning `result.result` changes the model-facing value seen by later implementations while leaving any failure in `result.error` intact. -Payload dataclasses (`LlmCallRequest`, `LlmCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LlmCallDecision`) live in `src/bub/core/events.py` and are re-exported by `bub.hooks.interception`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. +Payload dataclasses (`LlmCallRequest`, `LlmCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LlmCallDecision`) live in `packages/bubling/src/bubling/events.py` and are re-exported by `bub.hooks.interception`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. A continuation-style `wrap_tool_call` (retry / human-in-the-loop / caching) was prototyped in the original PR and removed by review direction; if those use cases materialize it will return as a separate proposal. diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index 2402376b..026f5ae5 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -11,7 +11,7 @@ The former `bub.types` compatibility module has been removed. Use `bub.envelope. ## Kernel vocabulary -Errors, prompt content, streaming, tracing and `TurnState` belong to the agent kernel in `bub.core`, which depends on nothing else in Bub. The former paths `bub.errors`, `bub.prompt`, `bub.streaming` and `bub.tracing` remain as aliases of the `bub.core` modules, and `bub.turn` re-exports `TurnState`. +Errors, prompt content, streaming, tracing and `TurnState` belong to the agent kernel, the `bubling` package, which depends on nothing in Bub. The former paths `bub.errors`, `bub.streaming` and `bub.tracing` remain as aliases of the `bubling` modules, and `bub.turn` re-exports `TurnState`. ### Envelopes — `bub.envelope` @@ -61,10 +61,10 @@ class ModelOptions: Channels and adapters may present these protocol-neutral choices to users. `BubFramework.get_model_options` merges values returned by `provide_model_options`; the caller owns selection UI and persistence. -### Streaming — `bub.core.streaming` +### Streaming — `bubling.streaming` ```python -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bubling.streaming import AsyncStreamEvents, StreamEvent, StreamState ``` `StreamEvent.kind` is one of `text`, `reasoning`, `tool_call`, `tool_result`, `usage`, `error`, or `final`. `AsyncStreamEvents` wraps an async iterator and exposes terminal `error` and `usage` state. @@ -73,10 +73,10 @@ Builtin `tool_call` events use Republic fields: `id`, `name`, `arguments`, and ` Builtin multipart prompts contain text blocks (`type: text`, `text`) or media blocks (`type: image|audio|video`, `media_type`, `url`). The URL can be remote or a base64 data URL. Bub converts these persisted blocks into native Republic parts; Republic owns provider encoding. Hooks supply these text and media blocks. -### Errors — `bub.core.errors` +### Errors — `bubling.errors` ```python -from bub.core.errors import BubError, ErrorKind +from bubling.errors import BubError, ErrorKind ``` `BubError` is the stable execution error object. Its `kind` is an `ErrorKind`, and `as_dict()` returns a transport-friendly payload. diff --git a/website/src/content/docs/docs/tutorials/fastapi.mdx b/website/src/content/docs/docs/tutorials/fastapi.mdx index 738fe60d..070781fb 100644 --- a/website/src/content/docs/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/docs/tutorials/fastapi.mdx @@ -1,11 +1,11 @@ --- title: Serve a Bub agent with FastAPI -description: Expose a bub.core agent over HTTP with FastAPI, with one conversation per session. +description: Expose a bubling agent over HTTP with FastAPI, with one conversation per session. sidebar: order: 7 --- -This tutorial serves an agent built from `bub.core` as an HTTP API. Each request names a session; requests with +This tutorial serves an agent built from `bubling` as an HTTP API. Each request names a session; requests with the same session continue one conversation, stored as a tape on disk. You need Python 3.12+ and an API key for a model provider. The example uses OpenAI through @@ -15,7 +15,7 @@ You need Python 3.12+ and an API key for a model provider. The example uses Open ```bash uv init bub-api && cd bub-api -uv add bub fastapi uvicorn +uv add bubling fastapi uvicorn ``` ## Write the app @@ -32,8 +32,8 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.core import Agent, Tool -from bub.core.store import FileTapeStore +from bubling import Agent, Tool +from bubling.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: diff --git a/website/src/content/docs/docs/tutorials/index.mdx b/website/src/content/docs/docs/tutorials/index.mdx index d06fdd4a..8a2d8657 100644 --- a/website/src/content/docs/docs/tutorials/index.mdx +++ b/website/src/content/docs/docs/tutorials/index.mdx @@ -16,7 +16,7 @@ Tutorials are hands-on lessons. Use this section when you want to learn a workfl 5. [Load Agent Plugins](/docs/tutorials/agent-plugins/) — load a portable skill and MCP server alongside existing Bub integrations. 6. [Experimental: Extend Bub with Other Languages](/docs/tutorials/language-extensions/) — keep Bub in Python, move selected hooks into Go, Rust, or other languages, and wire them back through pluggy, WebAssembly, and Extism. 7. [Use Bub as an ACP agent](/docs/tutorials/acp-server/) — connect Bub to ACP-compatible graphical clients such as Zed. -8. [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/) — expose a `bub.core` agent over HTTP, with one conversation per session. +8. [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/) — expose a `bubling` agent over HTTP, with one conversation per session. ## Next steps diff --git a/website/src/content/docs/zh-cn/docs/build/sdk.md b/website/src/content/docs/zh-cn/docs/build/sdk.md index 1b67ed00..e65eb8c7 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -5,7 +5,7 @@ sidebar: order: 0 --- -`bub.core` 是 Bub 的 agent 内核。它的 `Agent` 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答), +`bubling` 是 Bub 的 agent 内核,作为独立的包发布。它的 `Agent` 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答), 所用的组件都由你提供:工具、system prompt section 和 tape store,以及传给 agent 或每次运行的 Republic 模型。 它不读取 Bub 的 settings,也不加载插件,因此可以嵌入任何应用。 @@ -13,12 +13,14 @@ Bub 自身的运行时也构建在这个内核之上:`bub.builtin.BubAgent` ## 安装 -在 Python 3.12+ 项目中安装 Bub: +在 Python 3.12+ 项目中安装内核: ```bash -uv add bub +uv add bubling ``` +`bub` 依赖 `bubling`,同时需要 Bub 运行时的应用可以直接安装 `bub`。 + 下文描述的是当前源码中的 API;请使用包含这些接口的发布版本,或安装当前源码。 ## 创建 Agent @@ -31,8 +33,8 @@ from pathlib import Path import republic -from bub.core import Agent, Tool -from bub.core.store import FileTapeStore +from bubling import Agent, Tool +from bubling.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: @@ -107,20 +109,20 @@ async with agent.run(prompt, tape="t") as stream: 处理器可以把一次调用重定向到其他 `provider:model`:同一 provider 的模型复用运行模型的客户端,其他 provider 使用从 Republic 环境变量配置的客户端。 -如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 `bub.core.ModelClient` +如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 `bubling.ModelClient` 的对象:提供 `spec`(默认的 `provider:model`)、默认 `max_tokens`(或 `None`),以及 `open_stream(messages, spec=..., options=...)`——一个产出 provider 名称和 `republic.Stream` 的异步上下文管理器。 Bub 自己的 `bub.builtin.model.BubModel` 就是这样的客户端。 ## 工具 -`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core` 中的 +`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bubling` 中的 `@tool` 装饰器生成同样的 `Tool`,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。 以 `context=True` 创建的工具会收到 `ToolContext`,其中包含本次运行的 `tape`、turn `state`、`run_id` 和事件总线: ```python -from bub.core import ToolContext +from bubling import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -142,7 +144,7 @@ system prompt 是有序的 section 列表,每个模型步骤渲染一次,以 追加一个 section 并返回它,因此也可以用作装饰器;`agent.system_prompt.remove(section)` 将其移除。 ```python -from bub.core import PromptContext +from bubling import PromptContext @agent.system_prompt.add @@ -203,10 +205,10 @@ async with agent.run(",lookup_order order_id=A123", model=model, tape="customer- ## 事件 -循环通过 `bub.core.events` 中的事件报告进度;注册处理器即可观察或干预它。 +循环通过 `bubling.events` 中的事件报告进度;注册处理器即可观察或干预它。 ```python -from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision +from bubling import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision @agent.events.on(BeforeToolCall) @@ -250,7 +252,7 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat ```python from dataclasses import dataclass -from bub.core import AgentEvent, ToolContext +from bubling import AgentEvent, ToolContext @dataclass(kw_only=True) @@ -335,7 +337,7 @@ async with agent.run("Check order A123", model=model, tape="customer-42") as str | `continue_prompt(tape, prompt, state)` | `None` | 在工具调用后返回下一步的用户消息。 | ```python -from bub.core import Tape +from bubling import Tape class TriageAgent(Agent): diff --git a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx index 9499e349..6e37e964 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx @@ -46,7 +46,7 @@ tape_name = f"{workspace_hash}__{session_hash}" 这意味着同一个 `session_id` 在不同 workspace 会得到不同 tape,两个仓库不会互相串 state。 -默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bub.core.store.FileTapeStore` 的子类,文本查询按从新到旧返回去重后的结果,并支持近似匹配。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。 +默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bubling.store.FileTapeStore` 的子类,文本查询按从新到旧返回去重后的结果,并支持近似匹配。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。 ### spill sidecar diff --git a/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx b/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx index ccaae096..16be8c35 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx @@ -86,7 +86,7 @@ hook 运行时会在两者间互相适配:若插件只实现 `run_model_stream - `resolve_session` — 优先使用 envelope 的 `session_id`,否则 `f"{channel}:{chat_id}"`。 - `load_state` — 打开 inbound 的可选 `lifespan`,初始化 `session_id` 与内部 `_runtime_agent`,并恢复 session tape 上记录的会话设置(模型、reasoning effort、code mode)。 - `build_prompt` — 标记命令 prompt(`,help`、`,skill` 等)并原样传给 agent 执行,用时间戳与上下文包裹其他文本,并在附带媒体时输出多模态片段。 -- `run_model_stream` — 通过 `BubAgent.run_stream()` 运行 turn,它构建在 `bub.core` 的 agent loop 之上,使用 [Republic](https://github.com/bubbuild/republic) 模型,支持工具调用、命令和 auto-handoff(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。builtin 插件不实现 `run_model`;非流式调用方通过上文描述的适配获得拼接后的文本。 +- `run_model_stream` — 通过 `BubAgent.run_stream()` 运行 turn,它构建在 `bubling` 的 agent loop 之上,使用 [Republic](https://github.com/bubbuild/republic) 模型,支持工具调用、命令和 auto-handoff(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。builtin 插件不实现 `run_model`;非流式调用方通过上文描述的适配获得拼接后的文本。 - `save_state` — 用捕获的异常信息关闭 per-message lifespan。 - `render_outbound` — 将 `model_output` 包装为携带 inbound 路由字段的 `ChannelMessage`。 - `dispatch_outbound` — 转发到绑定的 `ChannelRouter`。 diff --git a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx index fbace925..f9e278dc 100644 --- a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx @@ -43,9 +43,9 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用 ### Prompt 内容 -`build_prompt` 返回 `bub.prompt` 中的 `list[UserContent]`,其中 `UserContent = str | republic.Image | republic.Audio | republic.Video`。媒体用 `republic.image(...)`、`republic.audio(...)` 或 `republic.video(...)` 构造。为保持兼容,它仍可返回旧格式:字符串,或 content block 字典列表(例如 `{"type": "image", "media_type": "image/png", "url": "..."}`)。框架会根据实际返回值进行转换。 +`build_prompt` 返回 `bubling.prompt` 中的 `list[UserContent]`,其中 `UserContent = str | republic.Image | republic.Audio | republic.Video`。媒体用 `republic.image(...)`、`republic.audio(...)` 或 `republic.video(...)` 构造。为保持兼容,它仍可返回旧格式:字符串,或 content block 字典列表(例如 `{"type": "image", "media_type": "image/png", "url": "..."}`)。框架会根据实际返回值进行转换。 -接收 prompt 的 hook(`run_model`、`run_model_stream`、`continue_prompt`、`system_prompt`)仍收到旧格式:没有媒体时为字符串,否则为 content block 列表,媒体使用 URL(内联媒体转为 `data:` URL)。可用 `bub.prompt.to_content(prompt)` 转为 user content。 +接收 prompt 的 hook(`run_model`、`run_model_stream`、`continue_prompt`、`system_prompt`)仍收到旧格式:没有媒体时为字符串,否则为 content block 列表,媒体使用 URL(内联媒体转为 `data:` URL)。可用 `bubling.prompt.to_content(prompt)` 转为 user content。 ## 钩子如何被调用 @@ -127,13 +127,13 @@ Windows 当前只清理直接 shell 进程。 ### Agent-loop 钩子(`PluginAgentHooks`) -四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` 事件,见 `bub.core.events`)分发,而不是 `call_first`/`call_many`,有三处刻意差异: +四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` 事件,见 `bubling.events`)分发,而不是 `call_first`/`call_many`,有三处刻意差异: - **链式** — `before_llm_call` 与 `before_tool_call` 按 LIFO 顺序执行全部实现并**折叠**修改:每个实现收到的是前序实现修改后的 request/call。第一个短路决定(`LlmCallDecision.finish`、`ToolCallDecision.replace`/`deny`)终止链。 - **错误隔离** — 实现抛异常只记日志(`hook.agent_hook_failed`)并跳过,绝不炸掉回合。阻断只能通过 decision 对象表达,坏掉的策略插件无法靠 crash 否决。 - **终态处理恰好一次** — `after_llm_call` 与 `after_tool_call` 对真实完成的调用恰好触发一次:成功或 `Exception` 失败。取消与消费方关闭(`BaseException`)刻意不进入 after hook。`result.error` 携带**原始异常对象**(工具失败为完整 `BubError`,保留 `kind`/`details`)。所有 `after_tool_call` 实现共享同一个 outcome,因此赋值 `result.result` 会改变后续实现看到的模型可见结果,同时保留 `result.error` 中的失败状态。 -载荷数据类(`LlmCallRequest`、`LlmCallResult`、`ToolCall`、`ToolCallDecision`、`ToolCallResult`、`LlmCallDecision`)位于 `src/bub/core/events.py`,并由 `bub.hooks.interception` 重新导出。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 +载荷数据类(`LlmCallRequest`、`LlmCallResult`、`ToolCall`、`ToolCallDecision`、`ToolCallResult`、`LlmCallDecision`)位于 `packages/bubling/src/bubling/events.py`,并由 `bub.hooks.interception` 重新导出。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 continuation 风格的 `wrap_tool_call`(重试 / human-in-the-loop / 缓存)曾在原 PR 中实现,按 review 方向移除;若相关用例成熟,将作为独立提案回归。 diff --git a/website/src/content/docs/zh-cn/docs/reference/types.mdx b/website/src/content/docs/zh-cn/docs/reference/types.mdx index 75f3a952..90d99cad 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -11,7 +11,7 @@ Bub 把契约放在拥有其行为的子系统旁边。请从定义它的模块 ## 内核词汇 -Error、prompt 内容、streaming、tracing 与 `TurnState` 属于 `bub.core` 中的 agent 内核,它不依赖 Bub 的其他部分。原路径 `bub.errors`、`bub.prompt`、`bub.streaming` 与 `bub.tracing` 作为 `bub.core` 对应模块的别名保留,`bub.turn` 也重新导出 `TurnState`。 +Error、prompt 内容、streaming、tracing 与 `TurnState` 属于 agent 内核,即 `bubling` 包,它不依赖 Bub 的任何部分。原路径 `bub.errors`、`bub.streaming` 与 `bub.tracing` 作为 `bubling` 对应模块的别名保留,`bub.turn` 也重新导出 `TurnState`。 ### Envelope — `bub.envelope` @@ -61,10 +61,10 @@ class ModelOptions: Channel 与 adapter 可向用户展示这些协议无关的选择项。`BubFramework.get_model_options` 合并 `provide_model_options` 返回的值;选择 UI 与持久化由调用方负责。 -### Streaming — `bub.core.streaming` +### Streaming — `bubling.streaming` ```python -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bubling.streaming import AsyncStreamEvents, StreamEvent, StreamState ``` `StreamEvent.kind` 可以是 `text`、`reasoning`、`tool_call`、`tool_result`、`usage`、`error` 或 `final`。`AsyncStreamEvents` 包装异步迭代器,并暴露最终 `error` 与 `usage` 状态。 @@ -73,10 +73,10 @@ builtin 的 `tool_call` 事件使用 Republic 字段:`id`、`name`、`argument builtin 的多部分 prompt 包含文本块(`type: text`、`text`)或媒体块(`type: image|audio|video`、`media_type`、`url`)。URL 可以是远程地址或 base64 data URL。Bub 将持久化内容块转换为 Republic 原生 part,provider 编码由 Republic 负责。hook 提供这些文本与媒体块。 -### Error — `bub.core.errors` +### Error — `bubling.errors` ```python -from bub.core.errors import BubError, ErrorKind +from bubling.errors import BubError, ErrorKind ``` `BubError` 是稳定的执行错误对象。其 `kind` 为 `ErrorKind`,`as_dict()` 返回适合传输的 payload。 diff --git a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx index b425630f..a807f244 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx @@ -1,11 +1,11 @@ --- title: 用 FastAPI 提供 Bub agent 服务 -description: 用 FastAPI 把 bub.core agent 暴露为 HTTP API,每个会话对应一段对话。 +description: 用 FastAPI 把 bubling agent 暴露为 HTTP API,每个会话对应一段对话。 sidebar: order: 7 --- -本教程把基于 `bub.core` 构建的 agent 作为 HTTP API 提供服务。每个请求都指定一个会话;同一会话的请求会延续同一段对话, +本教程把基于 `bubling` 构建的 agent 作为 HTTP API 提供服务。每个请求都指定一个会话;同一会话的请求会延续同一段对话, 对话以 tape 的形式保存在磁盘上。 你需要 Python 3.12+ 和某个模型提供方的 API key。示例通过 [Republic](https://github.com/bubbuild/republic) 使用 OpenAI; @@ -15,7 +15,7 @@ sidebar: ```bash uv init bub-api && cd bub-api -uv add bub fastapi uvicorn +uv add bubling fastapi uvicorn ``` ## 编写应用 @@ -32,8 +32,8 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.core import Agent, Tool -from bub.core.store import FileTapeStore +from bubling import Agent, Tool +from bubling.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: diff --git a/website/src/content/docs/zh-cn/docs/tutorials/index.mdx b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx index 12058ca2..d6514d76 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/index.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx @@ -16,7 +16,7 @@ sidebar: 5. [加载 Agent Plugins](/zh-cn/docs/tutorials/agent-plugins/) — 加载可移植的 skill 和 MCP 服务器,与现有 Bub 扩展一起使用。 6. [实验性:使用其他语言扩展 Bub](/zh-cn/docs/tutorials/language-extensions/) — 保持 Bub 作为 Python 宿主,把部分 hooks 放到 Go、Rust 或其他语言中实现,并通过 pluggy、WebAssembly 和 Extism 接回 Bub。 7. [将 Bub 作为 ACP agent 使用](/zh-cn/docs/tutorials/acp-server/) — 把 Bub 接入 Zed 等支持 ACP 的图形客户端。 -8. [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/) — 把 `bub.core` agent 暴露为 HTTP API,每个会话对应一段对话。 +8. [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/) — 把 `bubling` agent 暴露为 HTTP API,每个会话对应一段对话。 ## 下一步 From 243025cbe554b4c5f02de13281590a36626a0a3f Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 15:46:56 +0800 Subject: [PATCH 2/2] ci: pin bub to the bubling released with it Releases build bub and bubling at the tag's version and pin bub's bubling requirements to `==` before building; the repository keeps a range for development. scripts/release.py rewrites only the requirement strings and checks that both distributions carry the tag's version and that bub requires that exact bubling before publishing. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/on-release-main.yml | 10 ++- AGENTS.md | 1 + scripts/release.py | 89 +++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 scripts/release.py diff --git a/.github/workflows/on-release-main.yml b/.github/workflows/on-release-main.yml index b83ad194..1e26b95e 100644 --- a/.github/workflows/on-release-main.yml +++ b/.github/workflows/on-release-main.yml @@ -42,8 +42,16 @@ jobs: - name: Set up the environment uses: ./.github/actions/setup-python-env + # bub and bubling are released together: build both at the tag's version and pin bub to that bubling. + - name: Pin bubling to the release version + run: python scripts/release.py pin "${GITHUB_REF_NAME#v}" + - name: Build packages - run: uv build --all-packages + # Pinning dirties the checkout, which would make hatch-vcs compute a dev version. + run: SETUPTOOLS_SCM_PRETEND_VERSION="${GITHUB_REF_NAME#v}" uv build --all-packages + + - name: Check release metadata + run: python scripts/release.py check "${GITHUB_REF_NAME#v}" dist - name: Publish package run: uv publish --trusted-publishing always diff --git a/AGENTS.md b/AGENTS.md index 24ebe5e5..401ee9a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ Tests live in `tests/`. The website and docs runtime lives in `website/`. - `make test`: run pytest with doctests enabled. - `uv run --isolated --no-project --with ./packages/bubling --with pytest --with pytest-asyncio pytest packages/bubling/tests`: run `bubling`'s tests with only `bubling` installed, as CI does. - `make build`: build the distributions of both `bub` and `bubling`. +- Releases: publishing a GitHub release builds `bub` and `bubling` at the tag's version, with `bub` pinned to `bubling==` by `scripts/release.py` (the repository keeps a range for development). Rehearse with `python scripts/release.py pin X`, `SETUPTOOLS_SCM_PRETEND_VERSION=X uv build --all-packages`, `python scripts/release.py check X dist`, then revert `pyproject.toml`. - `make check`: lock validation, `prek` (including import contracts), and typing. - `make docs` / `make docs-test` / `make docs-preview`: serve, build, or preview the Astro website/docs in `website/`. diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 00000000..d1e6a60d --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,89 @@ +"""Release helpers: pin bub to the bubling released with it, then check the built distributions. + +python scripts/release.py pin 0.6.0a2 +python scripts/release.py check 0.6.0a2 dist +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tarfile +import tomllib +import zipfile +from email.parser import Parser +from pathlib import Path + +PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" +BUBLING_REQUIREMENT = re.compile(r"bubling(\[[^\]]*\])?(?=[\s<>=!~;]|$)") + + +def pin(version: str) -> None: + text = PYPROJECT.read_text(encoding="utf-8") + project = tomllib.loads(text)["project"] + requirements = [ + *project["dependencies"], + *(r for extra in project["optional-dependencies"].values() for r in extra), + ] + count = 0 + for requirement in dict.fromkeys(requirements): + if match := BUBLING_REQUIREMENT.match(requirement): + text = text.replace(f'"{requirement}"', f'"bubling{match.group(1) or ""}=={version}"') + count += 1 + if count == 0: + sys.exit(f"No bubling requirement found in {PYPROJECT}") + PYPROJECT.write_text(text, encoding="utf-8") + print(f"Pinned {count} bubling requirement(s) to =={version}") + + +def _metadata(path: Path) -> dict[str, list[str]]: + if path.suffix == ".whl": + with zipfile.ZipFile(path) as wheel: + name = next(n for n in wheel.namelist() if n.endswith(".dist-info/METADATA")) + raw = wheel.read(name).decode() + else: + with tarfile.open(path) as sdist: + member = next(m for m in sdist.getmembers() if m.name.count("/") == 1 and m.name.endswith("/PKG-INFO")) + raw = sdist.extractfile(member).read().decode() # type: ignore[union-attr] + message = Parser().parsestr(raw) + return {key: message.get_all(key) or [] for key in ("Name", "Version", "Requires-Dist")} + + +def check(version: str, dist: Path) -> None: + errors: list[str] = [] + seen: set[str] = set() + for path in sorted([*dist.glob("*.whl"), *dist.glob("*.tar.gz")]): + metadata = _metadata(path) + name, found = metadata["Name"][0], metadata["Version"][0] + seen.add(name) + if found != version: + errors.append(f"{path.name}: version {found} != {version}") + if name == "bub": + bubling = [req for req in metadata["Requires-Dist"] if req.startswith("bubling")] + unpinned = [req for req in bubling if f"=={version}" not in req] + if not bubling or unpinned: + errors.append(f"{path.name}: bubling is not pinned to =={version}: {bubling}") + if missing := {"bub", "bubling"} - seen: + errors.append(f"No distributions of {sorted(missing)} in {dist}") + if errors: + sys.exit("\n".join(errors)) + print(f"bub and bubling {version} are built and bub requires bubling=={version}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("pin").add_argument("version") + checker = commands.add_parser("check") + checker.add_argument("version") + checker.add_argument("dist", type=Path) + args = parser.parse_args() + if args.command == "pin": + pin(args.version) + else: + check(args.version, args.dist) + + +if __name__ == "__main__": + main()