Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,41 @@ jobs:




core-only-install:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v6

- name: Set up the environment
uses: ./.github/actions/setup-python-env

- name: Build the wheel
run: uv build --wheel -o dist

# `pip install bub` without the app extra installs only the agent kernel.
- name: Use bub.core without the app extra
run: |
uv run --isolated --no-project --with dist/bub-*.whl python - <<'EOF'
import importlib.util
from bub.core import Agent
from bub.core.store import InMemoryTapeStore
import bub.errors, bub.core.store, bub.core.tape, bub.core.tools

Agent(store=InMemoryTapeStore())
app_modules = ("typer", "pluggy", "pydantic_settings", "yaml", "rapidfuzz", "telegram", "rich")
assert not [m for m in app_modules if importlib.util.find_spec(m)], "app dependencies were installed"
EOF

- name: The bub command asks for the app extra
run: |
if output=$(uv run --isolated --no-project --with dist/bub-*.whl bub 2>&1); then
echo "bub ran without the app extra"; exit 1
fi
echo "$output"
grep -q "bub\[app\]" <<<"$output"

check-docs:
runs-on: ubuntu-latest
steps:
Expand Down
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,24 @@

Core code lives under `src/`:

- `src/bub/__main__.py`: Typer CLI entrypoint.
- `src/bub/__main__.py`: Typer CLI entrypoint; `main` (the `bub` script) checks the `app` extra before importing the CLI.
- `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.
- `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,streaming,errors,model_selection}.py`: small kernel 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, and tape services.
- `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/channels/`: channel abstractions plus CLI and Telegram adapters.
- `src/bub/skills.py` / `src/bub/tools.py`: skill discovery and tool registry.
- `src/bub/skills.py` / `src/bub/core/tools.py`: skill discovery and tool registry.
- `src/skills/`: bundled skills shipped with Bub.

Tests live in `tests/`. The website and docs runtime lives in `website/`.

Packaging: `pip install bub` installs only the kernel's dependencies (republic, pydantic, loguru, typing-extensions). Everything outside `bub.core` (framework, CLI, channels, hooks, skills, builtin) needs the `app` extra, and the `bub` script (`bub.__main__:main`) asks for it when it is missing. Add new non-kernel dependencies to `app`, not to `dependencies`.

## Build, Test, and Development Commands

- `uv sync`: install or update the Python environment; enough to run Bub from source or for deployment-only hosts.
- `uv sync`: install or update the Python environment, including the `app` extra through the `dev` group; enough to run Bub from source. Deployment-only hosts use `uv sync --no-dev --extra app`.
- `make install`: full local development bootstrap; sync Python deps, install website deps, and install `prek` hooks.
- `uv run bub chat`: run the interactive CLI.
- `uv run bub gateway`: start channel listener mode.
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md LICENSE entrypoint.sh ./
# Copy the full source and install
COPY src ./src
RUN uv sync --locked --no-dev --no-editable && \
RUN uv sync --locked --no-dev --no-editable --extra app && \
chmod +x /app/entrypoint.sh

WORKDIR /workspace
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.** `pip install bub` installs only `bub.core`, which exposes the agent loop on its own (the CLI, channels and plugins come with `bub[app]`) — bring your own model, tools, prompts, and tape store, with no settings or plugins required.

## How It Works

Expand Down
32 changes: 18 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,11 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]

# The agent kernel, bub.core. Everything else (framework, CLI, channels, builtin agent) is in the `app` extra.
dependencies = [
"pydantic>=2.12.5",
"pydantic-settings>=2.14.2",
"pyyaml>=6.0.3",
"pluggy>=1.6.0",
"inquirer-textual>=0.8.0",
"typer>=0.24.1",
"republic>=0.6.0a1",
"rich>=14.3.4",
"prompt-toolkit>=3.0.52",
"python-telegram-bot>=22.7",
"pydantic>=2.12.5",
"loguru>=0.7.3",
"rapidfuzz>=3.14.5",
"aiohttp>=3.14.3",
"httpx[socks]>=0.28.1",
"typing-extensions>=4.15.0",
]

Expand All @@ -44,21 +34,35 @@ Repository = "https://github.com/bubbuild/bub"
Documentation = "https://bub.build"

[project.scripts]
bub = "bub.__main__:app"
bub = "bub.__main__:main"

[project.optional-dependencies]
app = [
"pydantic-settings>=2.14.2",
"pyyaml>=6.0.3",
"pluggy>=1.6.0",
"inquirer-textual>=0.8.0",
"typer>=0.24.1",
"rich>=14.3.4",
"prompt-toolkit>=3.0.52",
"python-telegram-bot>=22.7",
"rapidfuzz>=3.14.5",
"aiohttp>=3.14.3",
"httpx[socks]>=0.28.1",
]
trace = [
"opentelemetry-api>=1.39.0",
"opentelemetry-sdk>=1.39.0",
"opentelemetry-exporter-otlp-proto-http>=1.39.0",
]
logfire = [
"bub[trace]",
"bub[app,trace]",
"logfire>=4.32.0",
]

[dependency-groups]
dev = [
"bub[app]",
"pytest>=9.0.3",
"tox-uv>=1.35.1",
"types-PyYAML>=6.0.12.20260408",
Expand Down
41 changes: 29 additions & 12 deletions src/bub/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
"""Bub framework package."""
"""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.
"""

from __future__ import annotations

Expand All @@ -7,12 +11,15 @@
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as metadata_version
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from bub.configure import Settings, config, ensure_config
from bub.core.tools import tool
from bub.framework import DEFAULT_HOME, BubFramework
from bub.hooks import hookimpl
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

home: Path

__all__ = [
"BubFramework",
Expand All @@ -24,6 +31,15 @@
"tool",
]

_EXPORTS = {
"BubFramework": "bub.framework",
"Settings": "bub.configure",
"config": "bub.configure",
"ensure_config": "bub.configure",
"hookimpl": "bub.hooks",
"tool": "bub.core.tools",
}

try:
__version__ = import_module("bub._version").version
except ModuleNotFoundError:
Expand All @@ -33,13 +49,14 @@
__version__ = "0.0.0"


if TYPE_CHECKING:
home: Path


def __getattr__(name: str):
def __getattr__(name: str) -> Any:
if name == "home":
# Importing bub.configure loads ``.env``, which may set ``BUB_HOME``.
from bub.configure import DEFAULT_HOME

if "BUB_HOME" in os.environ:
return Path(os.path.expanduser(os.environ["BUB_HOME"]))
return DEFAULT_HOME
raise AttributeError(f"module {__name__} has no attribute {name}")
if name not in _EXPORTS:
raise AttributeError(f"module {__name__} has no attribute {name}")
return getattr(import_module(_EXPORTS[name]), name)
52 changes: 45 additions & 7 deletions src/bub/__main__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
"""Bub framework CLI bootstrap."""
"""Bub framework CLI bootstrap.

The CLI needs the ``app`` extra, so this module imports it only when the command runs:
``main`` reports missing requirements instead of failing on an import.
"""

from __future__ import annotations

import os
import re
import sys
from importlib.metadata import PackageNotFoundError, distribution, requires
from typing import TYPE_CHECKING

import typer

from bub.builtin.settings import load_settings
from bub.framework import BubFramework
if TYPE_CHECKING:
import typer

LOG_LEVEL = {
0: "INFO",
1: "DEBUG",
2: "TRACE",
}

_APP_REQUIREMENT = re.compile(r"""^([A-Za-z0-9][A-Za-z0-9._-]*).*;.*\bextra\s*==\s*["']app["']""")


def missing_app_requirements() -> list[str]:
"""Return the distributions required by ``bub[app]`` that are not installed."""
try:
requirements = requires("bub") or []
except PackageNotFoundError: # Running from a source tree that is not installed.
return []
missing: list[str] = []
for requirement in requirements:
if (match := _APP_REQUIREMENT.match(requirement)) is None:
continue
try:
distribution(match.group(1))
except PackageNotFoundError:
missing.append(match.group(1))
return missing


def _instrument_bub(level: str) -> None:
from loguru import logger
Expand Down Expand Up @@ -43,6 +67,11 @@ def _instrument_bub(level: str) -> None:


def create_cli_app() -> typer.Typer:
import typer

from bub.builtin.settings import load_settings
from bub.framework import BubFramework

framework = BubFramework()
_instrument_bub(LOG_LEVEL[load_settings().verbose])
framework.load_hooks()
Expand All @@ -57,7 +86,16 @@ def _help() -> None:
return app


app = create_cli_app()
def main() -> None:
"""Run the ``bub`` command."""
if missing := missing_app_requirements():
sys.exit(
"The bub command needs the 'app' extra, which is not installed "
f"(missing: {', '.join(missing)}).\n"
"Install it with: uv tool install 'bub[app]' or pip install 'bub[app]'"
)
create_cli_app()()


if __name__ == "__main__":
app()
main()
2 changes: 1 addition & 1 deletion src/bub/builtin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ def _ensure_project(project: Path) -> None:
return
_uv("init", "--bare", "--name", "bub-project", "--app", cwd=project)
bub_requirement = _build_bub_requirement()
_uv("add", "--active", "--no-sync", *bub_requirement, cwd=project)
_uv("add", "--active", "--no-sync", "--extra", "app", *bub_requirement, cwd=project)


def install(
Expand Down
4 changes: 2 additions & 2 deletions src/bub/builtin/hook_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,9 @@ def provide_environment(self, session_id: str, workspace: Path) -> Environment:
@hookimpl
def provide_tape_store(self) -> TapeStore:
import bub
from bub.core.store import FileTapeStore
from bub.builtin.store import FuzzyFileTapeStore

return FileTapeStore(directory=bub.home / "tapes")
return FuzzyFileTapeStore(directory=bub.home / "tapes")

@hookimpl
def provide_tape_sidecar(self) -> TapeSidecar:
Expand Down
Loading
Loading