diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index a59f1540..94fbf5b8 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -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:
diff --git a/AGENTS.md b/AGENTS.md
index eeb9f18c..26b6c643 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,21 +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, tape context, and code mode (`codemode/`: `CodeMode`, the `run_code` subprocess runner, and the `republic` bridge).
- `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, CLI wiring, settings, 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.
diff --git a/Dockerfile b/Dockerfile
index b782e515..eb274532 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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 --no-dev --no-editable && uv pip install "any-llm-sdk[gemini,xai]" && \
+RUN uv sync --locked --no-dev --no-editable --extra app && \
chmod +x /app/entrypoint.sh
WORKDIR /workspace
diff --git a/README.md b/README.md
index 32b3b6ee..5884dbb2 100644
--- a/README.md
+++ b/README.md
@@ -9,13 +9,13 @@
A tiny agent runtime, composable with plugins.
+[Website](https://bub.build) · [Docs](https://bub.build/docs/getting-started/) · [Hub](https://hub.bub.build)
+
Bub is a small Python runtime for building agents in shared environments. It started in group chats, where multiple humans and agents had to work in the same conversation without hidden state, hand-wavy memory, or framework-specific magic.
-Built on [agents.md](https://agents.md/) and [Agent Skills](https://agentskills.io/) , Bub stays intentionally small. Every turn stage is a [pluggy](https://pluggy.readthedocs.io/) hook. Builtins are included but replaceable. The same runtime drives CLI, Telegram, and any channel you add.
-
-[Website](https://bub.build) · [GitHub](https://github.com/bubbuild/bub)
+Built on [agents.md](https://agents.md/) and [Agent Skills](https://agentskills.io/), Bub stays intentionally small. Every turn stage is a [pluggy](https://pluggy.readthedocs.io/) hook, builtins are replaceable, and the same runtime drives CLI, Telegram, and any channel you add.
## Quick Start
@@ -31,25 +31,7 @@ Windows PowerShell:
powershell -ExecutionPolicy ByPass -c "irm https://bub.build/install.ps1 | iex"
```
-The interactive installer uses a colored preset picker, accepts additional plugin dependencies, and runs `bub onboard` after installation. For automation, select a preset explicitly; non-interactive installs skip onboarding:
-
-```bash
-curl -fsSL https://bub.build/install.sh | bash -s -- --preset recommended --dependency extra-plugin
-```
-
-```powershell
-powershell -ExecutionPolicy ByPass -c "& ([scriptblock]::Create((irm https://bub.build/install.ps1))) --preset recommended --dependency extra-plugin"
-```
-
-Or from source:
-
-```bash
-git clone https://github.com/bubbuild/bub.git
-cd bub
-uv sync # enough to run Bub from source
-```
-
-For local development, use `make install` instead so the website toolchain and `prek` hooks are installed too.
+Then:
```bash
bub chat # interactive session
@@ -57,13 +39,16 @@ bub run "summarize this repo" # one-shot task
bub gateway # channel listener mode
```
+See [Getting Started](https://bub.build/docs/getting-started/) for installer presets and source installs, and [Configure](https://bub.build/docs/operate/configure/) for model and runtime settings.
+
## Why Bub
- **Composable by design.** Every turn stage is a plugin hook. Override one stage or replace the whole flow without forking the runtime.
- **Tape context.** Context is rebuilt from append-only records, not carried around as mutable session state. Easier to inspect, replay, and hand off.
- **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. No hidden operator class.
+- **Operator equivalence.** Humans and agents work inside the same runtime boundaries, with the same evidence trail and handoff model.
+- **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
@@ -75,101 +60,23 @@ resolve_session → load_state → build_prompt → run_model
dispatch_outbound ← render_outbound ← save_state
```
-Builtins are registered first. External plugins load after them. At runtime, later plugins take precedence. There are no framework-only shortcuts.
-
-Key source files:
-
-- Turn orchestrator: [`src/bub/framework.py`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py)
-- Hook contract: [`src/bub/hooks/specs.py`](https://github.com/bubbuild/bub/blob/main/src/bub/hooks/specs.py)
-- Builtin hooks: [`src/bub/builtin/hook_impl.py`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/hook_impl.py)
-- Skill discovery: [`src/bub/skills.py`](https://github.com/bubbuild/bub/blob/main/src/bub/skills.py)
-
-## Extend It
-
-```python
-from bub import hookimpl
-from bub.envelope import content_of
-
-
-class EchoPlugin:
- @hookimpl
- def build_prompt(self, message, session_id, state):
- return f"[echo] {content_of(message)}"
-
- @hookimpl
- async def run_model(self, prompt, session_id, state):
- return prompt
-
-
-echo_plugin = EchoPlugin()
-```
-
-```toml
-[project.entry-points."bub"]
-echo = "my_package.plugin:echo_plugin"
-```
-
-See the [Build docs](https://bub.build/docs/build/) for hook guides, packaging, and plugin structure.
-
-## CLI
-
-| Command | Description |
-| ------------------ | --------------------------------- |
-| `bub chat` | Interactive REPL |
-| `bub run MESSAGE` | One-shot turn |
-| `bub gateway` | Channel listener (Telegram, etc.) |
-| `bub install` | Install or sync Bub plugin deps |
-| `bub update` | Upgrade Bub plugin deps |
-| `bub login openai` | OpenAI Codex OAuth |
-
-Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`).
-
-`bub hooks` still exists for diagnostics, but it is hidden from top-level help. `bub install` and `bub update` manage a separate uv project for Bub plugins, defaulting to `~/.bub/bub-project` or `BUB_PROJECT`.
-
-## Configuration
-
-| Variable | Default | Description |
-| --------------------------- | ---------------------------- | ---------------------------------------------------- |
-| `BUB_MODEL` | `openrouter:openrouter/free` | Model identifier |
-| `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
-| `BUB_API_BASE` | — | Custom provider endpoint |
-| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
-| `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
-| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
-| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
-| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
-| `BUB_SPILL_THRESHOLD` | `4096` | Estimated tokens before tool output spills; `0` disables |
-
-## Background
-
-Bub is shaped by one constraint: real collaboration is messier than a solo demo. In shared environments, operators need visible boundaries, auditable history, and extension points that do not collapse into framework sprawl.
-
-Read more:
-
-- [Why We Rewrote Bub](https://bub.build/posts/why-rewrite-bub/)
-- [Socialized Evaluation and Agent Partnership](https://bub.build/posts/socialized-evaluation/)
-- [Context from Tape](https://tape.systems)
+Builtins are registered first and external plugins load after them, so later plugins take precedence. There are no framework-only shortcuts.
## Docs
- [Getting Started](https://bub.build/docs/getting-started/) — install Bub and run the first turn
- [Concepts](https://bub.build/docs/concepts/) — the mental model behind the runtime
-- [Channels](https://bub.build/docs/operate/channels/) — run Bub in CLI, Telegram, or your own channel
-- [Skills](https://bub.build/docs/build/skills/) — discover, inspect, and author Agent Skills in Bub
+- [Python SDK](https://bub.build/docs/build/sdk/) — embed Bub's agent loop in your own application
- [Build](https://bub.build/docs/build/) — write plugins, override hooks, ship tools and skills
+- [Bub Hub](https://hub.bub.build) — browse community plugins, skills, and distributions
+- [Channels](https://bub.build/docs/operate/channels/) — run Bub in CLI, Telegram, or your own channel
- [Deployment](https://bub.build/docs/operate/deploy/) — Docker, environment, upgrades
-## Development
-
-```bash
-make install
-make check
-make test
-make docs
-make docs-test
-```
+## Background
-See [CONTRIBUTING.md](https://github.com/bubbuild/bub/blob/main/CONTRIBUTING.md).
+- [Why We Rewrote Bub](https://bub.build/posts/why-rewrite-bub/)
+- [Socialized Evaluation and Agent Partnership](https://bub.build/posts/socialized-evaluation/)
+- [Context from Tape](https://tape.systems)
## License
diff --git a/pyproject.toml b/pyproject.toml
index 66b46370..a215b902 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,22 +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 = [
+ "republic>=0.6.0a1",
"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",
- "authlib>=1.7.2",
- "any-llm-sdk[anthropic]>=1.22.1",
- "rich>=14.3.4",
- "prompt-toolkit>=3.0.52",
- "python-telegram-bot>=22.7",
"loguru>=0.7.3",
- "rapidfuzz>=3.14.5",
- "aiohttp>=3.14.3",
- "httpx[socks]>=0.28.1",
"typing-extensions>=4.15.0",
]
@@ -45,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",
diff --git a/src/bub/__init__.py b/src/bub/__init__.py
index 0edf7945..58dbf274 100644
--- a/src/bub/__init__.py
+++ b/src/bub/__init__.py
@@ -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
@@ -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.framework import DEFAULT_HOME, BubFramework
-from bub.hooks import hookimpl
-from bub.tools import tool
+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",
@@ -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:
@@ -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)
diff --git a/src/bub/__main__.py b/src/bub/__main__.py
index 5494d737..e2942be4 100644
--- a/src/bub/__main__.py
+++ b/src/bub/__main__.py
@@ -1,14 +1,19 @@
-"""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",
@@ -16,6 +21,25 @@
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
@@ -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()
@@ -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()
diff --git a/src/bub/application.py b/src/bub/application.py
new file mode 100644
index 00000000..6cb3beeb
--- /dev/null
+++ b/src/bub/application.py
@@ -0,0 +1,31 @@
+"""CLI, channel work and prompt events published by the application."""
+
+from dataclasses import dataclass
+from typing import Literal
+
+from bub.core.events import Event
+
+type Outcome = Literal["succeeded", "failed", "cancelled"]
+type PromptKind = Literal["question", "permission", "auth"]
+
+
+@dataclass(frozen=True)
+class CommandChanged(Event):
+ """A CLI invocation started (None) or finished with an outcome."""
+
+ outcome: Outcome | None = None
+
+
+@dataclass(frozen=True)
+class WorkChanged(Event):
+ """A channel work snapshot, optionally including the message outcome that caused it."""
+
+ busy: bool
+ completed: Outcome | None = None
+
+
+@dataclass(frozen=True)
+class PromptChanged(Event):
+ """A modal prompt opened (kind) or closed (None)."""
+
+ kind: PromptKind | None
diff --git a/src/bub/builtin/__init__.py b/src/bub/builtin/__init__.py
index a6df24ad..6ca4c1ce 100644
--- a/src/bub/builtin/__init__.py
+++ b/src/bub/builtin/__init__.py
@@ -1,3 +1,6 @@
-from .agent import Agent
+from .agent import BubAgent
-__all__ = ["Agent"]
+Agent = BubAgent
+"""Deprecated alias of :class:`BubAgent`."""
+
+__all__ = ["Agent", "BubAgent"]
diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py
index 864b69e0..cee7d20a 100644
--- a/src/bub/builtin/agent.py
+++ b/src/bub/builtin/agent.py
@@ -1,45 +1,43 @@
-"""Runtime engine to process prompts with any-llm-sdk."""
+"""Bub's agent: the core agent configured by settings and the framework's hooks."""
from __future__ import annotations
import asyncio
-import inspect
import re
-import shlex
-import time
-from collections.abc import AsyncGenerator, AsyncIterator, Collection, Iterable
-from contextlib import AsyncExitStack, aclosing
-from dataclasses import dataclass, replace
-from datetime import UTC, datetime
-from functools import cached_property
+from collections.abc import Collection
from pathlib import Path
-from typing import Any
-from loguru import logger
-
-from bub.builtin.commands import strip_command_prefix, validate_command_prefix
-from bub.builtin.model_runner import (
- ModelRunner,
- is_context_length_error,
-)
+import bub.core as core
from bub.builtin.settings import load_settings
+from bub.core.store import AsyncTapeStore, TapeStore
+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.prompt import LegacyPrompt, UserContent, prompt_text, to_content
from bub.skills import discover_skills, render_skills_prompt
-from bub.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store
-from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
-from bub.tape import Tape
-from bub.tools import REGISTRY, Tool, ToolContext, model_tools
-from bub.tracing import Span, current_span
+from bub.streaming import AsyncStreamEvents, StreamState
from bub.turn import TurnState
from bub.utils import workspace_from_state
HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
-MAX_AUTO_HANDOFF_RETRIES = 1
+ALLOWED_SKILLS_STATE_KEY = "allowed_skills"
+
+
+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
+ 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``.
+ One instance serves any number of sessions: per-turn choices (model, allowed tools
+ and skills) live in the turn state.
+ """
-class Agent:
- """Agent that processes prompts using hooks, tools, tape, and any-llm-sdk."""
+ command_prefix: str
def __init__(
self,
@@ -50,15 +48,16 @@ def __init__(
skill_dirs: Collection[Path] | None = None,
command_prefix: str | None = None,
) -> None:
- """Create a builtin agent with instance-specific tools, skills, and storage.
+ """Create a Bub agent with instance-specific tools, skills, and storage.
Args:
framework: Configured hook runtime supplying prompts, tape context,
interception hooks, and optional shared resources.
tools: Tools available to this instance. None snapshots the global
registry; an empty collection disables tools.
- tape_store: Explicit store, preferred over the framework's active
- store. Without either, the agent uses an in-memory store.
+ tape_store: Explicit store, preferred over the framework's active store.
+ Create the agent inside ``framework.running()`` to use the latter.
+ Without either, the agent uses an in-memory store.
skill_dirs: Skill roots in precedence order. None uses project, user,
and builtin discovery; an empty collection disables discovery.
command_prefix: Non-empty prefix without whitespace. None uses the
@@ -68,54 +67,25 @@ def __init__(
lifecycle of an explicitly supplied store.
"""
self.settings = load_settings()
- self.command_prefix = validate_command_prefix(
- self.settings.command_prefix if command_prefix is None else command_prefix
- )
self.framework = framework
- self.tools = {tool.name: tool for tool in tools} if tools is not None else REGISTRY.copy()
- self.tape_store = tape_store
self.skill_dirs = skill_dirs
- self.model_runner = ModelRunner(self.settings, hooks=framework.get_agent_hooks())
-
- @cached_property
- def tape(self) -> Tape:
- """Return the lazily constructed, cached tape factory for this agent.
-
- Select the explicit store, active framework store, or an in-memory fallback,
- in that order. Adapt synchronous stores and use hook-provided context and
- sidecars. Archive files use ``bub.home / 'tapes'`` independently of the store.
- """
- import bub
-
- tape_store: TapeStore | AsyncTapeStore | None
- if self.tape_store is not None:
- tape_store = self.tape_store
- else:
- tape_store = self.framework.get_tape_store()
- if tape_store is None:
- tape_store = InMemoryTapeStore()
- if not is_async_tape_store(tape_store):
- tape_store = AsyncTapeStoreAdapter(tape_store)
- return Tape(
- bub.home / "tapes",
- tape_store,
- self.framework.build_tape_context(),
- sidecars=self.framework.get_tape_sidecars(),
+ super().__init__(
+ store=framework.get_tape_store() if tape_store is None else tape_store,
+ tools=REGISTRY.values() if tools is None else tools,
+ system_prompt=[self._hook_prompt, self._code_mode_prompt, self._deferred_tools_prompt, self._skills_prompt],
+ context=framework.build_tape_context(),
+ sidecars=framework.get_tape_sidecars(),
+ max_steps=self.settings.max_steps,
+ name="bub",
+ command_prefix=self.settings.command_prefix if command_prefix is None else command_prefix,
)
-
- @staticmethod
- def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents:
- async def generator() -> AsyncIterator:
- for item in iterable:
- yield item
-
- return AsyncStreamEvents(generator())
+ framework.get_agent_hooks().subscribe(self.events)
async def run_stream(
self,
*,
session_id: str,
- prompt: str | list[dict],
+ prompt: list[UserContent] | LegacyPrompt,
state: TurnState | None = None,
model: str | None = None,
allowed_skills: Collection[str] | None = None,
@@ -127,8 +97,9 @@ async def run_stream(
Args:
session_id: Session identity within the workspace. A ``temp/`` prefix
prevents the turn's fork from merging back into its parent tape.
- prompt: Text or multimodal content parts. Text beginning with this
- agent's command prefix after stripping whitespace invokes a command.
+ prompt: User content (text, images, audio and video); legacy text and content blocks are converted.
+ A text-only prompt beginning with this agent's command prefix after
+ stripping whitespace invokes a command.
state: Mutable turn state. None loads state through framework hooks
using this agent's store; supplied state skips that loading.
The current agent is always bound into the state.
@@ -140,482 +111,149 @@ async def run_stream(
Command execution uses the instance's tools directly.
reasoning_effort: Per-turn override of the value in state.
- Consume the stream to completion to finish execution and tape merging.
- A ``final`` event ends a model step, not necessarily the whole turn.
- The returned object exposes ``error`` and ``usage``; execution can also
- raise exceptions. This method does not render or dispatch outbound messages,
- call save-state hooks, or serialize concurrent turns in the same session.
+ Loads the turn state and starts the turn with :meth:`start` on the session's tape.
+ Consume the stream to completion to finish execution and tape merging. A
+ ``final`` event ends a model step, not necessarily the whole turn. The returned
+ object exposes ``error`` and ``usage``; execution can also raise exceptions. This
+ method does not render or dispatch outbound messages, call save-state hooks, or
+ serialize concurrent turns in the same session.
"""
- span = Span(
- "invoke_agent bub",
- {
- "gen_ai.operation.name": "invoke_agent",
- "gen_ai.agent.name": "bub",
- "gen_ai.conversation.id": session_id,
- },
- )
- span.messages("gen_ai.input.messages", [{"role": "user", "content": prompt}])
- stack = AsyncExitStack()
- try:
- with span.activate():
- if not prompt:
- events = self._events_from_iterable([
- StreamEvent("text", {"delta": "error: empty prompt"}),
- StreamEvent("final", {"text": "error: empty prompt", "ok": False}),
- ])
- else:
- if state is None:
- state = await self.framework.build_state({"_runtime_agent": self}, session_id)
- state["_runtime_agent"] = self
- if model is None:
- model = state.get("model")
- if reasoning_effort is not None:
- state["reasoning_effort"] = reasoning_effort
- state.setdefault("session_id", session_id)
- tape = self.tape.session_tape(
- session_id, workspace_from_state(state), context=replace(self.tape.context, state=state)
- )
- # Keep the tape fork open until the stream closes, even if it is never consumed.
- tape = await stack.enter_async_context(
- tape.fork_tape(merge_back=not session_id.startswith("temp/"))
- )
- await tape.ensure_bootstrap_anchor()
- command = strip_command_prefix(prompt, self.command_prefix) if isinstance(prompt, str) else None
- if command is not None:
- result = await self._run_command(tape=tape, line=command)
- events = self._events_from_iterable([
- StreamEvent("text", {"delta": result}),
- StreamEvent("final", {"text": result, "ok": True}),
- ])
- else:
- events = await self._agent_loop(
- tape=tape,
- prompt=prompt,
- model=model,
- allowed_skills=allowed_skills,
- allowed_tools=allowed_tools,
- )
- except BaseException as exc:
- span.fail(exc)
- try:
- with span.activate():
- await stack.aclose()
- finally:
- span.end()
- raise
- return AsyncStreamEvents(
- self._trace_events(events, span),
- state=events._state,
- on_close=stack.aclose,
- span=span,
- )
-
- @staticmethod
- async def _trace_events(events: AsyncStreamEvents, span: Span) -> AsyncGenerator[StreamEvent, None]:
- messages: list[dict[str, Any]] = []
- text: list[str] = []
- calls: list[dict[str, Any]] = []
- try:
- async with aclosing(events):
- async for event in events:
- if span.recording:
- if event.kind == "text":
- text.append(str(event.data.get("delta", "")))
- elif event.kind == "tool_call":
- calls = event.data.get("tool_calls", [])
- messages.append({"role": "assistant", "content": "".join(text), "tool_calls": calls})
- text.clear()
- elif event.kind == "tool_result":
- for call, result in zip(calls, event.data.get("tool_results", []), strict=False):
- messages.append({"role": "tool", "tool_call_id": call["id"], "content": result})
- elif event.kind == "error":
- span.fail(RuntimeError(str(event.data.get("message", "agent failed"))))
- yield event
- finally:
- if text:
- messages.append({"role": "assistant", "content": "".join(text)})
- span.messages("gen_ai.output.messages", messages)
-
- async def _run_command(self, tape: Tape, *, line: str) -> str:
- if not line:
- raise ValueError("empty command")
-
- name, arg_tokens = _parse_internal_command(line)
- start = time.monotonic()
- context = ToolContext(tape=tape, run_id="run_command", state=tape.context.state)
- output = ""
- status = "ok"
- try:
- if name not in self.tools:
- if "bash" not in self.tools:
- raise ValueError("bash tool is not available") # noqa: TRY301
- bash_tool = self.tools["bash"]
- output = bash_tool.render(await bash_tool.run(context=context, command=line))
- else:
- command_tool = self.tools[name]
- args = _parse_args(arg_tokens)
- if command_tool.context:
- args.kwargs["context"] = context
- result = command_tool.run(*args.positional, **args.kwargs)
- if inspect.isawaitable(result):
- result = await result
- output = command_tool.render(result)
- except Exception as exc:
- status = "error"
- output = f"{exc!s}"
- raise
- else:
- return output
- finally:
- elapsed_ms = int((time.monotonic() - start) * 1000)
-
- event_payload = {
- "raw": line,
- "name": name,
- "status": status,
- "elapsed_ms": elapsed_ms,
- "output": output,
- "date": datetime.now(UTC).isoformat(),
- }
- await tape.append_event("command", event_payload)
-
- async def _agent_loop(
- self,
- *,
- tape: Tape,
- prompt: str | list[dict],
- model: str | None = None,
- allowed_skills: Collection[str] | None = None,
- allowed_tools: Collection[str] | None = None,
- ) -> AsyncStreamEvents:
- next_prompt: str | list[dict] = prompt
- display_model = model or self.settings.model
- await tape.append_event(
- "loop.start",
- {
- "model": display_model,
- "prompt": prompt,
+ content = to_content(prompt)
+ if state is None:
+ state = await self.framework.build_state({"_runtime_agent": self}, session_id)
+ state["_runtime_agent"] = self
+ if reasoning_effort is not None:
+ state["reasoning_effort"] = reasoning_effort
+ state.setdefault("session_id", session_id)
+ metadata = None
+ if self.command_line(content) is None:
+ self._scope_turn(state, allowed_skills=allowed_skills, allowed_tools=allowed_tools)
+ metadata = {
"allowed_skills": list(allowed_skills) if allowed_skills else None,
"allowed_tools": list(allowed_tools) if allowed_tools else None,
- },
- )
- state = StreamState()
- iterator = self._stream_events_with_auto_handoff(
- tape=tape,
- prompt=next_prompt,
+ }
+ return await self.start(
+ content,
+ # The turn's model and reasoning effort apply until the next turn.
+ model=self.settings.chat_model(
+ state.get("model") if model is None else model, reasoning_effort=state.get("reasoning_effort")
+ ),
+ tape=session_tape_name(session_id, workspace_from_state(state)),
state=state,
- model=model,
- allowed_skills=allowed_skills,
- allowed_tools=allowed_tools,
- )
- return AsyncStreamEvents(iterator, state=state)
-
- async def _stream_events_with_auto_handoff(
- self,
- tape: Tape,
- prompt: str | list[dict],
- state: StreamState,
- model: str | None = None,
- allowed_skills: Collection[str] | None = None,
- allowed_tools: Collection[str] | None = None,
- ) -> AsyncGenerator[StreamEvent, None]:
- auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES
- display_model = model or self.settings.model
- next_prompt: str | list[dict] | None = prompt
- for step in range(1, self.settings.max_steps + 1):
- start = time.monotonic()
- should_continue = False
- logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model)
- await tape.append_event("loop.step.start", {"step": step, "prompt": next_prompt})
- try:
- output = await self._run_once(
- tape=tape,
- prompt=next_prompt,
- model=model,
- allowed_skills=allowed_skills,
- allowed_tools=allowed_tools,
- )
- async with aclosing(output):
- async for event in output:
- yield event
- if event.kind == "error":
- elapsed_ms = int((time.monotonic() - start) * 1000)
- await tape.append_event(
- "loop.step",
- {
- "step": step,
- "elapsed_ms": elapsed_ms,
- "status": "error",
- "error": event.data.get("message", ""),
- "date": datetime.now(UTC).isoformat(),
- },
- )
- elif event.kind == "final":
- should_continue = bool(event.data.get("tool_calls") or event.data.get("tool_results"))
- except Exception as exc:
- error_message = f"{exc!s}"
- elapsed_ms = int((time.monotonic() - start) * 1000)
- if auto_handoff_remaining > 0 and is_context_length_error(error_message):
- auto_handoff_remaining -= 1
- logger.warning(
- "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}",
- tape.name,
- step,
- )
- await tape.handoff(
- name="auto_handoff/context_overflow",
- state={"reason": "context_length_exceeded", "error": error_message},
- )
- await tape.append_event(
- "loop.step",
- {
- "step": step,
- "elapsed_ms": elapsed_ms,
- "status": "auto_handoff",
- "error": error_message,
- "date": datetime.now(UTC).isoformat(),
- },
- )
- next_prompt = prompt
- continue
-
- await tape.append_event(
- "loop.step",
- {
- "step": step,
- "elapsed_ms": elapsed_ms,
- "status": "error",
- "error": error_message,
- "date": datetime.now(UTC).isoformat(),
- },
- )
- raise
-
- state.error = output.error
- state.usage = output.usage
- elapsed_ms = int((time.monotonic() - start) * 1000)
- should_continue = should_continue or self._has_steering_messages(tape.context.state)
- if not should_continue:
- await tape.append_event(
- "loop.step",
- {
- "step": step,
- "elapsed_ms": elapsed_ms,
- "status": "ok",
- "date": datetime.now(UTC).isoformat(),
- },
- )
- return
-
- next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state)
- await tape.append_event(
- "loop.step",
- {
- "step": step,
- "elapsed_ms": elapsed_ms,
- "status": "continue",
- "date": datetime.now(UTC).isoformat(),
- },
- )
-
- raise RuntimeError(f"max_steps_reached={self.settings.max_steps}")
-
- def _load_skills_prompt(self, prompt: str, workspace: Path, allowed_skills: set[str] | None = None) -> str:
- skill_index = {
- skill.name.casefold(): skill
- for skill in discover_skills(workspace, skill_dirs=self.skill_dirs)
- if allowed_skills is None or skill.name.casefold() in allowed_skills
- }
- expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys())
- return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills)
-
- async def _run_once(
- self,
- *,
- tape: Tape,
- prompt: str | list[dict] | None,
- model: str | None = None,
- allowed_tools: Collection[str] | None = None,
- allowed_skills: Collection[str] | None = None,
- ) -> AsyncStreamEvents:
- if isinstance(prompt, str):
- prompt_text = prompt
- elif prompt is None:
- prompt_text = ""
- else:
- prompt_text = _extract_text_from_parts(prompt)
- if allowed_skills is not None:
- allowed_skills = {name.casefold() for name in allowed_skills}
- tape.context.state["allowed_skills"] = list(allowed_skills)
- return await self._run_once_stream(
- tape=tape,
- prompt=prompt,
- prompt_text=prompt_text,
- model=model,
- allowed_skills=allowed_skills,
- tools=self._allowed_tools(tape, allowed_tools),
+ merge_back=not session_id.startswith("temp/"),
+ metadata=metadata,
)
- def _allowed_tools(self, tape: Tape, allowed_tools: Collection[str] | None) -> list[Tool]:
- """Filter direct and deferred tools alike by ``allowed_tools`` and record the scope in state."""
+ def _scope_turn(
+ self, state: TurnState, *, allowed_skills: Collection[str] | None, allowed_tools: Collection[str] | None
+ ) -> None:
+ """Record the turn's allowed skills and tools in state; unknown tool names raise ``ValueError``."""
from bub.builtin.tools import ALLOWED_TOOLS_STATE_KEY, resolve_tool_names
- state = tape.context.state
if allowed_tools is None:
state.pop(ALLOWED_TOOLS_STATE_KEY, None)
- return list(self.tools.values())
- allowed = resolve_tool_names(allowed_tools, all_names=self.tools)
- state[ALLOWED_TOOLS_STATE_KEY] = sorted(allowed)
- return [tool for name, tool in self.tools.items() if name in allowed]
+ else:
+ state[ALLOWED_TOOLS_STATE_KEY] = sorted(resolve_tool_names(allowed_tools, all_names=self.tools))
+ if allowed_skills is None:
+ state.pop(ALLOWED_SKILLS_STATE_KEY, None)
+ else:
+ state[ALLOWED_SKILLS_STATE_KEY] = list({name.casefold() for name in allowed_skills})
- async def _prepare_deferred_tools(self, tools: list[Tool], tape: Tape) -> tuple[list[Tool], str]:
- """Return direct tools followed by deferred tools loaded on the tape, plus the deferred tools prompt.
+ async def prepare_tools(self, tape: Tape, prompt: list[UserContent] | None) -> list[Tool]:
+ from bub.builtin.codemode import CODE_TOOLS_STATE_KEY
+ from bub.builtin.tools import TOOL_DESCRIBE_TOOL_NAME, loaded_tool_names, tool_describe
- ``tool.describe`` is exposed exactly when a deferred tool remains, since it is the only way to load one.
- """
- from bub.builtin.tools import (
- TOOL_DESCRIBE_TOOL_NAME,
- loaded_tool_names,
- render_deferred_tools_prompt,
- tool_describe,
- )
-
- direct = [tool for tool in tools if not tool.deferred and tool.name != TOOL_DESCRIBE_TOOL_NAME]
- deferred = {tool.name: tool for tool in tools if tool.deferred and tool.exposure in ("auto", "direct")}
- if not deferred:
- return direct, ""
- direct.append(self.tools.get(TOOL_DESCRIBE_TOOL_NAME, tool_describe))
- loaded = [deferred[name] for name in await loaded_tool_names(tape) if name in deferred]
- return [*direct, *loaded], render_deferred_tools_prompt(deferred.values())
-
- async def _run_once_stream(
- self,
- *,
- tape: Tape,
- prompt: str | list[dict] | None,
- prompt_text: str,
- model: str | None,
- allowed_skills: set[str] | None,
- tools: list[Tool],
- ) -> AsyncStreamEvents:
+ state = tape.context.state
# Code mode sees every allowed tool, deferred ones included; deferral applies to model-facing tools.
- tools, code_mode_prompt = await self._prepare_code_mode(tools, tape)
- tools, deferred_prompt = await self._prepare_deferred_tools(tools, tape)
- system_prompt = self._system_prompt(
- prompt_text,
- state=tape.context.state,
- allowed_skills=allowed_skills,
- tools_prompt="\n\n".join(block for block in (code_mode_prompt, deferred_prompt) if block),
- )
- resolved_model = model or self.settings.model
-
- model_tools_for_call = model_tools(tools)
- if (span := current_span()) and span.recording:
- span.set(**{
- "gen_ai.tool.definitions": [
- tool.to_schema()["function"] | {"type": "function"} for tool in model_tools_for_call
- ]
- })
+ tools, code_tools = self._split_code_mode(state)
+ if code_tools is None:
+ state.pop(CODE_TOOLS_STATE_KEY, None)
+ else:
+ state[CODE_TOOLS_STATE_KEY] = model_tools(code_tools)
+ # ``tool.describe`` is exposed exactly when a deferred tool remains, since it is the only way to load one.
+ direct = [tool for tool in tools if not tool.deferred and tool.name != TOOL_DESCRIBE_TOOL_NAME]
+ deferred = {tool.name: tool for tool in _deferred_tools(tools)}
+ if deferred:
+ direct.append(self.tools.get(TOOL_DESCRIBE_TOOL_NAME, tool_describe))
+ direct.extend(deferred[name] for name in await loaded_tool_names(tape) if name in deferred)
+ return model_tools(direct)
+
+ async def pending_messages(self, tape: Tape) -> list[list[UserContent]]:
+ state = tape.context.state
steering_inbox = self.framework.get_steering_inbox()
- steering_envelopes = await steering_inbox.drain_messages(tape.context.state) if steering_inbox else []
- steering_messages = list(
+ envelopes = await steering_inbox.drain_messages(state) if steering_inbox else []
+ return list(
await asyncio.gather(*[
- self.framework.build_prompt(
- message, session_id=field_of(message, "session_id"), state=tape.context.state
- )
- for message in steering_envelopes
+ self.framework.build_prompt(message, session_id=field_of(message, "session_id"), state=state)
+ for message in envelopes
])
)
- return self.model_runner.run(
- tape=tape,
- model=resolved_model,
- tools=model_tools_for_call,
- system_prompt=system_prompt,
- prompt=prompt,
- steering_messages=steering_messages,
- )
- async def _prepare_code_mode(self, tools: list[Tool], tape: Tape) -> tuple[list[Tool], str]:
- """Split tools for code mode and return model-facing tools and the stub prompt.
+ def has_pending_messages(self, tape: Tape) -> bool:
+ steering_inbox = self.framework.get_steering_inbox()
+ return bool(steering_inbox and steering_inbox.message_count(tape.context.state) > 0)
+
+ async def continue_prompt(
+ self, tape: Tape, prompt: list[UserContent] | None, state: StreamState
+ ) -> list[UserContent] | None:
+ continuation = await self.framework.continue_prompt(prompt=prompt, tape=tape, state=state)
+ return None if continuation is None else [continuation]
+
+ def _turn_tools(self, state: TurnState) -> list[Tool]:
+ """Return the tools allowed in this turn, direct and deferred alike."""
+ from bub.builtin.tools import ALLOWED_TOOLS_STATE_KEY
+
+ if (allowed := state.get(ALLOWED_TOOLS_STATE_KEY)) is None:
+ return list(self.tools.values())
+ return [tool for name, tool in self.tools.items() if name in allowed]
+
+ def _split_code_mode(self, state: TurnState) -> tuple[list[Tool], list[Tool] | None]:
+ """Return the tools left to the model and, in code mode, the tools callable only from code.
Code mode is a session setting (``state["code_mode"]``, switched by the ``code_mode``
command) and applies only when ``run_code`` is among the allowed tools: the model then
sees ``direct`` tools, and ``auto`` and ``code`` tools are callable only from code. Outside
code mode, ``code`` tools are dropped.
"""
- from bub.builtin.codemode import (
- CODE_MODE_STATE_KEY,
- CODE_TOOLS_STATE_KEY,
- RUN_CODE_TOOL_NAME,
- render_code_mode_prompt,
- write_tool_stub,
- )
+ from bub.builtin.codemode import CODE_MODE_STATE_KEY, RUN_CODE_TOOL_NAME
- state = tape.context.state
+ tools = self._turn_tools(state)
direct_tools = [tool for tool in tools if tool.name != RUN_CODE_TOOL_NAME]
if not state.get(CODE_MODE_STATE_KEY) or len(direct_tools) == len(tools):
- state.pop(CODE_TOOLS_STATE_KEY, None)
- return [tool for tool in direct_tools if tool.exposure != "code"], ""
-
+ return [tool for tool in direct_tools if tool.exposure != "code"], None
code_tools = [tool for tool in direct_tools if tool.exposure in ("auto", "code")]
- state[CODE_TOOLS_STATE_KEY] = model_tools(code_tools)
- stub_path = write_tool_stub(
- code_tools, session_id=str(state.get("session_id", "")), workspace=workspace_from_state(state)
+ return [tool for tool in tools if tool.exposure == "direct"], code_tools
+
+ def _hook_prompt(self, context: core.PromptContext) -> str:
+ return self.framework.get_system_prompt(prompt=context.prompt, state=context.state)
+
+ @staticmethod
+ def _code_mode_prompt(context: core.PromptContext) -> str | None:
+ from bub.builtin.codemode import CODE_MODE
+
+ return CODE_MODE.prompt(context)
+
+ def _deferred_tools_prompt(self, context: core.PromptContext) -> str:
+ from bub.builtin.tools import render_deferred_tools_prompt
+
+ tools, _ = self._split_code_mode(context.state)
+ return render_deferred_tools_prompt(_deferred_tools(tools))
+
+ def _skills_prompt(self, context: core.PromptContext) -> str:
+ allowed = context.state.get(ALLOWED_SKILLS_STATE_KEY)
+ return self._load_skills_prompt(
+ prompt_text(context.prompt), workspace_from_state(context.state), None if allowed is None else set(allowed)
)
- return [tool for tool in tools if tool.exposure == "direct"], render_code_mode_prompt(stub_path)
- def _system_prompt(
- self,
- prompt: str,
- state: TurnState,
- allowed_skills: set[str] | None = None,
- tools_prompt: str = "",
- ) -> str:
- blocks: list[str] = []
- if result := self.framework.get_system_prompt(prompt=prompt, state=state):
- blocks.append(result)
- if tools_prompt:
- blocks.append(tools_prompt)
- workspace = workspace_from_state(state)
- if skills_prompt := self._load_skills_prompt(prompt, workspace, allowed_skills):
- blocks.append(skills_prompt)
- return "\n\n".join(blocks)
-
- def _has_steering_messages(self, state: TurnState) -> bool:
- steering_inbox = self.framework.get_steering_inbox()
- return bool(steering_inbox and steering_inbox.message_count(state) > 0)
-
-
-@dataclass(frozen=True)
-class Args:
- positional: list[str]
- kwargs: dict[str, Any]
-
-
-def _parse_internal_command(line: str) -> tuple[str, list[str]]:
- body = line.strip()
- words = shlex.split(body)
- if not words:
- return "", []
- return words[0], words[1:]
-
-
-def _parse_args(args_tokens: list[str]) -> Args:
- positional: list[str] = []
- kwargs: dict[str, str] = {}
- first_kwarg = False
- for token in args_tokens:
- if "=" in token:
- key, value = token.split("=", 1)
- kwargs[key] = value
- first_kwarg = True
- elif first_kwarg:
- raise ValueError(f"positional argument '{token}' cannot appear after keyword arguments")
- else:
- positional.append(token)
- return Args(positional=positional, kwargs=kwargs)
+ def _load_skills_prompt(self, prompt: str, workspace: Path, allowed_skills: set[str] | None = None) -> str:
+ skill_index = {
+ skill.name.casefold(): skill
+ for skill in discover_skills(workspace, skill_dirs=self.skill_dirs)
+ if allowed_skills is None or skill.name.casefold() in allowed_skills
+ }
+ expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys())
+ return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills)
-def _extract_text_from_parts(parts: list[dict]) -> str:
- """Extract text content from multimodal content parts."""
- return "\n".join(p.get("text", "") for p in parts if p.get("type") == "text")
+def _deferred_tools(tools: list[Tool]) -> list[Tool]:
+ return [tool for tool in tools if tool.deferred and tool.exposure in ("auto", "direct")]
diff --git a/src/bub/builtin/archive.py b/src/bub/builtin/archive.py
new file mode 100644
index 00000000..9e190514
--- /dev/null
+++ b/src/bub/builtin/archive.py
@@ -0,0 +1,85 @@
+"""Archive tapes to JSONL files before they are reset."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict
+from datetime import UTC, datetime
+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
+
+
+def archive_dir() -> Path:
+ """Bub's archive directory, ``bub.home / "tapes"``."""
+ import bub
+
+ return bub.home / "tapes"
+
+
+def _stamp() -> str:
+ return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
+
+
+async def _write_archive(tape: Tape, tape_name: str, directory: Path, stamp: str) -> Path:
+ directory.mkdir(parents=True, exist_ok=True)
+ path = directory / f"{tape_name}.jsonl.{stamp}.bak"
+ with path.open("w", encoding="utf-8") as f:
+ for entry in await tape.store.fetch_all(TapeQuery(tape=tape_name, store=tape.store)):
+ f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n")
+ return path
+
+
+async def archive_tape(tape: Tape, directory: Path, *, stamp: str | None = None) -> Path:
+ """Write the tape's entries to ``{directory}/{tape name}.jsonl.{stamp}.bak`` and return the path."""
+ return await _write_archive(tape, tape.name, directory, stamp or _stamp())
+
+
+async def archive_sidecar(
+ tape: Tape, name: str, directory: Path, *, reason: str = "manual", stamp: str | None = None
+) -> tuple[Path | None, dict[str, Any]]:
+ """Archive one mounted sidecar of ``tape``; return the archive path and ``sidecar.archive`` event data.
+
+ A failed archive returns no path and error data instead of raising.
+ """
+ if tape.get_sidecar(name) is None:
+ raise KeyError(f"tape sidecar {name!r} is not mounted")
+ data: dict[str, Any] = {"sidecar": name, "reason": reason}
+ try:
+ path = await _write_archive(tape, sidecar_tape_name(tape.name, name), directory, stamp or _stamp())
+ except Exception as exc:
+ return None, {**data, "status": "error", "error": str(exc)}
+ return path, {**data, "status": "ok", "archive": str(path)}
+
+
+async def reset_tape(tape: Tape, *, archive_to: Path | None = None) -> str:
+ """Reset ``tape``, first archiving it and its sidecars to ``archive_to`` when given.
+
+ A sidecar whose archive fails is kept rather than reset; archive outcomes are recorded
+ as ``sidecar.archive`` events after the reset.
+ """
+ if archive_to is None:
+ await tape.reset()
+ return "ok"
+ stamp = _stamp()
+ path = await archive_tape(tape, archive_to, stamp=stamp)
+ archives = [
+ await archive_sidecar(tape, sidecar.name, archive_to, reason="tape.reset", stamp=stamp)
+ for sidecar in tape.sidecars
+ ]
+ failed = [data["sidecar"] for archived, data in archives if archived is None]
+ await tape.reset(state={"archived": str(path)}, keep_sidecars=failed)
+ for _, data in archives:
+ await tape.append_event("sidecar.archive", data, context=False)
+ if data["status"] == "error":
+ skipped = {
+ "sidecar": data["sidecar"],
+ "status": "skipped",
+ "reason": "tape.reset",
+ "cause": "archive_failed",
+ }
+ await tape.append_event("sidecar.reset", skipped, context=False)
+ return f"Archived: {path}"
diff --git a/src/bub/builtin/auth.py b/src/bub/builtin/auth.py
index 3700c83d..63b7a0ff 100644
--- a/src/bub/builtin/auth.py
+++ b/src/bub/builtin/auth.py
@@ -1,537 +1,50 @@
-"""Authentication helpers for builtin providers."""
+"""CLI entry points for Republic-managed account authentication."""
-# ruff: noqa: B008
from __future__ import annotations
-import json
-import math
+import asyncio
import os
-import secrets
-import threading
-import time
-import urllib.parse
-import webbrowser
-from base64 import urlsafe_b64decode, urlsafe_b64encode
-from collections.abc import Callable
-from contextlib import suppress
-from dataclasses import dataclass
-from datetime import UTC, datetime
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import subprocess
from pathlib import Path
-from typing import Any
+from typing import Annotated
import typer
-from authlib.integrations.httpx_client import OAuth2Client
-from loguru import logger
+from republic.errors import AuthenticationError
+from republic.providers import CodexAuth
-CODEX_PROVIDER = "openai"
-DEFAULT_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback"
+app = typer.Typer(name="login", help="Account authentication")
-_CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
-_CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105
-_CODEX_OAUTH_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
-_CODEX_OAUTH_SCOPE = "openid profile email offline_access"
-_CODEX_OAUTH_ORIGINATOR = "codex_cli_rs"
-app = typer.Typer(name="login", help="Authentication related commands")
+@app.callback()
+def login() -> None:
+ """Authenticate a provider account."""
-class CodexOAuthLoginError(RuntimeError):
- """Raised when Codex OAuth login cannot complete."""
-
-
-class CodexOAuthStateMismatchError(CodexOAuthLoginError):
- """Raised when OAuth state validation fails."""
-
-
-class CodexOAuthMissingCodeError(CodexOAuthLoginError):
- """Raised when OAuth redirect does not include an authorization code."""
-
-
-class CodexOAuthResponseError(TypeError):
- """Raised when the OAuth token endpoint returns a malformed payload."""
-
-
-class CodexOAuthRefreshError(RuntimeError):
- """Raised when an expired Codex OAuth token cannot be refreshed."""
-
-
-@dataclass(frozen=True)
-class OpenAICodexOAuthTokens:
- access_token: str
- refresh_token: str
- expires_at: int | None
- account_id: str | None = None
-
-
-def resolve_codex_home(codex_home: str | Path | None = None) -> Path:
- if codex_home is not None:
- return Path(codex_home).expanduser()
- return Path(os.getenv("CODEX_HOME", "~/.codex")).expanduser()
-
-
-def resolve_codex_auth_path(codex_home: str | Path | None = None) -> Path:
- return resolve_codex_home(codex_home) / "auth.json"
-
-
-def load_openai_codex_oauth_tokens(codex_home: str | Path | None = None) -> OpenAICodexOAuthTokens | None:
- auth_path = resolve_codex_auth_path(codex_home)
- try:
- payload = json.loads(auth_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
- return None
- if not isinstance(payload, dict):
- return None
- return _parse_tokens(payload)
-
-
-def save_openai_codex_oauth_tokens(
- tokens: OpenAICodexOAuthTokens,
- codex_home: str | Path | None = None,
-) -> Path:
- auth_path = resolve_codex_auth_path(codex_home)
- auth_path.parent.mkdir(parents=True, exist_ok=True)
-
- try:
- existing = json.loads(auth_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
- existing = {}
- payload: dict[str, Any] = existing if isinstance(existing, dict) else {}
-
- token_payload = payload.get("tokens")
- if not isinstance(token_payload, dict):
- token_payload = {}
- token_payload.update({
- "access_token": tokens.access_token,
- "refresh_token": tokens.refresh_token,
- })
- if tokens.expires_at is None:
- token_payload.pop("expires_at", None)
- else:
- token_payload["expires_at"] = _unix_to_rfc3339(tokens.expires_at)
- if tokens.account_id:
- token_payload["account_id"] = tokens.account_id
- payload["tokens"] = token_payload
- payload["last_refresh"] = _unix_to_rfc3339(int(time.time()))
-
- auth_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
- with suppress(OSError):
- os.chmod(auth_path, 0o600)
- return auth_path
-
-
-def refresh_openai_codex_oauth_tokens(
- refresh_token: str,
- *,
- timeout_seconds: float = 15.0,
- client_id: str = _CODEX_OAUTH_CLIENT_ID,
- token_url: str = _CODEX_OAUTH_TOKEN_URL,
-) -> OpenAICodexOAuthTokens:
- with OAuth2Client(client_id=client_id, timeout=timeout_seconds, trust_env=True) as oauth:
- payload = oauth.refresh_token(url=token_url, refresh_token=refresh_token)
- return _tokens_from_token_payload(payload, account_id=None)
-
-
-def openai_codex_oauth_resolver(
- codex_home: str | Path | None = None,
- *,
- refresh_skew_seconds: int = 120,
- refresh_timeout_seconds: float = 15.0,
- refresher: Callable[[str], OpenAICodexOAuthTokens] | None = None,
-) -> Callable[[str], str | None]:
- """Build a provider-scoped OAuth token resolver with refresh support."""
-
- lock = threading.Lock()
- if refresher is None:
- refresher = lambda refresh_token: refresh_openai_codex_oauth_tokens(
- refresh_token,
- timeout_seconds=refresh_timeout_seconds,
- )
-
- def _resolve(provider: str) -> str | None:
- if provider != CODEX_PROVIDER:
- return None
- with lock:
- tokens = load_openai_codex_oauth_tokens(codex_home)
- if tokens is None:
- return None
-
- now = int(time.time())
- # Unknown expiry is not evidence that the token needs refreshing.
- if tokens.expires_at is None or tokens.expires_at > now + refresh_skew_seconds:
- return tokens.access_token
-
+@app.command("codex")
+def login_codex(
+ executable: Annotated[str, typer.Option("--executable", help="Codex CLI executable")] = "codex",
+ device_auth: Annotated[bool, typer.Option("--device-auth", help="Use device authorization")] = False,
+ codex_home: Annotated[Path | None, typer.Option("--codex-home", help="Codex credential directory")] = None,
+) -> None:
+ """Log in with the Codex CLI using file credential storage."""
+ try:
+ if codex_home is None:
+ auth = asyncio.run(CodexAuth.login(executable=executable, device_auth=device_auth))
+ else:
+ home = codex_home.expanduser().resolve()
+ command = [executable, "login", "--config", 'cli_auth_credentials_store="file"']
+ if device_auth:
+ command.append("--device-auth")
try:
- refreshed = refresher(tokens.refresh_token)
- except Exception as exc:
- logger.warning("Codex OAuth token refresh failed: {}", type(exc).__name__)
- if tokens.expires_at > time.time():
- return tokens.access_token
- raise CodexOAuthRefreshError(
- f"Codex OAuth token refresh failed ({type(exc).__name__}). "
- "Check your connection or sign in again with `bub login openai`."
- ) from None
-
- persisted = OpenAICodexOAuthTokens(
- access_token=refreshed.access_token,
- refresh_token=refreshed.refresh_token,
- expires_at=refreshed.expires_at,
- account_id=refreshed.account_id or tokens.account_id,
- )
- save_openai_codex_oauth_tokens(persisted, codex_home)
- return persisted.access_token
-
- return _resolve
-
-
-def login_openai_codex_oauth(
- *,
- codex_home: str | Path | None = None,
- prompt_for_redirect: Callable[[str], str] | None = None,
- open_browser: bool = True,
- browser_opener: Callable[[str], Any] | None = None,
- redirect_uri: str = DEFAULT_CODEX_REDIRECT_URI,
- timeout_seconds: float = 300.0,
- client_id: str = _CODEX_OAUTH_CLIENT_ID,
- authorize_url: str = _CODEX_OAUTH_AUTHORIZE_URL,
- token_url: str = _CODEX_OAUTH_TOKEN_URL,
- scope: str = _CODEX_OAUTH_SCOPE,
- originator: str = _CODEX_OAUTH_ORIGINATOR,
-) -> OpenAICodexOAuthTokens:
- """Run the OpenAI Codex OAuth flow and persist tokens."""
-
- verifier = _build_pkce_verifier()
- state = secrets.token_hex(16)
- oauth_url = _build_authorize_url(
- client_id=client_id,
- redirect_uri=redirect_uri,
- code_challenge=verifier,
- state=state,
- authorize_url=authorize_url,
- scope=scope,
- originator=originator,
- )
-
- if open_browser:
- opener = browser_opener or webbrowser.open
- opener(oauth_url)
-
- if prompt_for_redirect is None:
- callback_values = _wait_for_local_oauth_callback(redirect_uri=redirect_uri, timeout_seconds=timeout_seconds)
- if callback_values is None:
- raise CodexOAuthLoginError(
- "Did not receive OAuth callback. "
- f"redirect_uri={redirect_uri!r}, timeout_seconds={timeout_seconds}. "
- "Try increasing --timeout or use --manual."
- )
- code, returned_state = callback_values
- else:
- code, returned_state = _extract_code_and_state(prompt_for_redirect(oauth_url))
-
- if returned_state and returned_state != state:
- raise CodexOAuthStateMismatchError
- if not isinstance(code, str) or not code.strip():
- raise CodexOAuthMissingCodeError
-
- tokens = _exchange_openai_codex_authorization_code(
- code=code.strip(),
- verifier=verifier,
- redirect_uri=redirect_uri,
- timeout_seconds=timeout_seconds,
- client_id=client_id,
- token_url=token_url,
- )
- save_openai_codex_oauth_tokens(tokens, codex_home)
- return tokens
-
-
-def _prompt_for_codex_redirect(authorize_url: str) -> str:
- typer.echo("Open this URL in your browser and complete the Codex sign-in flow:\n")
- typer.echo(authorize_url)
- typer.echo("\nPaste the full callback URL or the authorization code.")
- return str(typer.prompt("callback")).strip()
-
-
-def _render_codex_login_result(tokens: OpenAICodexOAuthTokens, auth_path: Path) -> None:
+ subprocess.run(command, env={**os.environ, "CODEX_HOME": str(home)}, check=True)
+ except OSError:
+ raise AuthenticationError(f"Cannot start {executable}; install the Codex CLI.") from None
+ except subprocess.CalledProcessError as exc:
+ raise AuthenticationError(f"Codex login failed (exit {exc.returncode})") from None
+ auth = CodexAuth.from_file(home / "auth.json")
+ except AuthenticationError as exc:
+ typer.secho(f"login: failed: {exc}", fg="red", err=True)
+ raise typer.Exit(1) from None
typer.echo("login: ok")
- typer.echo(f"account_id: {tokens.account_id or '-'}")
- typer.echo(f"auth_file: {auth_path}")
- typer.echo("usage: set BUB_MODEL=openai: and omit BUB_API_KEY")
-
-
-@app.command()
-def openai(
- codex_home: Path | None = typer.Option(None, "--codex-home", help="Directory to store Codex OAuth credentials"),
- open_browser: bool = typer.Option(True, "--browser/--no-browser", help="Open the OAuth URL in a browser"),
- manual: bool = typer.Option(
- False,
- "--manual",
- help="Paste the callback URL or code instead of waiting for a local callback server",
- ),
- timeout_seconds: float = typer.Option(300.0, "--timeout", help="OAuth wait timeout in seconds"),
-) -> None:
- """Login with OpenAI OAuth."""
-
- resolved_codex_home = resolve_codex_home(codex_home)
- prompt_for_redirect = _prompt_for_codex_redirect if manual or not open_browser else None
-
- try:
- tokens = login_openai_codex_oauth(
- codex_home=resolved_codex_home,
- prompt_for_redirect=prompt_for_redirect,
- open_browser=open_browser,
- redirect_uri=DEFAULT_CODEX_REDIRECT_URI,
- timeout_seconds=timeout_seconds,
- )
- except CodexOAuthLoginError as exc:
- typer.echo(f"Codex login failed: {exc}", err=True)
- raise typer.Exit(1) from exc
-
- _render_codex_login_result(tokens, resolved_codex_home / "auth.json")
-
-
-def extract_openai_codex_account_id(access_token: str) -> str | None:
- payload = _decode_unverified_jwt_payload(access_token)
- if payload is None:
- return None
- auth = payload.get("https://api.openai.com/auth")
- if not isinstance(auth, dict):
- return None
- account_id = auth.get("chatgpt_account_id")
- if not isinstance(account_id, str):
- return None
- return account_id.strip() or None
-
-
-def _decode_unverified_jwt_payload(access_token: str) -> dict[str, Any] | None:
- """Read local JWT claims without treating them as verified credentials."""
-
- parts = access_token.split(".")
- if len(parts) != 3:
- return None
- payload_segment = parts[1]
- padding = "=" * (-len(payload_segment) % 4)
- try:
- payload = json.loads(urlsafe_b64decode((payload_segment + padding).encode("ascii")).decode("utf-8"))
- except (UnicodeError, ValueError):
- return None
- return payload if isinstance(payload, dict) else None
-
-
-def _jwt_expiry(access_token: str) -> int | None:
- payload = _decode_unverified_jwt_payload(access_token)
- if payload is None:
- return None
- exp = payload.get("exp")
- if type(exp) is int:
- return exp
- if type(exp) is float and math.isfinite(exp):
- return int(exp)
- return None
-
-
-def _parse_tokens(payload: dict[str, Any]) -> OpenAICodexOAuthTokens | None:
- tokens = payload.get("tokens")
- if not isinstance(tokens, dict):
- return None
-
- access_token = tokens.get("access_token")
- refresh_token = tokens.get("refresh_token")
- if not isinstance(access_token, str) or not isinstance(refresh_token, str):
- return None
- access = access_token.strip()
- refresh = refresh_token.strip()
- if not access or not refresh:
- return None
-
- expires_at = _parse_expiry(tokens.get("expires_at"), access)
- account_id = tokens.get("account_id")
- return OpenAICodexOAuthTokens(
- access_token=access,
- refresh_token=refresh,
- expires_at=expires_at,
- account_id=account_id if isinstance(account_id, str) else None,
- )
-
-
-def _parse_expiry(expires_raw: object, access_token: str) -> int | None:
- try:
- if isinstance(expires_raw, str):
- if (expires_at := _rfc3339_to_unix(expires_raw)) is not None:
- return expires_at
- elif isinstance(expires_raw, int | float) and not isinstance(expires_raw, bool):
- return int(expires_raw)
- except (ValueError, OverflowError):
- pass
- return _jwt_expiry(access_token)
-
-
-def _tokens_from_token_payload(payload: dict[str, Any], *, account_id: str | None) -> OpenAICodexOAuthTokens:
- access_token = payload.get("access_token")
- refresh_token = payload.get("refresh_token")
- expires_in = payload.get("expires_in")
- if not isinstance(access_token, str) or not isinstance(refresh_token, str):
- raise CodexOAuthResponseError
- if not isinstance(expires_in, int | float):
- raise CodexOAuthResponseError
-
- access = access_token.strip()
- return OpenAICodexOAuthTokens(
- access_token=access,
- refresh_token=refresh_token.strip(),
- expires_at=int(time.time() + float(expires_in)),
- account_id=account_id or extract_openai_codex_account_id(access),
- )
-
-
-def _exchange_openai_codex_authorization_code(
- code: str,
- *,
- verifier: str,
- redirect_uri: str,
- timeout_seconds: float,
- client_id: str,
- token_url: str,
-) -> OpenAICodexOAuthTokens:
- with OAuth2Client(
- client_id=client_id,
- redirect_uri=redirect_uri,
- code_challenge_method="S256",
- timeout=timeout_seconds,
- ) as oauth:
- payload = oauth.fetch_token(
- url=token_url,
- grant_type="authorization_code",
- code=code,
- code_verifier=verifier,
- )
- return _tokens_from_token_payload(
- payload,
- account_id=extract_openai_codex_account_id(str(payload.get("access_token", ""))),
- )
-
-
-def _build_pkce_verifier() -> str:
- return urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=")
-
-
-def _build_authorize_url(
- *,
- client_id: str,
- redirect_uri: str,
- code_challenge: str,
- state: str,
- authorize_url: str,
- scope: str,
- originator: str,
-) -> str:
- with OAuth2Client(
- client_id=client_id,
- redirect_uri=redirect_uri,
- scope=scope,
- code_challenge_method="S256",
- trust_env=True,
- ) as oauth:
- url, _ = oauth.create_authorization_url(
- authorize_url,
- state=state,
- code_verifier=code_challenge,
- id_token_add_organizations="true", # noqa: S106
- codex_cli_simplified_flow="true",
- originator=originator,
- )
- return str(url)
-
-
-def _extract_code_and_state(input_value: str) -> tuple[str | None, str | None]:
- raw = input_value.strip()
- if not raw:
- return None, None
-
- parsed = urllib.parse.urlsplit(raw)
- query = urllib.parse.parse_qs(parsed.query)
- code = query.get("code", [None])[0]
- state = query.get("state", [None])[0]
- if isinstance(code, str) or isinstance(state, str):
- return code if isinstance(code, str) else None, state if isinstance(state, str) else None
-
- if "code=" in raw:
- parsed_query = urllib.parse.parse_qs(raw)
- code = parsed_query.get("code", [None])[0]
- state = parsed_query.get("state", [None])[0]
- return code if isinstance(code, str) else None, state if isinstance(state, str) else None
-
- return raw, None
-
-
-def _wait_for_local_oauth_callback(
- *, redirect_uri: str, timeout_seconds: float
-) -> tuple[str | None, str | None] | None:
- parsed_redirect = urllib.parse.urlsplit(redirect_uri)
- if parsed_redirect.scheme != "http" or (parsed_redirect.hostname or "").lower() not in {"127.0.0.1", "localhost"}:
- return None
- if parsed_redirect.port is None:
- return None
-
- path = parsed_redirect.path or "/"
- state: dict[str, str | None] = {"code": None, "state": None}
- done = threading.Event()
- lock = threading.Lock()
-
- class Handler(BaseHTTPRequestHandler):
- def log_message(self, format: str, *args: Any) -> None: # noqa: A002
- return
-
- def do_GET(self) -> None:
- parsed = urllib.parse.urlsplit(self.path)
- if parsed.path != path:
- self.send_response(404)
- self.end_headers()
- return
-
- query = urllib.parse.parse_qs(parsed.query)
- with lock:
- code = query.get("code", [None])[0]
- returned_state = query.get("state", [None])[0]
- state["code"] = code if isinstance(code, str) else None
- state["state"] = returned_state if isinstance(returned_state, str) else None
- done.set()
-
- body = (
- b"Authentication successful. Return to your terminal.
"
- )
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- try:
- server = ThreadingHTTPServer((parsed_redirect.hostname or "localhost", parsed_redirect.port), Handler)
- except OSError:
- return None
-
- server.timeout = 0.2
- deadline = time.monotonic() + timeout_seconds
- try:
- while not done.is_set() and time.monotonic() < deadline:
- server.handle_request()
- finally:
- server.server_close()
-
- if not done.is_set():
- return None
- with lock:
- return state["code"], state["state"]
-
-
-def _unix_to_rfc3339(timestamp: int) -> str:
- return datetime.fromtimestamp(timestamp, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
-
-
-def _rfc3339_to_unix(value: str) -> int | None:
- try:
- parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
- return int(parsed.timestamp()) if parsed.tzinfo is not None else None
- except (ValueError, OverflowError, OSError):
- return None
+ typer.echo(f"account_id: {auth.account_id}")
+ typer.echo("Use BUB_MODEL=codex: for ChatGPT plan access.")
diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py
index 5ab0058e..10fff3f8 100644
--- a/src/bub/builtin/cli.py
+++ b/src/bub/builtin/cli.py
@@ -173,7 +173,7 @@ def onboard(
enabled_channels = str(config_data.get("enabled_channels", "")).strip()
if not enabled_channels or not is_gateway_service_supported():
return
- if not bub_inquirer.ask_confirm("Install gateway as a background service?", default=False):
+ if not bub_inquirer.ask_confirm("Install gateway as a background service?", default=False, kind="permission"):
return
try:
@@ -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(
diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py
index d9038796..921bd97e 100644
--- a/src/bub/builtin/codemode/__init__.py
+++ b/src/bub/builtin/codemode/__init__.py
@@ -1,302 +1,93 @@
-"""Code mode: expose tools to model-written Python through a generated stub and ``run_code``."""
+"""Bub's code mode: :class:`bub.core.codemode.CodeMode` wired to session settings, environments and models.
+
+``BubAgent`` chooses the code-callable tools of each turn and stores them in the turn
+state under ``CODE_TOOLS_STATE_KEY``; the ``code_mode`` command switches it per session.
+"""
from __future__ import annotations
-import asyncio
import hashlib
-import keyword
-import re
-import uuid
-from collections.abc import Iterable
-from dataclasses import replace
+from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
from bub.builtin.environment import environment_from_state
from bub.builtin.settings import set_session_setting
-from bub.environment import CodeFailed
-from bub.errors import BubError, ErrorKind
-from bub.hooks.interception import AgentHooks
-from bub.tools import Tool, ToolContext, ToolExecutor, model_tools, tool
+from bub.core.codemode import (
+ DEFAULT_RUN_CODE_TIMEOUT_SECONDS,
+ MEDIA_TOOLS,
+ RUN_CODE_TOOL_NAME,
+ CodeMode,
+ attach_audio,
+ attach_image,
+ attach_video,
+ render_code_mode_prompt,
+ render_tool_stub,
+ write_stub,
+)
+from bub.core.tools import REGISTRY, Tool, ToolContext, tool
+from bub.turn import TurnState
+from bub.utils import workspace_from_state
+
+__all__ = [
+ "CODE_MODE",
+ "CODE_MODE_STATE_KEY",
+ "CODE_TOOLS_STATE_KEY",
+ "DEFAULT_RUN_CODE_TIMEOUT_SECONDS",
+ "RUN_CODE_TOOL_NAME",
+ "attach_audio",
+ "attach_image",
+ "attach_video",
+ "render_code_mode_prompt",
+ "render_tool_stub",
+ "run_code",
+ "set_code_mode",
+ "write_tool_stub",
+]
-RUN_CODE_TOOL_NAME = "run_code"
CODE_MODE_STATE_KEY = "code_mode"
CODE_TOOLS_STATE_KEY = "_runtime_code_tools"
-DEFAULT_RUN_CODE_TIMEOUT_SECONDS = 120
-
-_STUB_HEADER = '''"""Bub tools available inside `run_code` as `tools.(...)`.
-
-Every tool is an async function: `await` it (top-level `await` is allowed) and pass keyword
-arguments only. Each call returns the structured value described by its return type and
-raises an exception when the tool fails. Use `asyncio.gather` to run independent calls
-concurrently.
-"""
-
-from typing import Any, Literal, NotRequired, TypedDict'''
-_JSON_TYPES = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None"}
-
-
-def _identifier(name: str) -> str:
- ident = re.sub(r"\W", "_", name)
- if not ident or ident[0].isdigit() or keyword.iskeyword(ident):
- ident = f"_{ident}"
- return ident
-
-
-def _is_identifier(name: str) -> bool:
- return name.isidentifier() and not keyword.iskeyword(name)
-
-
-def _pascal(name: str) -> str:
- return "".join(part[:1].upper() + part[1:] for part in re.split(r"[^0-9A-Za-z]+", name))
-
-
-def _docstring(text: str, indent: str = " ") -> list[str]:
- text = text.strip().replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
- if not text:
- return []
- lines = text.splitlines()
- if len(lines) == 1:
- return [f'{indent}"""{lines[0]}"""']
- return [f'{indent}"""{lines[0]}', *(f"{indent}{line}".rstrip() for line in lines[1:]), f'{indent}"""']
-
-
-class _StubBuilder:
- """Render tool JSON schemas as Python stub declarations."""
-
- def __init__(self) -> None:
- self.classes: dict[str, str] = {}
- self._refs: dict[tuple[int, str], str] = {}
-
- def function(self, tool_item: Tool) -> str:
- name = _identifier(tool_item.name)
- schema = tool_item.parameters or {}
- defs = self._defs(schema)
- properties = schema.get("properties") or {}
- required = set(schema.get("required") or [])
- params: list[str] = []
- arg_docs: list[str] = []
- for key, prop in properties.items():
- hint = self.type_hint(prop, defs, f"{_pascal(name)}{_pascal(key)}")
- if key in required:
- params.append(f"{key}: {hint}")
- elif isinstance(prop, dict) and "default" in prop:
- params.append(f"{key}: {hint} = {prop['default']!r}")
- else:
- params.append(f"{key}: {hint} = ...")
- description = prop.get("description") if isinstance(prop, dict) else None
- if description:
- arg_docs.append(f" {key}: {' '.join(description.split())}")
- if not all(_is_identifier(key) for key in properties):
- signature = "**kwargs: Any"
- elif params:
- signature = f"*, {', '.join(params)}"
- else:
- signature = ""
- output = tool_item.output_schema
- returns = "Any" if output is None else self.type_hint(output, self._defs(output), f"{_pascal(name)}Result")
- doc = tool_item.description.strip()
- if arg_docs:
- doc = f"{doc}\n\nArgs:\n" + "\n".join(arg_docs) if doc else "Args:\n" + "\n".join(arg_docs)
- body = _docstring(doc) or [" ..."]
- return "\n".join([f"async def {name}({signature}) -> {returns}:", *body])
- def type_hint( # noqa: C901
- self, schema: Any, defs: dict[str, Any], name_hint: str, ref_key: tuple[int, str] | None = None
- ) -> str:
- if not isinstance(schema, dict) or not schema:
- return "Any"
- if isinstance(ref := schema.get("$ref"), str):
- return self._ref(ref, defs)
- for key in ("anyOf", "oneOf"):
- if isinstance(options := schema.get(key), list):
- return self._union(self.type_hint(option, defs, name_hint) for option in options)
- if isinstance(all_of := schema.get("allOf"), list) and len(all_of) == 1:
- return self.type_hint(all_of[0], defs, name_hint, ref_key)
- if "const" in schema:
- return f"Literal[{schema['const']!r}]"
- if isinstance(enum := schema.get("enum"), list) and enum:
- return f"Literal[{', '.join(repr(value) for value in enum)}]"
-
- schema_type = schema.get("type")
- if isinstance(schema_type, list):
- return self._union(self.type_hint({**schema, "type": item}, defs, name_hint) for item in schema_type)
- if schema_type == "array":
- if isinstance(prefix := schema.get("prefixItems"), list):
- return f"tuple[{', '.join(self.type_hint(item, defs, name_hint) for item in prefix)}]"
- return f"list[{self.type_hint(schema.get('items'), defs, f'{name_hint}Item')}]"
- if schema_type == "object":
- if isinstance(properties := schema.get("properties"), dict) and properties:
- return self._typed_dict(schema, properties, defs, schema.get("title") or name_hint, ref_key)
- extra = schema.get("additionalProperties")
- if isinstance(extra, dict) and extra:
- return f"dict[str, {self.type_hint(extra, defs, f'{name_hint}Value')}]"
- return "dict[str, Any]"
- return _JSON_TYPES.get(schema_type, "Any") if isinstance(schema_type, str) else "Any"
-
- @staticmethod
- def _defs(schema: dict[str, Any]) -> dict[str, Any]:
- defs = schema.get("$defs", schema.get("definitions"))
- return defs if isinstance(defs, dict) else {}
-
- @staticmethod
- def _union(hints: Iterable[str]) -> str:
- unique = list(dict.fromkeys(hints))
- return "Any" if "Any" in unique else " | ".join(unique)
-
- def _ref(self, ref: str, defs: dict[str, Any]) -> str:
- name = ref.rsplit("/", 1)[-1]
- key = (id(defs), name)
- if key in self._refs:
- return self._refs[key]
- target = defs.get(name)
- if not isinstance(target, dict):
- return "Any"
- hint = self.type_hint(target, defs, name, ref_key=key)
- self._refs[key] = hint
- return hint
-
- def _typed_dict(
- self,
- schema: dict[str, Any],
- properties: dict[str, Any],
- defs: dict[str, Any],
- title: str,
- ref_key: tuple[int, str] | None,
- ) -> str:
- base = _identifier(_pascal(title) or "Result")
- class_name = base
- index = 2
- while class_name in self.classes:
- class_name = f"{base}{index}"
- index += 1
- # Reserve the name first so recursive references resolve to this class.
- self.classes[class_name] = ""
- if ref_key is not None:
- self._refs[ref_key] = class_name
+def _resolve_model(spec: str | None, context: ToolContext) -> tuple[str, Mapping[str, Any]]:
+ """Resolve a spec, or the session's model, with Bub's settings: fallbacks, credentials and API bases."""
+ agent = context.state.get("_runtime_agent")
+ settings = getattr(agent, "settings", None)
+ if settings is None:
+ raise ValueError("Republic models need a running Bub agent.")
+ candidate = settings.model_candidates(spec or context.state.get("model") or settings.model)[0]
+ client_kwargs = settings.model_client_kwargs(candidate.provider_name or candidate.provider)
+ return f"{candidate.provider}:{candidate.model_id}", client_kwargs
- required = set(schema.get("required") or [])
- fields: list[tuple[str, str, str | None]] = []
- for key, prop in properties.items():
- hint = self.type_hint(prop, defs, f"{class_name}{_pascal(key)}")
- if key not in required:
- hint = f"NotRequired[{hint}]"
- description = prop.get("description") if isinstance(prop, dict) else None
- fields.append((key, hint, description))
- source = self._class_source(class_name, fields, schema.get("description"))
- # Reuse an identical class that was declared under the same title by another tool.
- for existing, existing_source in self.classes.items():
- if (
- existing != class_name
- and re.fullmatch(rf"{re.escape(base)}\d*", existing)
- and existing_source == source.replace(class_name, existing)
- ):
- del self.classes[class_name]
- if ref_key is not None:
- self._refs[ref_key] = existing
- return existing
- self.classes[class_name] = source
- return class_name
+def _stub_dir(session_id: str, workspace: Path) -> Path:
+ import bub
- @staticmethod
- def _class_source(name: str, fields: list[tuple[str, str, str | None]], description: object) -> str:
- if not all(_is_identifier(key) for key, _, _ in fields):
- items = ", ".join(f"{key!r}: {hint}" for key, hint, _ in fields)
- return f"{name} = TypedDict({name!r}, {{{items}}})"
- lines = [f"class {name}(TypedDict):"]
- if isinstance(description, str):
- lines.extend(_docstring(description))
- for key, hint, field_description in fields:
- if field_description:
- lines.append(f" # {' '.join(field_description.split())}")
- lines.append(f" {key}: {hint}")
- return "\n".join(lines)
+ return bub.home / "codemode" / hashlib.sha256(f"{workspace}\0{session_id}".encode()).hexdigest()[:16]
-def render_tool_stub(tools: Iterable[Tool]) -> str:
- """Render a Python stub declaring one function per model-facing tool."""
- builder = _StubBuilder()
- functions = [builder.function(tool_item) for tool_item in model_tools(tools)]
- return "\n\n\n".join([_STUB_HEADER, *builder.classes.values(), *functions]) + "\n"
+def _state_stub_dir(state: TurnState) -> Path:
+ return _stub_dir(str(state.get("session_id", "")), workspace_from_state(state))
def write_tool_stub(tools: Iterable[Tool], *, session_id: str, workspace: Path) -> Path:
"""Write the tool stub under Bub home; the path stays stable while the session's tool set is unchanged."""
- import bub
-
- content = render_tool_stub(tools)
- session_key = hashlib.sha256(f"{workspace}\0{session_id}".encode()).hexdigest()[:16]
- content_key = hashlib.sha256(content.encode()).hexdigest()[:12]
- path = bub.home / "codemode" / session_key / f"tools-{content_key}.pyi"
- if not path.exists():
- path.parent.mkdir(parents=True, exist_ok=True)
- temp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
- temp_path.write_text(content, encoding="utf-8")
- temp_path.replace(path)
- return path
-
-
-def render_code_mode_prompt(stub_path: Path) -> str:
- return (
- "\n"
- f"More tools are available as async Python functions `tools.(...)` inside `{RUN_CODE_TOOL_NAME}`. "
- f"Their signatures, result types and documentation are in the stub file: {stub_path}\n"
- "Read the stub before calling a tool you have not used yet. Always `await` tool calls (top-level `await` "
- "is allowed) and pass keyword arguments; they return structured values and raise on failure. "
- f"`{RUN_CODE_TOOL_NAME}` returns only what the code prints, so print the results you need, and combine "
- "several tool calls in one run when possible.\n"
- ""
- )
-
-
-@tool(name=RUN_CODE_TOOL_NAME, context=True, exposure="direct")
-async def run_code(code: str, timeout_seconds: int = DEFAULT_RUN_CODE_TIMEOUT_SECONDS, *, context: ToolContext) -> str:
- """Run Python code in the environment and return everything it prints.
-
- Tools are async functions available as `tools.(...)`: await them with keyword arguments
- (top-level `await` is allowed). See the tool stub file referenced in the system prompt for their
- signatures and result types. The code is stopped after timeout_seconds.
- """
- code_tools = context.state.get(CODE_TOOLS_STATE_KEY)
- if code_tools is None:
- raise BubError(ErrorKind.INVALID_INPUT, "Code mode is not enabled for this run.")
- tools_by_name = {_identifier(item.name): item for item in code_tools}
- agent = context.state.get("_runtime_agent")
- hooks: AgentHooks | None = getattr(getattr(agent, "model_runner", None), "hooks", None)
- # Code consumes structured results, so this executor does not render them to text.
- executor = ToolExecutor(hooks=hooks, render=False)
- code_context = replace(context, code_mode=True)
-
- async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
- tool_item = tools_by_name.get(name)
- if tool_item is None:
- raise BubError(ErrorKind.INVALID_INPUT, f"unknown tool: tools.{name}")
- execution = await executor.execute_async([(tool_item, arguments)], context=code_context)
- if execution.error is not None:
- raise execution.error
- return execution.tool_results[0]
-
- output: list[str] = []
- try:
- async with asyncio.timeout(timeout_seconds) as deadline:
- await environment_from_state(context.state).run_code(
- code, tools=list(tools_by_name), call_tool=call_tool, write=output.append
- )
- except TimeoutError:
- if not deadline.expired():
- raise
- raise BubError(
- ErrorKind.TOOL, f"Code timed out after {timeout_seconds} seconds", details={"output": "".join(output)}
- ) from None
- except CodeFailed as exc:
- raise BubError(
- ErrorKind.TOOL, f"Code raised {exc.error}", details={"output": "".join(output), "traceback": exc.traceback}
- ) from exc
- except BubError as exc:
- raise BubError(exc.kind, exc.message, details={"output": "".join(output), **(exc.details or {})}) from exc
- return "".join(output)
+ return write_stub(render_tool_stub(tools), _stub_dir(session_id, workspace))
+
+
+CODE_MODE = CodeMode(
+ lambda state: state.get(CODE_TOOLS_STATE_KEY),
+ environment=environment_from_state,
+ models=_resolve_model,
+ stub_dir=_state_stub_dir,
+ # The media tools are registered below, so turns select them like any other tool.
+ media_tools=False,
+)
+"""Code mode of Bub's agents."""
+
+run_code = CODE_MODE.run_code
+for _tool in (run_code, *MEDIA_TOOLS):
+ REGISTRY[_tool.name] = _tool
@tool(name="code_mode", context=True, exposure="command")
diff --git a/src/bub/builtin/codemode/code_runner_child.py b/src/bub/builtin/codemode/code_runner_child.py
deleted file mode 100644
index cd8080dd..00000000
--- a/src/bub/builtin/codemode/code_runner_child.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""Child side of ``run_code_in_subprocess``: run the code and forward ``tools.*`` calls to Bub.
-
-Bub runs this file with ``python -c`` inside the environment, so it must only use
-the standard library. Both directions exchange JSON lines:
-
-- Bub writes ``{"type": "run", "code", "filename", "tools"}`` to stdin first,
- then ``{"type": "result", "id", "value"}`` or ``{"type": "error", "id", "message"}``
- for each tool call.
-- The runner writes ``{"type": "call", "id", "name", "arguments"}`` to stdout for
- each tool call, ``{"type": "output", "data"}`` for what the code writes to
- ``sys.stdout``, and ends with ``{"type": "done"}`` or
- ``{"type": "failed", "error", "traceback"}``.
-"""
-
-from __future__ import annotations
-
-import ast
-import asyncio
-import builtins
-import inspect
-import io
-import itertools
-import json
-import linecache
-import sys
-import threading
-import time
-import traceback
-import types
-from typing import Any
-
-_OUTPUT_FLUSH_SIZE = 1 << 13
-_OUTPUT_FLUSH_INTERVAL = 0.2
-
-
-class BubError(Exception):
- """A tool call failed or was denied on the Bub side."""
-
-
-class _Channel:
- def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
- self._loop = loop
- self._out = sys.stdout
- self._send_lock = threading.Lock()
- self._ids = itertools.count()
- self._pending: dict[int, asyncio.Future[Any]] = {}
- self._first: asyncio.Future[dict[str, Any]] = loop.create_future()
-
- def send(self, message: dict[str, Any]) -> None:
- line = json.dumps(message) + "\n"
- with self._send_lock:
- self._out.write(line)
- self._out.flush()
-
- def start(self) -> asyncio.Future[dict[str, Any]]:
- # A daemon thread keeps reading stdin without blocking interpreter exit.
- threading.Thread(target=self._read, daemon=True).start()
- return self._first
-
- def _read(self) -> None:
- for line in sys.stdin:
- self._loop.call_soon_threadsafe(self._dispatch, json.loads(line))
- self._loop.call_soon_threadsafe(self._close)
-
- def _dispatch(self, message: dict[str, Any]) -> None:
- if not self._first.done():
- self._first.set_result(message)
- return
- future = self._pending.pop(message.get("id"), None) # type: ignore[arg-type]
- if future is None or future.done():
- return
- if message.get("type") == "result":
- future.set_result(message.get("value"))
- else:
- future.set_exception(BubError(message.get("message", "tool call failed")))
-
- def _close(self) -> None:
- if not self._first.done():
- self._first.set_exception(EOFError("Bub closed the connection"))
- for future in self._pending.values():
- if not future.done():
- future.set_exception(BubError("Bub closed the connection"))
- self._pending.clear()
-
- async def call(self, name: str, arguments: dict[str, Any]) -> Any:
- call_id = next(self._ids)
- future = self._loop.create_future()
- self._pending[call_id] = future
- try:
- self.send({"type": "call", "id": call_id, "name": name, "arguments": arguments})
- return await future
- finally:
- self._pending.pop(call_id, None)
-
-
-class _Output(io.TextIOBase):
- """Forward what the code prints to Bub in chunks, so output survives a timeout.
-
- A daemon thread also flushes periodically, because blocking code stalls the event loop.
- """
-
- def __init__(self, channel: _Channel) -> None:
- self._channel = channel
- self._parts: list[str] = []
- self._size = 0
- self._lock = threading.Lock()
- threading.Thread(target=self._flush_periodically, daemon=True).start()
-
- def writable(self) -> bool:
- return True
-
- def write(self, text: str) -> int:
- with self._lock:
- self._parts.append(text)
- self._size += len(text)
- full = self._size >= _OUTPUT_FLUSH_SIZE
- if full:
- self.flush()
- return len(text)
-
- def flush(self) -> None:
- with self._lock:
- if self._parts:
- data = "".join(self._parts)
- self._parts.clear()
- self._size = 0
- self._channel.send({"type": "output", "data": data})
-
- def _flush_periodically(self) -> None:
- while True:
- time.sleep(_OUTPUT_FLUSH_INTERVAL)
- self.flush()
-
-
-def _tool_function(channel: _Channel, output: _Output, name: str) -> Any:
- async def call(*args: Any, **kwargs: Any) -> Any:
- if args:
- raise TypeError(f"tools.{name}() accepts keyword arguments only")
- output.flush()
- return await channel.call(name, kwargs)
-
- call.__name__ = name
- return call
-
-
-async def _main() -> None:
- channel = _Channel(asyncio.get_running_loop())
- run = await channel.start()
- code, filename = run["code"], run["filename"]
- output = _Output(channel)
- sys.stdout = output
- namespace = {
- "__name__": "__run_code__",
- "__builtins__": builtins,
- "tools": types.SimpleNamespace(**{name: _tool_function(channel, output, name) for name in run["tools"]}),
- }
- linecache.cache[filename] = (len(code), None, code.splitlines(keepends=True), filename)
- try:
- compiled = compile(code, filename, "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT)
- # With top-level await enabled, eval returns a coroutine when the code awaits anything.
- result = eval(compiled, namespace) # noqa: S307
- if inspect.iscoroutine(result):
- await result
- except (Exception, SystemExit) as exc:
- error = traceback.TracebackException.from_exception(exc)
- # Keep only frames from the model's code; runner frames are noise to the model.
- error.stack = traceback.StackSummary.from_list([frame for frame in error.stack if frame.filename == filename])
- output.flush()
- channel.send({"type": "failed", "error": f"{type(exc).__name__}: {exc}", "traceback": "".join(error.format())})
- else:
- output.flush()
- channel.send({"type": "done"})
-
-
-if __name__ == "__main__":
- asyncio.run(_main())
diff --git a/src/bub/builtin/codex_provider.py b/src/bub/builtin/codex_provider.py
deleted file mode 100644
index e6569d03..00000000
--- a/src/bub/builtin/codex_provider.py
+++ /dev/null
@@ -1,521 +0,0 @@
-"""OpenAI Codex OAuth provider for any-llm Responses calls."""
-
-from __future__ import annotations
-
-import time
-from collections.abc import AsyncIterator, Mapping, Sequence
-from dataclasses import dataclass
-from typing import Any, cast, override
-
-from any_llm.exceptions import MissingApiKeyError
-from any_llm.providers.openai.base import BaseOpenAIProvider
-from any_llm.types.completion import ChatCompletion, ChatCompletionChunk, CompletionParams
-from any_llm.types.model import Model
-from any_llm.types.responses import Response, ResponsesParams
-
-from bub.builtin.auth import (
- extract_openai_codex_account_id,
- load_openai_codex_oauth_tokens,
- openai_codex_oauth_resolver,
-)
-
-DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
-DEFAULT_CODEX_ORIGINATOR = "bub"
-DEFAULT_CODEX_INCLUDE = ["reasoning.encrypted_content"]
-DEFAULT_CODEX_INSTRUCTIONS = "You are Codex."
-DEFAULT_CODEX_TEXT_CONFIG = {"verbosity": "medium"}
-
-
-class OpenAICodexTransportError(RuntimeError):
- def __init__(self, status_code: int | None, message: str, body: str | None = None) -> None:
- super().__init__(message)
- self.status_code = status_code
- self.body = body
-
-
-class OpenaiCodexProvider(BaseOpenAIProvider):
- """OpenAI-compatible Responses provider backed by Codex OAuth credentials."""
-
- API_BASE = DEFAULT_CODEX_BASE_URL
- ENV_API_KEY_NAME = "OPENAI_CODEX_API_KEY"
- ENV_API_BASE_NAME = "OPENAI_CODEX_API_BASE"
- PROVIDER_NAME = "openaicodex"
- PROVIDER_DOCUMENTATION_URL = "https://platform.openai.com/docs/codex"
-
- SUPPORTS_COMPLETION_STREAMING = True
- SUPPORTS_COMPLETION = True
- SUPPORTS_COMPLETION_REASONING = True
- SUPPORTS_RESPONSES = True
- SUPPORTS_LIST_MODELS = False
- SUPPORTS_BATCH = False
- SUPPORTS_IMAGE_GENERATION = False
- SUPPORTS_AUDIO_TRANSCRIPTION = False
- SUPPORTS_AUDIO_SPEECH = False
- SUPPORTS_EMBEDDING = False
- SUPPORTS_MODERATION = False
-
- def __init__(
- self,
- *,
- api_key: str | None = None,
- api_base: str | None = None,
- codex_home: str | None = None,
- default_instructions: str = DEFAULT_CODEX_INSTRUCTIONS,
- default_include: Sequence[str] = tuple(DEFAULT_CODEX_INCLUDE),
- default_text: dict[str, Any] | None = None,
- originator: str = DEFAULT_CODEX_ORIGINATOR,
- store: bool = False,
- **kwargs: Any,
- ) -> None:
- self._codex_home = codex_home
- self._default_instructions = default_instructions
- self._default_include = list(default_include)
- self._default_text = dict(default_text or DEFAULT_CODEX_TEXT_CONFIG)
- self._originator = originator
- self._store = store
- super().__init__(api_key=api_key, api_base=api_base, **kwargs)
-
- @override
- def _verify_and_set_api_key(self, api_key: str | None = None) -> str | None:
- if api_key:
- return api_key
- resolved = openai_codex_oauth_resolver(self._codex_home)("openai")
- if not resolved:
- raise MissingApiKeyError(self.PROVIDER_NAME, self.ENV_API_KEY_NAME)
- return resolved
-
- @override
- def _init_client(self, api_key: str | None = None, api_base: str | None = None, **kwargs: Any) -> None:
- default_headers = dict(kwargs.pop("default_headers", {}))
- default_headers.update(build_openai_codex_default_headers(api_key or "", originator=self._originator))
- super()._init_client(
- api_key=api_key,
- api_base=resolve_openai_codex_api_base(api_base),
- default_headers=default_headers,
- **kwargs,
- )
-
- @staticmethod
- @override
- def _convert_list_models_response(response: Any) -> Sequence[Model]:
- raise NotImplementedError("OpenAI Codex OAuth provider does not support listing models.")
-
- @override
- async def _acompletion(
- self,
- params: CompletionParams,
- **kwargs: Any,
- ) -> ChatCompletion | AsyncIterator[ChatCompletionChunk]:
- responses_params = self._completion_params_to_responses_params(params)
- if params.stream:
- response = await self._aresponses(responses_params.model_copy(update={"stream": True}))
- if not hasattr(response, "__aiter__"):
- raise OpenAICodexTransportError(None, "OpenAI Codex Responses API returned a non-streaming result.")
- return self._response_stream_to_completion_chunks(
- cast("AsyncIterator[Any]", response),
- model=params.model_id,
- )
-
- response = await self._aresponses(responses_params.model_copy(update={"stream": False}))
- return self._response_to_completion(cast("Response", response), model=params.model_id)
-
- @override
- async def _aresponses(self, params: ResponsesParams, **kwargs: Any) -> Any:
- return await super()._aresponses(self._with_codex_response_defaults(params), **kwargs)
-
- def _completion_params_to_responses_params(self, params: CompletionParams) -> ResponsesParams:
- reasoning = None
- if params.reasoning_effort not in {None, "auto"}:
- reasoning = {"effort": params.reasoning_effort}
-
- return ResponsesParams(
- model=params.model_id,
- input=cast("Any", _completion_messages_to_responses_input(params.messages)),
- tools=self._completion_tools_to_response_tools(cast("Sequence[Any] | None", params.tools)),
- tool_choice=self._completion_tool_choice_to_response_tool_choice(params.tool_choice),
- response_format=params.response_format,
- stream=params.stream,
- parallel_tool_calls=params.parallel_tool_calls,
- reasoning=reasoning,
- )
-
- @staticmethod
- def _completion_tools_to_response_tools(tools: Sequence[Any] | None) -> list[dict[str, Any]] | None:
- if not tools:
- return None
- response_tools: list[dict[str, Any]] = []
- for tool in tools:
- payload = tool.model_dump(exclude_none=True) if hasattr(tool, "model_dump") else dict(tool)
- function = payload.get("function")
- if payload.get("type") == "function" and isinstance(function, dict):
- response_tools.append({
- "type": "function",
- "name": function.get("name", ""),
- "description": function.get("description", ""),
- "parameters": function.get("parameters", {}),
- })
- else:
- response_tools.append(payload)
- return response_tools
-
- @staticmethod
- def _completion_tool_choice_to_response_tool_choice(tool_choice: str | dict[str, Any] | None) -> Any:
- if not isinstance(tool_choice, dict):
- return tool_choice
- function = tool_choice.get("function")
- if tool_choice.get("type") == "function" and isinstance(function, dict):
- return {"type": "function", "name": function.get("name", "")}
- return tool_choice
-
- async def _response_stream_to_completion_chunks(
- self,
- events: AsyncIterator[Any],
- *,
- model: str,
- ) -> AsyncIterator[ChatCompletionChunk]:
- mapper = CodexCompletionChunkMapper(model=model)
- async for event in events:
- for chunk in mapper.map_event(event):
- yield chunk
-
- @staticmethod
- def _response_to_completion(response: Response, *, model: str) -> ChatCompletion:
- text_parts: list[str] = []
- tool_calls: list[dict[str, Any]] = []
- for item in getattr(response, "output", []) or []:
- if getattr(item, "type", None) == "message":
- for content in getattr(item, "content", []) or []:
- text = getattr(content, "text", None)
- if isinstance(text, str):
- text_parts.append(text)
- if getattr(item, "type", None) in {"function_call", "custom_tool_call"}:
- tool_calls.append({
- "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{len(tool_calls)}",
- "type": "function",
- "function": {
- "name": getattr(item, "name", "") or "",
- "arguments": _tool_item_arguments(item) or "{}",
- },
- })
-
- usage = _completion_usage_from_response_usage(getattr(response, "usage", None))
- return ChatCompletion.model_validate({
- "id": getattr(response, "id", None) or "chatcmpl_codex",
- "object": "chat.completion",
- "created": int(getattr(response, "created_at", None) or time.time()),
- "model": getattr(response, "model", None) or model,
- "choices": [
- {
- "index": 0,
- "finish_reason": "tool_calls" if tool_calls else "stop",
- "message": {
- "role": "assistant",
- "content": "".join(text_parts) or None,
- "tool_calls": tool_calls or None,
- },
- }
- ],
- "usage": usage,
- })
-
- def _with_codex_response_defaults(self, params: ResponsesParams) -> ResponsesParams:
- update: dict[str, Any] = {}
- if params.store is None:
- update["store"] = self._store
- if params.instructions is None:
- update["instructions"] = self._default_instructions
- if params.include is None:
- update["include"] = list(self._default_include)
- if isinstance(params.text, dict):
- update["text"] = {**self._default_text, **params.text}
- elif params.text is None:
- update["text"] = dict(self._default_text)
- return params.model_copy(update=update)
-
-
-@dataclass
-class _CodexToolState:
- started: bool = False
- arguments_seen: bool = False
-
-
-class CodexCompletionChunkMapper:
- def __init__(self, *, model: str) -> None:
- self.model = model
- self.created = int(time.time())
- self._tool_states: dict[int, _CodexToolState] = {}
- self._tool_indexes_by_identifier: dict[str, int] = {}
-
- def map_event(self, event: Any) -> list[ChatCompletionChunk]:
- event_type = getattr(event, "type", None)
- if event_type == "response.output_text.delta":
- return [self._delta_chunk({"content": _string_attr(event, "delta")})]
- if event_type in {"response.reasoning_text.delta", "response.reasoning_summary_text.delta"}:
- return [self._delta_chunk({"reasoning": _string_attr(event, "delta")})]
- if event_type == "response.output_item.added":
- return self._tool_item_chunks(event)
- if event_type in {"response.function_call_arguments.delta", "response.custom_tool_call_input.delta"}:
- return self._tool_arguments_delta_chunks(event)
- if event_type == "response.function_call_arguments.done":
- return self._tool_arguments_done_chunks(event)
- if event_type == "response.output_item.done":
- return self._tool_item_chunks(event, done=True)
- if event_type == "response.completed":
- response = getattr(event, "response", None)
- return [self._terminal_chunk(response=response)]
- if event_type in {"response.failed", "response.incomplete"}:
- raise OpenAICodexTransportError(None, f"OpenAI Codex response ended with {event_type}")
- if event_type == "error":
- message = getattr(event, "message", None) or "OpenAI Codex response stream error"
- raise OpenAICodexTransportError(None, str(message))
- return []
-
- def _tool_item_chunks(self, event: Any, *, done: bool = False) -> list[ChatCompletionChunk]:
- output_index = getattr(event, "output_index", None)
- item = getattr(event, "item", None)
- if getattr(item, "type", None) not in {"function_call", "custom_tool_call"} or not isinstance(
- output_index, int
- ):
- return []
-
- self._remember_tool_identifiers(item, output_index)
- state = self._tool_states.setdefault(output_index, _CodexToolState())
- if done and state.started and state.arguments_seen:
- return []
-
- tool_delta: dict[str, Any] = {
- "index": output_index,
- "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{output_index}",
- "type": "function",
- "function": {
- "name": getattr(item, "name", None) or "",
- "arguments": "" if state.arguments_seen else _tool_item_arguments(item),
- },
- }
- state.started = True
- if tool_delta["function"]["arguments"]:
- state.arguments_seen = True
- return [self._delta_chunk({"tool_calls": [tool_delta]})]
-
- def _tool_arguments_delta_chunks(self, event: Any) -> list[ChatCompletionChunk]:
- output_index = self._tool_index_for_event(event)
- if output_index is None:
- return []
- delta = _string_attr(event, "delta")
- if not delta:
- return []
- state = self._tool_states.setdefault(output_index, _CodexToolState())
- state.arguments_seen = True
- return [self._delta_chunk({"tool_calls": [{"index": output_index, "function": {"arguments": delta}}]})]
-
- def _tool_arguments_done_chunks(self, event: Any) -> list[ChatCompletionChunk]:
- output_index = self._tool_index_for_event(event)
- if output_index is None:
- return []
- state = self._tool_states.setdefault(output_index, _CodexToolState())
- if state.arguments_seen:
- return []
- arguments = _string_attr(event, "arguments")
- if not arguments:
- return []
- state.arguments_seen = True
- name = _string_attr(event, "name")
- function: dict[str, str] = {"arguments": arguments}
- if name:
- function["name"] = name
- return [self._delta_chunk({"tool_calls": [{"index": output_index, "function": function}]})]
-
- def _tool_index_for_event(self, event: Any) -> int | None:
- output_index = getattr(event, "output_index", None)
- if isinstance(output_index, int):
- return output_index
- for attr in ("item_id", "call_id"):
- identifier = getattr(event, attr, None)
- if isinstance(identifier, str):
- known_index = self._tool_indexes_by_identifier.get(identifier)
- if known_index is not None:
- return known_index
- return None
-
- def _remember_tool_identifiers(self, item: Any, output_index: int) -> None:
- for attr in ("id", "call_id"):
- identifier = getattr(item, attr, None)
- if isinstance(identifier, str) and identifier:
- self._tool_indexes_by_identifier[identifier] = output_index
-
- def _delta_chunk(self, delta: dict[str, Any]) -> ChatCompletionChunk:
- return ChatCompletionChunk.model_validate({
- "id": "chatcmpl_codex",
- "object": "chat.completion.chunk",
- "created": self.created,
- "model": self.model,
- "choices": [{"index": 0, "delta": delta, "finish_reason": None}],
- })
-
- def _terminal_chunk(self, *, response: Any) -> ChatCompletionChunk:
- usage = _completion_usage_from_response_usage(getattr(response, "usage", None))
- finish_reason = "tool_calls" if self._tool_states else "stop"
- return ChatCompletionChunk.model_validate({
- "id": getattr(response, "id", None) or "chatcmpl_codex",
- "object": "chat.completion.chunk",
- "created": int(getattr(response, "created_at", None) or self.created),
- "model": getattr(response, "model", None) or self.model,
- "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
- "usage": usage,
- })
-
-
-def _completion_usage_from_response_usage(usage: Any) -> dict[str, Any]:
- payload = usage.model_dump(exclude_none=True) if hasattr(usage, "model_dump") else usage
- if not isinstance(payload, dict):
- payload = {
- "input_tokens": getattr(usage, "input_tokens", 0),
- "output_tokens": getattr(usage, "output_tokens", 0),
- "total_tokens": getattr(usage, "total_tokens", 0),
- "input_tokens_details": getattr(usage, "input_tokens_details", None),
- }
- prompt_tokens = int(payload.get("input_tokens") or payload.get("prompt_tokens") or 0)
- completion_tokens = int(payload.get("output_tokens") or payload.get("completion_tokens") or 0)
- total_tokens = int(payload.get("total_tokens") or prompt_tokens + completion_tokens)
- completion_usage: dict[str, Any] = {
- "prompt_tokens": prompt_tokens,
- "completion_tokens": completion_tokens,
- "total_tokens": total_tokens,
- }
- input_details = _mapping_from_value(payload.get("input_tokens_details"))
- cached_tokens = input_details.get("cached_tokens")
- if isinstance(cached_tokens, int) and not isinstance(cached_tokens, bool):
- completion_usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
- return completion_usage
-
-
-def _string_attr(obj: Any, name: str) -> str:
- value = getattr(obj, name, None)
- return value if isinstance(value, str) else ""
-
-
-def _tool_item_arguments(item: Any) -> str:
- return _string_attr(item, "arguments") or _string_attr(item, "input")
-
-
-def _completion_messages_to_responses_input(messages: Sequence[Any]) -> list[dict[str, Any]]:
- response_input: list[dict[str, Any]] = []
- for message in messages:
- payload = _mapping_from_value(message)
- if not payload:
- continue
-
- role = payload.get("role")
- if role == "tool":
- tool_result = _completion_tool_result_to_response_item(payload)
- if tool_result is not None:
- response_input.append(tool_result)
- continue
-
- content = _completion_content_to_responses_content(payload.get("content"), role=role)
- tool_calls = payload.get("tool_calls")
- if isinstance(tool_calls, Sequence) and not isinstance(tool_calls, str):
- if content:
- response_input.append({"role": "assistant", "content": content})
- response_input.extend(_completion_tool_calls_to_response_items(tool_calls))
- continue
-
- if isinstance(role, str):
- response_input.append({"role": role, "content": content or ""})
- return response_input
-
-
-def _completion_content_to_responses_content(content: Any, *, role: str | None) -> Any:
- """Translate Chat Completions parts without changing strings or Responses parts."""
- if not isinstance(content, list):
- return content
- parts: list[Any] = []
- for value in content:
- part = _mapping_from_value(value)
- match part.get("type"):
- case "text":
- parts.append({**part, "type": "output_text" if role == "assistant" else "input_text"})
- case "image_url":
- image = _mapping_from_value(part.get("image_url"))
- parts.append({
- "type": "input_image",
- "image_url": image.get("url"),
- "detail": image.get("detail", "auto"),
- })
- case "file":
- parts.append({"type": "input_file", **_mapping_from_value(part.get("file"))})
- case _:
- parts.append(value)
- return parts
-
-
-def _completion_tool_calls_to_response_items(tool_calls: Sequence[Any]) -> list[dict[str, Any]]:
- items: list[dict[str, Any]] = []
- for tool_call in tool_calls:
- payload = _mapping_from_value(tool_call)
- function = _mapping_from_value(payload.get("function"))
- call_id = payload.get("id")
- name = function.get("name")
- if not isinstance(call_id, str) or not isinstance(name, str):
- continue
- arguments = function.get("arguments")
- items.append({
- "type": "function_call",
- "call_id": call_id,
- "name": name,
- "arguments": arguments if isinstance(arguments, str) else "{}",
- "status": "completed",
- })
- return items
-
-
-def _completion_tool_result_to_response_item(message: Mapping[str, Any]) -> dict[str, Any] | None:
- call_id = message.get("tool_call_id")
- if not isinstance(call_id, str) or not call_id:
- return None
- content = message.get("content")
- return {
- "type": "function_call_output",
- "call_id": call_id,
- "output": content if isinstance(content, str) else "",
- }
-
-
-def _mapping_from_value(value: Any) -> Mapping[str, Any]:
- if isinstance(value, Mapping):
- return value
- if hasattr(value, "model_dump"):
- dumped = value.model_dump(exclude_none=True)
- if isinstance(dumped, Mapping):
- return dumped
- return {}
-
-
-def should_use_openai_codex_provider(
- provider: str, model_id: str, *, api_key: str | None, api_base: str | None
-) -> bool:
- if provider != "openai" or api_base:
- return False
- if api_key:
- return extract_openai_codex_account_id(api_key) is not None
- return load_openai_codex_oauth_tokens() is not None
-
-
-def resolve_openai_codex_api_base(api_base: str | None) -> str:
- raw = (api_base or DEFAULT_CODEX_BASE_URL).rstrip("/")
- if raw.endswith("/responses"):
- raw = raw[: -len("/responses")]
- if raw.endswith("/codex"):
- return raw
- return f"{raw}/codex"
-
-
-def build_openai_codex_default_headers(api_key: str, *, originator: str = DEFAULT_CODEX_ORIGINATOR) -> dict[str, str]:
- account_id = extract_openai_codex_account_id(api_key)
- if account_id is None:
- raise OpenAICodexTransportError(None, "OpenAI Codex OAuth token is missing chatgpt_account_id")
- return {
- "chatgpt-account-id": account_id,
- "OpenAI-Beta": "responses=experimental",
- "originator": originator,
- }
diff --git a/src/bub/builtin/commands.py b/src/bub/builtin/commands.py
index 1956554e..132dd98a 100644
--- a/src/bub/builtin/commands.py
+++ b/src/bub/builtin/commands.py
@@ -1,20 +1,8 @@
-"""Command prefix rules for the builtin agent and its channel adapters."""
+"""Compatibility alias for :mod:`bub.core.commands`."""
+import sys
-def validate_command_prefix(prefix: str) -> str:
- if not prefix or any(char.isspace() for char in prefix):
- raise ValueError("command_prefix must be non-empty and contain no whitespace")
- return prefix
+from bub.core import commands as _module
+from bub.core.commands import * # noqa: F403
-
-def strip_command_prefix(text: str, prefix: str) -> str | None:
- """Return the command without its prefix, or None for ordinary text.
-
- Strip surrounding whitespace and exactly one prefix. A bare prefix returns
- an empty string so execution can report an empty command.
- Callers validate the prefix when configuring it.
- """
- text = text.strip()
- if text.startswith(prefix):
- return text[len(prefix) :].strip()
- return None
+sys.modules[__name__] = _module
diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py
index 1476bc06..510f507c 100644
--- a/src/bub/builtin/context.py
+++ b/src/bub/builtin/context.py
@@ -1,97 +1,8 @@
-"""Tape context helpers."""
+"""Compatibility alias for :mod:`bub.core.context`."""
-from __future__ import annotations
+import sys
-import json
-from collections.abc import Iterable
-from typing import Any
+from bub.core import context as _module
+from bub.core.context import * # noqa: F403
-from bub.tape import TapeContext, TapeEntry
-from bub.tools import render_result
-
-
-def default_tape_context() -> TapeContext:
- """Return the default context selection for Bub."""
-
- return TapeContext(select=_select_messages)
-
-
-def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[dict[str, Any]]:
- messages: list[dict[str, Any]] = []
- pending_calls: list[dict[str, Any]] = []
-
- for entry in entries:
- match entry.kind:
- case "anchor":
- _append_anchor_entry(messages, entry)
- case "message":
- _append_message_entry(messages, entry)
- case "tool_call":
- pending_calls = _append_tool_call_entry(messages, entry)
- case "tool_result":
- _append_tool_result_entry(messages, pending_calls, entry)
- pending_calls = []
- return messages
-
-
-def _append_anchor_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> None:
- payload = entry.payload
- content = f"[Anchor created: {payload.get('name')}]: {json.dumps(payload.get('state'), ensure_ascii=False)}"
- messages.append({"role": "assistant", "content": content})
-
-
-def _append_message_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> None:
- payload = entry.payload
- if isinstance(payload, dict):
- messages.append(dict(payload))
-
-
-def _append_tool_call_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> list[dict[str, Any]]:
- calls = _normalize_tool_calls(entry.payload.get("calls"))
- if calls:
- messages.append({"role": "assistant", "content": entry.payload.get("content") or "", "tool_calls": calls})
- return calls
-
-
-def _append_tool_result_entry(
- messages: list[dict[str, Any]],
- pending_calls: list[dict[str, Any]],
- entry: TapeEntry,
-) -> None:
- results = entry.payload.get("results")
- if not isinstance(results, list):
- return
- for index, result in enumerate(results):
- messages.append(_build_tool_result_message(result, pending_calls, index))
-
-
-def _build_tool_result_message(
- result: object,
- pending_calls: list[dict[str, Any]],
- index: int,
-) -> dict[str, Any]:
- message: dict[str, Any] = {"role": "tool", "content": render_result(result)}
- if index >= len(pending_calls):
- return message
-
- call = pending_calls[index]
- call_id = call.get("id")
- if isinstance(call_id, str) and call_id:
- message["tool_call_id"] = call_id
-
- function = call.get("function")
- if isinstance(function, dict):
- name = function.get("name")
- if isinstance(name, str) and name:
- message["name"] = name
- return message
-
-
-def _normalize_tool_calls(value: object) -> list[dict[str, Any]]:
- if not isinstance(value, list):
- return []
- calls: list[dict[str, Any]] = []
- for item in value:
- if isinstance(item, dict):
- calls.append(dict(item))
- return calls
+sys.modules[__name__] = _module
diff --git a/src/bub/builtin/environment.py b/src/bub/builtin/environment.py
index b59469ee..f3399dca 100644
--- a/src/bub/builtin/environment.py
+++ b/src/bub/builtin/environment.py
@@ -1,136 +1,16 @@
-"""Host implementation of :class:`bub.environment.Environment`, provided by Bub's builtin hooks."""
+"""Bub's builtin environment provider: the session's environment from turn state."""
from __future__ import annotations
-import asyncio
-import contextlib
-import os
-import shutil
-import signal
-import sys
-from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
-from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, Environment, Process
+from bub.environment import ENVIRONMENT_STATE_KEY, Environment, LocalEnvironment
if TYPE_CHECKING:
from bub.turn import TurnState
-class LocalProcess(Process):
- """A host process in its own session, so signals reach the whole process group."""
-
- def __init__(self, process: asyncio.subprocess.Process) -> None:
- self.process = process
-
- @property
- def pid(self) -> int:
- return self.process.pid
-
- @property
- def stdout(self) -> asyncio.StreamReader:
- assert self.process.stdout is not None # noqa: S101
- return self.process.stdout
-
- @property
- def stderr(self) -> asyncio.StreamReader:
- assert self.process.stderr is not None # noqa: S101
- return self.process.stderr
-
- @property
- def returncode(self) -> int | None:
- return self.process.returncode
-
- async def wait(self) -> int:
- return await self.process.wait()
-
- async def write_stdin(self, data: bytes) -> None:
- assert self.process.stdin is not None # noqa: S101
- self.process.stdin.write(data)
- await self.process.stdin.drain()
-
- def close_stdin(self) -> None:
- if self.process.stdin is not None:
- self.process.stdin.close()
-
- def signal(self, *, kill: bool) -> None:
- with contextlib.suppress(ProcessLookupError):
- if os.name != "nt":
- os.killpg(self.process.pid, signal.SIGKILL if kill else signal.SIGTERM)
- elif self.returncode is None:
- if kill:
- self.process.kill()
- else:
- self.process.terminate()
-
- def is_running(self) -> bool:
- if os.name == "nt":
- return self.returncode is None
- try:
- os.killpg(self.process.pid, 0)
- except ProcessLookupError:
- return False
- except PermissionError:
- # EPERM does not establish that the group is gone (in particular
- # while its leader is exiting on macOS).
- return True
- return True
-
-
-class LocalEnvironment(Environment):
- """Run processes and access files directly on the host."""
-
- SHELL = shutil.which("bash") or shutil.which("sh") if os.name != "nt" else None
-
- def __init__(self, workspace: str | Path | None = None) -> None:
- self.workspace = str(workspace) if workspace is not None else None
- self.python = sys.executable
-
- async def spawn(
- self, command: str | Sequence[str], *, cwd: str | None = None, env: Mapping[str, str] | None = None
- ) -> LocalProcess:
- options: dict[str, Any] = {
- "cwd": cwd or self.workspace,
- "env": {**os.environ, **env} if env else None,
- "stdin": asyncio.subprocess.PIPE,
- "stdout": asyncio.subprocess.PIPE,
- "stderr": asyncio.subprocess.PIPE,
- "start_new_session": os.name != "nt",
- }
- if isinstance(command, str):
- process = await asyncio.create_subprocess_shell(command, executable=self.SHELL, **options)
- else:
- process = await asyncio.create_subprocess_exec(*command, **options)
- return LocalProcess(process)
-
- async def read_text(self, path: str) -> str:
- return await asyncio.to_thread(Path(path).read_text, encoding="utf-8")
-
- async def write_text(self, path: str, content: str) -> None:
- def write() -> None:
- target = Path(path)
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_text(content, encoding="utf-8")
-
- await asyncio.to_thread(write)
-
- async def run_code(
- self, code: str, *, tools: Sequence[str], call_tool: CallTool, write: Callable[[str], None]
- ) -> None:
- from bub.builtin.codemode.code_runner import run_code_in_subprocess
-
- await run_code_in_subprocess(self, code, tools=tools, call_tool=call_tool, write=write)
-
- def resolve_path(self, path: str) -> str:
- expanded = Path(path).expanduser()
- if expanded.is_absolute():
- return str(expanded)
- if self.workspace is None:
- raise ValueError(f"relative path '{path}' is not allowed without a workspace")
- return str((Path(self.workspace) / expanded).resolve())
-
-
def environment_from_state(state: TurnState) -> Environment:
"""Return the session's environment, or a host environment for the state's workspace when none was provided.
diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py
index 89c2b1f7..4454a455 100644
--- a/src/bub/builtin/hook_impl.py
+++ b/src/bub/builtin/hook_impl.py
@@ -3,34 +3,36 @@
from datetime import datetime
from difflib import get_close_matches
from pathlib import Path
-from typing import Any, cast
+from typing import cast
+import republic
import typer
from loguru import logger
from bub import inquirer as bub_inquirer
-from bub.builtin.agent import Agent
-from bub.builtin.commands import strip_command_prefix
-from bub.builtin.context import default_tape_context
+from bub.builtin.agent import BubAgent
from bub.builtin.onboarding import collect_model_config
from bub.builtin.settings import load_session_settings, load_settings
from bub.builtin.steering import InMemorySteeringInbox
from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot
from bub.channels.base import Channel
from bub.channels.contracts import MessageHandler
-from bub.channels.message import ChannelMessage, MediaItem, audio_format_from_mime_type
+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.events import ToolCall, ToolCallDecision, ToolCallResult
+from bub.core.sidecars import TapeSidecar
+from bub.core.store import TapeStore
+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.errors import BubError
from bub.framework import BubFramework
from bub.hooks import hookimpl
-from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult
from bub.model_selection import ModelChoice, ModelOptions
-from bub.sidecars import TapeSidecar
-from bub.store import TapeStore
+from bub.prompt import UserContent
from bub.streaming import AsyncStreamEvents
-from bub.tape import TapeContext
-from bub.tools import render_result
from bub.turn import TurnState
AGENTS_FILE_NAME = "AGENTS.md"
@@ -57,16 +59,6 @@
"""
-def _input_audio_part(data_url: str, mime_type: str) -> dict[str, Any] | None:
- prefix, separator, data = data_url.partition("base64,")
- if not separator or not prefix.startswith("data:audio/") or not data:
- return None
- return {
- "type": "input_audio",
- "input_audio": {"data": data, "format": audio_format_from_mime_type(mime_type)},
- }
-
-
class BuiltinImpl:
"""Default hook implementations for basic runtime operations."""
@@ -74,13 +66,13 @@ def __init__(self, framework: BubFramework) -> None:
from bub.builtin import codemode, spill, tools # noqa: F401
self.framework = framework
- self._agent: Agent | None = None
+ self._agent: BubAgent | None = None
- def _get_agent(self, state: TurnState | None = None) -> Agent:
+ def _get_agent(self, state: TurnState | None = None) -> BubAgent:
if state and "_runtime_agent" in state:
- return cast("Agent", state["_runtime_agent"])
+ return cast("BubAgent", state["_runtime_agent"])
if self._agent is None:
- self._agent = Agent(self.framework)
+ self._agent = BubAgent(self.framework)
return self._agent
@staticmethod
@@ -127,7 +119,7 @@ async def load_state(self, message: ChannelMessage, session_id: str) -> TurnStat
# Carry over per-session settings (model, reasoning effort, code mode) recorded
# on the session tape. Only settings a prior turn actually recorded are set, so
# a fresh/unknown session never inherits another session's choices.
- state.update(await load_session_settings(agent.tape.session_tape(session_id, self.framework.workspace)))
+ state.update(await load_session_settings(agent.tape(session_tape_name(session_id, self.framework.workspace))))
if model := field_of(message, "context", {}).get("model"):
state["model"] = model
if thread_id := field_of(message, "context", {}).get("thread_id"):
@@ -144,38 +136,28 @@ async def save_state(self, session_id: str, state: TurnState, message: ChannelMe
# events, so nothing to write here — this hook only closes the lifespan.
@hookimpl
- async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> str | list[dict]:
+ async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> list[UserContent]:
content = content_of(message)
if strip_command_prefix(content, self._get_agent(state).command_prefix) is not None:
message.kind = "command"
- return content.strip()
+ return [content.strip()]
context = field_of(message, "context_str")
now = datetime.now().astimezone().isoformat(timespec="seconds")
context_prefix = f"{context}\n---Date: {now}---\n" if context else ""
text = f"{context_prefix}{content}"
- media = field_of(message, "media") or []
- if not media:
- return text
-
- media_parts: list[dict] = []
- for item in cast("list[MediaItem]", media):
+ prompt: list[UserContent] = [text]
+ for item in cast("list[MediaItem]", field_of(message, "media") or []):
+ if item.type not in {"image", "audio", "video"} or not (url := await item.get_url()):
+ continue
match item.type:
- case "image" | "video":
- data_url = await item.get_url()
- if not data_url:
- continue
- part_type = f"{item.type}_url"
- media_parts.append({"type": part_type, part_type: {"url": data_url}})
+ case "image":
+ prompt.append(republic.image(url, media_type=item.mime_type))
case "audio":
- data_url = await item.get_url()
- if data_url and (audio_part := _input_audio_part(data_url, item.mime_type)):
- media_parts.append(audio_part)
- case _:
- pass
- if media_parts:
- return [{"type": "text", "text": text}, *media_parts]
- return text
+ prompt.append(republic.audio(url, media_type=item.mime_type))
+ case "video":
+ prompt.append(republic.video(url, media_type=item.mime_type))
+ return prompt
@hookimpl
async def run_model_stream(self, prompt: str | list[dict], session_id: str, state: TurnState) -> AsyncStreamEvents:
@@ -253,10 +235,10 @@ def provide_channels(self, message_handler: MessageHandler) -> list[Channel]:
from bub.channels.cli import CliChannel
from bub.channels.telegram import TelegramChannel
- agent = self._get_agent()
+ # Channels are created before framework.running(); the agent is created on first use.
return [
- TelegramChannel(on_receive=message_handler, command_prefix=agent.command_prefix),
- CliChannel(on_receive=message_handler, agent=agent),
+ TelegramChannel(on_receive=message_handler, command_prefix=load_settings().command_prefix),
+ CliChannel(on_receive=message_handler, get_agent=self._get_agent, workspace=self.framework.workspace),
]
@hookimpl
@@ -306,16 +288,16 @@ async def provide_lifespan(self) -> AsyncIterator[None]:
@hookimpl
def provide_environment(self, session_id: str, workspace: Path) -> Environment:
- from bub.builtin.environment import LocalEnvironment
+ from bub.environment import LocalEnvironment
return LocalEnvironment(workspace)
@hookimpl
def provide_tape_store(self) -> TapeStore:
import bub
- from bub.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:
@@ -354,12 +336,14 @@ async def before_tool_call(
When the model invokes a tool outside the current model-facing tool set,
replace it with a guidance ``tool_result`` so the model can re-issue a
- valid call on the next step.
+ valid call on the next step. Commands run the agent's tools directly and are left alone.
"""
from bub.builtin.codemode import CODE_TOOLS_STATE_KEY
from bub.builtin.tools import TOOL_DESCRIBE_TOOL_NAME
- from bub.tools import model_tools
+ from bub.core.tools import model_tools
+ if call.context is not None and call.context.command:
+ return None
agent = self._get_agent(state)
agent_tools = model_tools(agent.tools.values())
@@ -399,8 +383,9 @@ async def after_tool_call(
from bub.builtin.spill import SPILL_READ_MODEL_NAME, SPILL_SIDECAR_NAME, SpillStore
tape = state.get("_runtime_tape")
- # Results of calls from run_code are structured values for the code, not model-facing text.
- if tape is None or (call.context is not None and call.context.code_mode):
+ # Results of calls from run_code are structured values for the code, and results of
+ # commands go to the user; neither is model-facing text.
+ if tape is None or (call.context is not None and (call.context.code_mode or call.context.command)):
return
if SPILL_READ_MODEL_NAME not in state.get("_runtime_tool_names", ()):
return
@@ -415,6 +400,16 @@ async def after_tool_call(
else:
return
+ if is_content_result(tool_result):
+ # Bound the text; media stays inline.
+ blocks = tool_result["content"]
+ text = "".join(block["text"] for block in blocks if block.get("type") == "text")
+ bounded_text = await spill.spill_tool_result(tape, text, tool=call.tool, run_id=call.run_id)
+ if bounded_text != text:
+ media = [block for block in blocks if block.get("type") != "text"]
+ result.result = {**tool_result, "content": [{"type": "text", "text": bounded_text}, *media]}
+ return
+
rendered_result = render_result(tool_result)
bounded_result = await spill.spill_tool_result(
tape,
diff --git a/src/bub/builtin/model.py b/src/bub/builtin/model.py
new file mode 100644
index 00000000..23e837d4
--- /dev/null
+++ b/src/bub/builtin/model.py
@@ -0,0 +1,150 @@
+"""Bub's model client: fallbacks, custom providers and per-provider credentials from settings."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator, Callable, Mapping, Sequence
+from contextlib import AsyncExitStack, asynccontextmanager
+from dataclasses import dataclass
+from typing import Any
+
+import republic
+from loguru import logger
+
+from bub.tracing import current_span, event
+
+
+@dataclass(frozen=True)
+class ModelCandidate:
+ provider: str
+ model_id: str
+ name: str
+ provider_name: str | None = None
+
+
+ClientOptions = Mapping[str, Any] | Callable[[str], Mapping[str, Any]]
+"""Republic client options, or a function returning them for a provider name."""
+
+
+class BubModel:
+ """A ``provider:model`` spec with Bub's client policy, usable as the agent loop's model.
+
+ Args:
+ name: Default ``provider:model`` spec. Hooks may redirect a call to another spec.
+ fallbacks: Specs tried in order when opening the default model fails.
+ providers: Custom provider names mapped to the registered Republic provider type they speak.
+ client_options: Options for ``republic.get_model``, or a function of the provider name
+ (the custom name when one is used) returning them.
+ options: Generation options (``republic.ChatOptions``) sent with every call.
+ max_tokens: Default output token limit, unless a call sets one.
+ timeout: Seconds allowed for one complete call, streaming included.
+ """
+
+ def __init__(
+ self,
+ name: str,
+ *,
+ fallbacks: Sequence[str] = (),
+ providers: Mapping[str, str] | None = None,
+ client_options: ClientOptions | None = None,
+ options: Mapping[str, Any] | None = None,
+ max_tokens: int | None = None,
+ timeout: float | None = None,
+ ) -> None:
+ self.name = name
+ self.fallbacks = tuple(fallbacks)
+ self.providers = dict(providers or {})
+ self.client_options = client_options
+ self.options = dict(options or {})
+ self.max_tokens = max_tokens
+ self.timeout = timeout
+
+ def __repr__(self) -> str:
+ return f"{type(self).__name__}({self.name!r})"
+
+ @property
+ def spec(self) -> str:
+ return self.name
+
+ def candidates(self, model: str | None = None) -> list[ModelCandidate]:
+ """Resolve ``model`` (the default when omitted) and, for the default, its fallbacks."""
+ model = model or self.name
+ candidate_names = [model]
+ if model == self.name:
+ candidate_names.extend(self.fallbacks)
+
+ candidates: list[ModelCandidate] = []
+ for candidate in candidate_names:
+ provider, separator, model_id = candidate.partition(":")
+ if not separator or not provider or not model_id:
+ raise ValueError(f"Expected a provider:model identifier, got {candidate!r}")
+ if custom_type := self.providers.get(provider):
+ candidates.append(ModelCandidate(custom_type, model_id, candidate, provider))
+ continue
+ provider = provider.lower()
+ candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=f"{provider}:{model_id}"))
+ return candidates
+
+ def client_kwargs(self, provider: str) -> dict[str, Any]:
+ """Return Republic client options for a provider name."""
+ if callable(self.client_options):
+ return dict(self.client_options(provider))
+ return dict(self.client_options or {})
+
+ def chat_options(self, provider: str, options: Mapping[str, Any]) -> dict[str, Any]:
+ """Merge this model's generation options with a call's for ``provider``."""
+ merged = {**self.options, **options}
+ if provider == "anthropic":
+ merged["extra_body"] = {"cache_control": {"type": "ephemeral"}} | merged.get("extra_body", {})
+ max_tokens = options.get("max_tokens", self.max_tokens)
+ merged.pop("max_tokens", None)
+ if provider != "codex" and max_tokens is not None:
+ merged["max_tokens"] = max_tokens
+ return merged
+
+ @asynccontextmanager
+ async def open_stream(
+ self, messages: list[republic.Message], *, spec: str, options: Mapping[str, Any]
+ ) -> AsyncIterator[tuple[str, republic.Stream[Any]]]:
+ """Open a streaming completion, falling back to later candidates if opening fails.
+
+ Fallback covers client creation and the request; errors raised while consuming the
+ stream are not retried. ``timeout`` covers the whole call.
+ """
+ async with asyncio.timeout(self.timeout):
+ completion_error: Exception | None = None
+ candidates = self.candidates(spec)
+ for index, candidate in enumerate(candidates):
+ async with AsyncExitStack() as stack:
+ try:
+ chat_model = await stack.enter_async_context(
+ republic.get_model(
+ f"{candidate.provider}:{candidate.model_id}",
+ **self.client_kwargs(candidate.provider_name or candidate.provider),
+ )
+ )
+ if span := current_span():
+ span.rename(f"chat {candidate.model_id}")
+ span.set(**{
+ "gen_ai.provider.name": candidate.provider,
+ "gen_ai.request.model": candidate.model_id,
+ })
+ stream = await stack.enter_async_context(
+ chat_model.stream(messages, **self.chat_options(candidate.provider, options))
+ )
+ except Exception as exc:
+ event("bub.model.attempt_failed", model=candidate.name, error=type(exc).__name__)
+ if completion_error is None:
+ completion_error = exc
+ if index == len(candidates) - 1:
+ raise completion_error from None
+ logger.warning(
+ "model candidate failed; trying fallback model={} error={}",
+ candidate.name,
+ type(exc).__name__,
+ )
+ else:
+ yield candidate.provider_name or candidate.provider, stream
+ return
+
+ raise RuntimeError("no model candidates available")
diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py
deleted file mode 100644
index aae88010..00000000
--- a/src/bub/builtin/model_runner.py
+++ /dev/null
@@ -1,629 +0,0 @@
-"""LLM completion and model-output helpers for the builtin agent."""
-
-from __future__ import annotations
-
-import asyncio
-import inspect
-import re
-from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Iterator
-from contextlib import aclosing
-from dataclasses import dataclass
-from datetime import UTC, datetime
-from time import monotonic
-from typing import Any, Literal, cast
-
-from any_llm import AnyLLM
-from any_llm.constants import LLMProvider
-from any_llm.providers.anthropic.base import BaseAnthropicProvider
-from any_llm.providers.openai.base import BaseOpenAIProvider
-from any_llm.types.completion import (
- ChatCompletion,
- ChatCompletionChunk,
- ChatCompletionMessage,
- ChatCompletionMessageFunctionToolCall,
- ChatCompletionMessageToolCall,
- ChoiceDeltaToolCall,
- Function,
- ParsedChatCompletion,
-)
-from loguru import logger
-from pydantic import TypeAdapter, ValidationError
-
-from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider
-from bub.builtin.settings import AgentSettings, ModelCandidate
-from bub.channels.message import audio_mime_type_from_format
-from bub.errors import BubError, ErrorKind
-from bub.hooks.interception import (
- AgentHooks,
- LlmCallDecision,
- LlmCallRequest,
- LlmCallResult,
-)
-from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
-from bub.tape import Tape
-from bub.tools import Tool, ToolContext, ToolExecutor
-from bub.tracing import Span, current_span, event
-
-CONTEXT_LENGTH_PATTERNS = re.compile(
- r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum",
- re.IGNORECASE,
-)
-TOOL_ARGUMENTS_ADAPTER = TypeAdapter(dict[str, Any])
-CompletionResult = ChatCompletion | ParsedChatCompletion[Any] | AsyncIterator[ChatCompletionChunk]
-GOOGLE_FILE_CONTENT_PROVIDERS = frozenset({LLMProvider.GEMINI, LLMProvider.VERTEXAI})
-
-
-def _extra_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any]:
- """Return provider-specific extra completion options."""
- if isinstance(llm, BaseAnthropicProvider):
- return {"cache_control": {"type": "ephemeral"}}
- elif stream and isinstance(llm, BaseOpenAIProvider):
- return {"stream_options": {"include_usage": True}}
- return {}
-
-
-def _adapt_messages_for_provider(messages: list[dict[str, Any]], provider: LLMProvider) -> list[dict[str, Any]]:
- """Translate canonical multimodal blocks when a provider uses a different wire format."""
- if provider not in GOOGLE_FILE_CONTENT_PROVIDERS:
- return messages
-
- adapted_messages: list[dict[str, Any]] = []
- for message in messages:
- content = message.get("content")
- if not isinstance(content, list):
- adapted_messages.append(message)
- continue
-
- adapted_content: list[Any] = []
- changed = False
- for part in content:
- if not isinstance(part, dict):
- adapted_content.append(part)
- continue
-
- if part.get("type") == "video_url":
- video_url = part.get("video_url")
- url = video_url.get("url") if isinstance(video_url, dict) else None
- if isinstance(url, str) and url:
- adapted_content.append({"type": "file", "file": {"file_data": url}})
- changed = True
- continue
- elif part.get("type") == "input_audio":
- input_audio = part.get("input_audio")
- data = input_audio.get("data") if isinstance(input_audio, dict) else None
- audio_format = input_audio.get("format") if isinstance(input_audio, dict) else None
- if isinstance(data, str) and data and isinstance(audio_format, str) and audio_format:
- mime_type = audio_mime_type_from_format(audio_format)
- file_data = f"data:{mime_type};base64,{data}"
- adapted_content.append({"type": "file", "file": {"file_data": file_data}})
- changed = True
- continue
- adapted_content.append(part)
-
- adapted_messages.append({**message, "content": adapted_content} if changed else message)
- return adapted_messages
-
-
-class ModelRunner:
- def __init__(self, settings: AgentSettings, hooks: AgentHooks | None = None) -> None:
- self.settings = settings
- self.hooks = hooks
-
- def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, AnyLLM]]:
- for candidate in self.settings.model_candidates(model):
- client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider)
- yield (
- candidate,
- self.create_llm_client(candidate, client_kwargs),
- )
-
- @staticmethod
- def create_llm_client(candidate: ModelCandidate, client_kwargs: dict[str, Any]) -> AnyLLM:
- if candidate.provider == LLMProvider.OPENAI and should_use_openai_codex_provider(
- candidate.provider.value,
- candidate.model_id,
- api_key=client_kwargs.get("api_key"),
- api_base=client_kwargs.get("api_base"),
- ):
- return OpenaiCodexProvider(**client_kwargs)
- return AnyLLM.create(candidate.provider, **client_kwargs)
-
- async def completion_response(
- self,
- *,
- model: str,
- messages: list[dict[str, Any]],
- tools: list[Tool],
- max_tokens: int | None = None,
- reasoning_effort: str | None = None,
- ) -> CompletionResult:
- tool_payloads = [tool.to_schema() for tool in tools] or None
- clients = list(self.iter_llm_clients(model))
- completion_error: Exception | None = None
- for index, (candidate, llm) in enumerate(clients):
- try:
- if span := current_span():
- span.rename(f"chat {candidate.model_id}")
- span.set(**{
- "gen_ai.provider.name": candidate.provider.value,
- "gen_ai.request.model": candidate.model_id,
- })
- streaming = llm.SUPPORTS_COMPLETION_STREAMING
- completion_messages = _adapt_messages_for_provider(messages, candidate.provider)
- completion_kwargs = {
- **self.settings.completion_args,
- **_extra_options(llm, stream=streaming),
- "model": candidate.model_id,
- "messages": completion_messages,
- "tools": tool_payloads,
- "max_tokens": max_tokens if max_tokens is not None else self.settings.max_tokens,
- "stream": streaming,
- }
- if reasoning_effort is not None:
- completion_kwargs["reasoning_effort"] = reasoning_effort
- return cast("CompletionResult", await llm.acompletion(**completion_kwargs))
- except Exception as exc:
- event("bub.model.attempt_failed", model=candidate.name, error=repr(exc))
- if completion_error is None:
- completion_error = exc
- if index == len(clients) - 1:
- raise completion_error from None
- logger.warning("model candidate failed; trying fallback model={} error={}", candidate.name, exc)
-
- raise RuntimeError("no model candidates available")
-
- def run(
- self,
- *,
- tape: Tape,
- model: str,
- tools: list[Tool],
- system_prompt: str | None,
- prompt: str | list[dict] | None,
- steering_messages: list[list[dict[str, Any]] | str] | None = None,
- ) -> AsyncStreamEvents:
- state = StreamState()
-
- async def iterator() -> AsyncGenerator[StreamEvent, None]:
- run_id = self.generate_run_id()
- messages, new_messages = await self.build_messages(
- tape=tape,
- run_id=run_id,
- system_prompt=system_prompt,
- prompt=prompt,
- model=model,
- steering_messages=steering_messages,
- )
- output = ModelOutputAccumulator()
- request = LlmCallRequest(
- run_id=run_id,
- model=model,
- messages=messages,
- tool_names=tuple(tool_item.name for tool_item in tools),
- max_tokens=self.settings.max_tokens,
- )
- decision: LlmCallDecision | None = None
- if self.hooks is not None:
- request, decision = await self.hooks.before_llm_call(request, state=tape.context.state)
- if decision is not None:
- await self.record_chat(
- tape=tape,
- run_id=run_id,
- system_prompt=system_prompt,
- new_messages=new_messages,
- response_text=decision.text,
- model=request.model,
- )
- yield StreamEvent("text", {"delta": decision.text})
- yield StreamEvent("final", {"ok": True, "text": decision.text})
- return
- llm_started = datetime.now(UTC)
- after_fired = False
-
- async def fire_after(error: Exception | None = None) -> None:
- """Fire after_llm_call once per completed call (success or Exception failure); cancellation/consumer close bypasses it."""
-
- nonlocal after_fired
- if after_fired:
- return
- after_fired = True
- await self._fire_after_llm_call(request, output, state, llm_started, tape, error=error)
-
- try:
- completion_started = monotonic()
- async with aclosing(self._traced_completion(request, tools, tape, state, output)) as events:
- async for event in events:
- yield event
- completion_elapsed = monotonic() - completion_started
- except Exception as exc:
- # Cancellation / consumer close (BaseException) intentionally
- # bypasses after_llm_call: only real completions and failures
- # are terminal observations.
- await fire_after(exc)
- raise
- await fire_after()
-
- yield StreamEvent("usage", {"usage": state.usage, "elapsed_seconds": completion_elapsed})
-
- tool_calls = output.tool_calls
- if tool_calls:
- tool_map = {tool_item.name: tool_item for tool_item in tools}
- serialized_tool_calls = [tool_call.model_dump(exclude_none=True) for tool_call in tool_calls]
- tool_invocations = [tool_invocation_from_native(tool_call, tool_map) for tool_call in tool_calls]
- yield StreamEvent("tool_call", {"tool_calls": serialized_tool_calls})
- tape.context.state["_runtime_tool_names"] = tuple(tool_map)
- context = ToolContext(tape=tape, run_id=run_id, state=tape.context.state)
- execution = await ToolExecutor(hooks=self.hooks).execute_async(
- tool_invocations,
- context=context,
- call_ids=[call.id for call in tool_calls],
- )
- await self.record_chat(
- tape=tape,
- run_id=run_id,
- system_prompt=system_prompt,
- new_messages=new_messages,
- response_text=output.text or None,
- tool_calls=serialized_tool_calls,
- tool_results=execution.tool_results,
- response=output.response,
- model=request.model,
- usage=state.usage,
- )
- yield StreamEvent("tool_result", {"tool_results": execution.tool_results})
- yield StreamEvent(
- "final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": execution.tool_results}
- )
- return
-
- text = output.text
- await self.record_chat(
- tape=tape,
- run_id=run_id,
- system_prompt=system_prompt,
- new_messages=new_messages,
- response_text=text,
- response=output.response,
- model=request.model,
- usage=state.usage,
- )
- yield StreamEvent("final", {"ok": True, "text": text})
-
- return AsyncStreamEvents(iterator(), state=state)
-
- def _traced_completion(
- self,
- request: LlmCallRequest,
- tools: list[Tool],
- tape: Tape,
- state: StreamState,
- output: ModelOutputAccumulator,
- ) -> AsyncStreamEvents:
- provider, _, model = request.model.partition(":")
- span = Span(
- f"chat {model or request.model}",
- {
- "gen_ai.operation.name": "chat",
- "gen_ai.provider.name": provider,
- "gen_ai.request.model": model or request.model,
- "gen_ai.request.max_tokens": request.max_tokens,
- "gen_ai.conversation.id": tape.context.state.get("session_id"),
- "bub.run_id": request.run_id,
- "bub.tape": tape.name,
- },
- )
- span.messages("gen_ai.input.messages", request.messages)
- if span.recording:
- span.set(**{
- "gen_ai.tool.definitions": [tool.to_schema()["function"] | {"type": "function"} for tool in tools]
- })
-
- async def iterator() -> AsyncGenerator[StreamEvent, None]:
- async with asyncio.timeout(self.settings.model_timeout_seconds):
- completion = await self.completion_response(
- model=request.model,
- messages=list(request.messages),
- tools=tools,
- max_tokens=request.max_tokens,
- reasoning_effort=tape.context.state.get("reasoning_effort"),
- )
- try:
- async with aclosing(self._completion_events(completion, state, output)) as events:
- async for item in events:
- yield item
- finally:
- close = getattr(completion, "aclose", None) or getattr(completion, "close", None)
- if close is not None:
- result = close()
- if inspect.isawaitable(result):
- await result
-
- async def finish() -> None:
- if not span.recording:
- return
- usage = state.usage or {}
- span.set(**{
- "gen_ai.usage.input_tokens": usage.get("prompt_tokens", usage.get("input_tokens")),
- "gen_ai.usage.output_tokens": usage.get("completion_tokens", usage.get("output_tokens")),
- })
- span.messages(
- "gen_ai.output.messages",
- [
- {
- "role": "assistant",
- "content": output.text,
- "tool_calls": [call.model_dump(exclude_none=True) for call in output.tool_calls],
- }
- ],
- )
-
- return AsyncStreamEvents(iterator(), state=state, span=span, on_close=finish)
-
- @staticmethod
- def generate_run_id() -> str:
- return f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}"
-
- async def _fire_after_llm_call(
- self,
- request: LlmCallRequest,
- output: ModelOutputAccumulator,
- state: StreamState,
- started: datetime,
- tape: Tape,
- error: Exception | None = None,
- ) -> None:
- if self.hooks is None:
- return
- duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000)
- result = LlmCallResult(
- run_id=request.run_id,
- text=output.text or None,
- tool_calls=[call.model_dump(exclude_none=True) for call in output.tool_calls],
- usage=state.usage,
- error=error,
- duration_ms=duration_ms,
- )
- await self.hooks.after_llm_call(request, result, state=tape.context.state)
-
- async def build_messages(
- self,
- *,
- tape: Tape,
- run_id: str,
- system_prompt: str | None,
- prompt: str | list[dict] | None,
- model: str,
- steering_messages: list[list[dict[str, Any]] | str] | None = None,
- ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
- try:
- messages = await tape.read_messages()
- except BubError as exc:
- await self.record_context_error(
- tape=tape,
- run_id=run_id,
- system_prompt=system_prompt,
- error=exc,
- model=model,
- )
- raise
- steering_messages_native = [{"role": "user", "content": message} for message in (steering_messages or [])]
- if system_prompt:
- messages = [{"role": "system", "content": system_prompt}, *messages]
- new_messages = [*steering_messages_native]
- if prompt is not None:
- new_messages.append({"role": "user", "content": prompt})
- messages.extend(new_messages)
- return messages, new_messages
-
- async def record_context_error(
- self,
- *,
- tape: Tape,
- run_id: str,
- system_prompt: str | None,
- error: BubError,
- model: str,
- ) -> None:
- await self.record_chat(
- tape=tape,
- run_id=run_id,
- system_prompt=system_prompt,
- context_error=error,
- new_messages=[],
- response_text=None,
- error=error,
- model=model,
- )
-
- async def record_chat(
- self,
- *,
- tape: Tape,
- run_id: str,
- system_prompt: str | None,
- new_messages: list[dict[str, Any]],
- response_text: str | None,
- context_error: BubError | None = None,
- tool_calls: list[dict[str, Any]] | None = None,
- tool_results: list[Any] | None = None,
- error: BubError | None = None,
- response: Any | None = None,
- provider: str | None = None,
- model: str | None = None,
- usage: dict[str, Any] | None = None,
- ) -> None:
- await tape.record_chat(
- run_id=run_id,
- system_prompt=system_prompt,
- new_messages=new_messages,
- response_text=response_text,
- context_error=context_error,
- tool_calls=tool_calls,
- tool_results=tool_results,
- error=error,
- response=response,
- provider=provider,
- model=model,
- usage=usage,
- )
-
- async def _completion_events(
- self,
- completion: CompletionResult,
- state: StreamState,
- output: ModelOutputAccumulator,
- ) -> AsyncGenerator[StreamEvent, None]:
- if isinstance(completion, ChatCompletion):
- self._trace_response(completion)
- if usage := Tape._extract_usage(completion):
- state.usage = usage
- output.response = completion
- message = completion.choices[0].message
- for event in self._completion_message_events(message, output):
- yield event
- return
-
- async for chunk in completion:
- self._trace_response(chunk)
- async for event in self._completion_chunk_events(chunk, state, output):
- yield event
-
- @staticmethod
- def _trace_response(response: ChatCompletion | ChatCompletionChunk) -> None:
- if span := current_span():
- span.set(**{"gen_ai.response.model": response.model, "gen_ai.response.id": response.id})
- reasons = [choice.finish_reason for choice in response.choices if choice.finish_reason is not None]
- if reasons:
- span.set(**{"gen_ai.response.finish_reasons": reasons})
-
- def _completion_message_events(
- self,
- message: ChatCompletionMessage,
- output: ModelOutputAccumulator,
- ) -> Iterable[StreamEvent]:
- if message.reasoning:
- yield StreamEvent("reasoning", {"delta": self.reasoning_text(message.reasoning)})
- if message.content:
- output.add_text(message.content)
- yield StreamEvent("text", {"delta": message.content})
- output.add_message_tool_calls(cast("Iterable[ChatCompletionMessageToolCall]", message.tool_calls or []))
-
- async def _completion_chunk_events(
- self,
- chunk: ChatCompletionChunk,
- state: StreamState,
- output: ModelOutputAccumulator,
- ) -> AsyncGenerator[StreamEvent, None]:
- if usage := Tape._extract_usage(chunk):
- state.usage = usage
- for choice in chunk.choices:
- delta = choice.delta
- if delta.reasoning:
- yield StreamEvent("reasoning", {"delta": self.reasoning_text(delta.reasoning)})
- if delta.content:
- output.add_text(delta.content)
- yield StreamEvent("text", {"delta": delta.content})
- if delta.tool_calls:
- output.merge_delta_tool_calls(delta.tool_calls)
-
- @staticmethod
- def reasoning_text(reasoning: object) -> str:
- content = getattr(reasoning, "content", reasoning)
- return "" if content is None else str(content)
-
-
-@dataclass
-class StreamToolCall:
- id: str | None = None
- type: Literal["function"] | None = None
- name: str | None = None
- arguments: str = ""
-
- def merge(self, delta: ChoiceDeltaToolCall) -> None:
- if delta.id:
- self.id = delta.id
- if delta.type:
- self.type = delta.type
- if delta.function is None:
- return
- if delta.function.name:
- if self.name is None or self.name == delta.function.name:
- self.name = delta.function.name
- else:
- self.name += delta.function.name
- if delta.function.arguments:
- self.arguments += delta.function.arguments
-
- def as_tool_call(self, index: int) -> ChatCompletionMessageFunctionToolCall:
- return ChatCompletionMessageFunctionToolCall(
- id=self.id or f"call_{index}",
- type=self.type or "function",
- function=Function(name=self.name or "", arguments=self.arguments or "{}"),
- )
-
-
-class ModelOutputAccumulator:
- def __init__(self) -> None:
- self.response: ChatCompletion | ParsedChatCompletion[Any] | None = None
- self._text_parts: list[str] = []
- self._message_calls: list[ChatCompletionMessageToolCall] = []
- self._stream_calls: dict[int, StreamToolCall] = {}
-
- def add_text(self, text: str) -> None:
- self._text_parts.append(text)
-
- def add_message_tool_calls(self, calls: Iterable[ChatCompletionMessageToolCall]) -> None:
- self._message_calls.extend(calls)
-
- def merge_delta_tool_calls(self, deltas: Iterable[ChoiceDeltaToolCall]) -> None:
- for delta in deltas:
- self._stream_calls.setdefault(delta.index, StreamToolCall()).merge(delta)
-
- @property
- def text(self) -> str:
- return "".join(self._text_parts)
-
- @property
- def tool_calls(self) -> list[ChatCompletionMessageToolCall]:
- if self._message_calls:
- return list(self._message_calls)
- return [self._stream_calls[index].as_tool_call(index) for index in sorted(self._stream_calls)]
-
-
-def tool_invocation_from_native(
- tool_call: ChatCompletionMessageToolCall,
- tool_map: dict[str, Tool],
-) -> tuple[Tool, dict[str, Any]]:
- """Resolve a model tool call to (runtime tool, arguments).
-
- An unknown tool name is not treated as a fatal error: it is surfaced as a
- placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and
- builtin hooks (e.g. ``before_tool_call``) can recover it into a guidance
- ``tool_result`` instead of interrupting the turn. If no hook replaces the
- call, the placeholder raises a clear tool error rather than succeeding with
- an empty result.
- """
- tool_name, arguments = parse_native_function_call(tool_call)
- tool_obj = tool_map.get(tool_name)
- if tool_obj is None:
-
- def raise_unknown_tool(**_: Any) -> None:
- raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.")
-
- return Tool(name=tool_name, handler=raise_unknown_tool), arguments
- return tool_obj, arguments
-
-
-def parse_native_function_call(tool_call: ChatCompletionMessageToolCall) -> tuple[str, dict[str, Any]]:
- if tool_call.type != "function":
- raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.")
- try:
- arguments = TOOL_ARGUMENTS_ADAPTER.validate_json(tool_call.function.arguments or "{}")
- except ValidationError as exc:
- raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc
- return tool_call.function.name, arguments
-
-
-def is_context_length_error(error_msg: str) -> bool:
- """Check whether an error message indicates a context-length / prompt-too-long failure."""
- return bool(CONTEXT_LENGTH_PATTERNS.search(error_msg))
diff --git a/src/bub/builtin/onboarding.py b/src/bub/builtin/onboarding.py
index a62da049..d7518d8a 100644
--- a/src/bub/builtin/onboarding.py
+++ b/src/bub/builtin/onboarding.py
@@ -3,32 +3,29 @@
from __future__ import annotations
import asyncio
-import inspect
-import os
from typing import Any
from urllib.parse import urlsplit
+import republic
import typer
-from any_llm import AnyLLM
-from any_llm.exceptions import AuthenticationError, MissingApiKeyError, UnsupportedProviderError
+from republic.errors import APITimeoutError, AuthenticationError, ProviderNotFoundError, UnsupportedFeatureError
from bub import configure, inquirer
-from bub.builtin.codex_provider import should_use_openai_codex_provider
-from bub.builtin.settings import DEFAULT_MODEL, AgentSettings
+from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, CustomProvider
PROVIDERS = {
"openrouter": "OpenRouter (hosted model gateway)",
"openai": "OpenAI (official API)",
"openai-compatible": "OpenAI-compatible (custom URL / local server)",
"anthropic": "Anthropic (Claude API)",
- "gemini": "Google Gemini (native API)",
- "azure": "Azure AI (Azure endpoint)",
- "bedrock": "Amazon Bedrock (AWS)",
+ "google": "Google Gemini (native API)",
+ "codex": "Codex (ChatGPT plan login)",
+ "azure-openai": "Azure OpenAI (Azure endpoint)",
"ollama": "Ollama (local server)",
- "groq": "Groq",
"mistral": "Mistral AI",
"deepseek": "DeepSeek",
- "custom": "Other provider (SDK provider name)",
+ "magpie": "Magpie (local model gateway)",
+ "custom": "Other registered Republic provider",
}
OPENAI_BASE = "https://api.openai.com/v1"
CONNECTION_TIMEOUT = 10
@@ -37,31 +34,12 @@
RETRY_CONNECTION = "Retry connection"
-def _provider_class(provider: str) -> type[AnyLLM] | None:
- try:
- return AnyLLM.get_provider_class(provider)
- except (ImportError, UnsupportedProviderError):
- return None
-
-
def _default_base(provider: str) -> str:
- provider_class = _provider_class(provider)
- if provider_class is None:
+ try:
+ client = republic.get_provider(provider, api_key="display-only", api_base="", env_prefix="BUB_DISPLAY_ONLY")
+ except (AuthenticationError, ProviderNotFoundError, ValueError):
return ""
- env_name = provider_class.ENV_API_BASE_NAME
- return (
- (os.getenv(env_name) if env_name else None)
- or provider_class.API_BASE
- or ("http://localhost:11434" if provider == "ollama" else "")
- )
-
-
-def _has_environment_key(provider: str) -> bool:
- if AgentSettings().model_client_kwargs(provider)["api_key"]:
- return True
- provider_class = _provider_class(provider)
- names = (provider_class.ENV_API_KEY_NAME or "").split("/") if provider_class else []
- return any(os.getenv(name) for name in names)
+ return client.api_base
def _endpoint(provider: str, api_base: str | None) -> str:
@@ -98,20 +76,9 @@ def _ask_base(default: str, *, required: bool) -> str:
async def _discover_models(provider: str, **client_args: Any) -> list[str]:
- async with asyncio.timeout(CONNECTION_TIMEOUT):
- if not AnyLLM.get_provider_class(provider).SUPPORTS_LIST_MODELS:
- raise NotImplementedError
- llm = AnyLLM.create(provider, **client_args)
- try:
- models = await llm.alist_models()
- return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()})
- finally:
- client = getattr(llm, "client", None)
- close = getattr(client, "aclose", None) or getattr(client, "close", None)
- if callable(close):
- result = close()
- if inspect.isawaitable(result):
- await result
+ async with republic.get_provider(provider, **{**client_args, "timeout": CONNECTION_TIMEOUT}) as client:
+ models = await client.list_models()
+ return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()})
def discover_models(provider: str, **client_args: Any) -> list[str]:
@@ -120,16 +87,14 @@ def discover_models(provider: str, **client_args: Any) -> list[str]:
def _connection_error(exc: Exception) -> str:
- # SDK errors may contain request URLs, response bodies or credentials.
- if isinstance(exc, AuthenticationError | MissingApiKeyError):
+ # Provider errors may contain request URLs, response bodies or credentials.
+ if isinstance(exc, AuthenticationError):
return "Authentication failed or API key missing. Check the key and its permissions."
- if isinstance(exc, TimeoutError):
+ if isinstance(exc, TimeoutError | APITimeoutError):
return f"Connection timed out after {CONNECTION_TIMEOUT} seconds. Check the URL and network."
- if isinstance(exc, NotImplementedError | UnsupportedProviderError):
+ if isinstance(exc, NotImplementedError | UnsupportedFeatureError | ProviderNotFoundError):
return "Model discovery is unavailable for this provider. You can enter a model ID manually."
- if isinstance(exc, ImportError):
- return "The provider SDK is not installed. Install its dependencies or enter a model ID manually."
- status = getattr(exc, "status_code", None) or getattr(getattr(exc, "original_exception", None), "status_code", None)
+ status = getattr(exc, "status_code", None)
if status in {401, 403}:
return f"Authentication rejected (HTTP {status}). Check the API key and its permissions."
if status == 404:
@@ -151,9 +116,13 @@ def _choose_model(models: list[str], default: str) -> str:
def _connection_config(
- current_config: dict[str, object], provider: str, api_base: str, api_key: str
+ current_config: dict[str, object], provider: str, api_base: str, api_key: str, api_format: str | None
) -> dict[str, object]:
+ if isinstance(named := current_config.get("providers"), dict) and provider in named:
+ return {"providers": {provider: {"api_base": api_base or None, "api_key": api_key or None}}}
config: dict[str, object] = {}
+ if api_format is not None:
+ config["client_args"] = {**AgentSettings.model_validate(current_config).client_args, "api_format": api_format}
for name, value in (("api_base", api_base), ("api_key", api_key)):
existing = current_config.get(name)
if isinstance(existing, dict):
@@ -167,12 +136,15 @@ def _connection_config(
def _select_connection(
current_config: dict[str, object], current_provider: str, current_endpoint: str
-) -> tuple[str, str, str]:
+) -> tuple[str, str, str, str | None]:
choice = (
"openai-compatible" if current_provider == "openai" and current_endpoint != OPENAI_BASE else current_provider
)
choices = dict(PROVIDERS)
choices.setdefault(choice, choice)
+ for registered in republic.all_providers():
+ if registered != "typesafe":
+ choices.setdefault(registered, registered)
selected = inquirer.ask_fuzzy("LLM provider", choices=list(choices.values()), default=choices[choice])
choice = next((name for name, label in choices.items() if label == selected), selected)
provider = "openai" if choice == "openai-compatible" else choice
@@ -186,10 +158,13 @@ def _select_connection(
for name in ("api_base", "api_key")
if isinstance(value := current_config.get(name), dict) or provider == current_provider
}
- stored = AgentSettings.model_construct(
- api_base=explicit.get("api_base"), api_key=explicit.get("api_key")
- ).model_client_kwargs(provider)
- api_base, api_key = stored["api_base"] or "", stored["api_key"] or ""
+ stored = AgentSettings.model_construct(api_base=explicit.get("api_base"), api_key=explicit.get("api_key"))
+ api_base = AgentSettings._provider_value(stored.api_base, provider) or ""
+ api_key = AgentSettings._provider_value(stored.api_key, provider) or ""
+ if isinstance(named := current_config.get("providers"), dict) and provider in named:
+ custom = CustomProvider.model_validate(named[provider])
+ api_base = custom.api_base or api_base
+ api_key = custom.api_key or api_key
previous_endpoint = _endpoint(provider, api_base)
if choice == "openai" and previous_endpoint != OPENAI_BASE:
api_base = OPENAI_BASE
@@ -197,35 +172,33 @@ def _select_connection(
typer.echo(f"API endpoint: {endpoint}" if api_base else f"Default API endpoint: {endpoint}")
if (
choice in {"openai-compatible", "custom"}
- or provider in {"azure", "ollama"}
+ or provider in {"azure-openai", "ollama", "magpie"}
or (choice != "openai" and api_base and _endpoint(provider, api_base) != _default_base(provider).rstrip("/"))
):
- api_base = _ask_base(api_base, required=choice == "openai-compatible" or provider == "azure")
+ api_base = _ask_base(api_base, required=choice == "openai-compatible" or provider == "azure-openai")
if _endpoint(provider, api_base) != previous_endpoint:
api_key = ""
- return provider, api_base, api_key
+ return provider, api_base, api_key, "chat" if choice == "openai-compatible" else None
-def _ask_key(provider: str, api_base: str, api_key: str) -> str:
+def _ask_key(api_key: str) -> str:
prompt = "API key (Enter to keep current key)" if api_key else "API key (optional)"
api_key = inquirer.ask_secret(prompt).strip() or api_key
- if (
- provider == "openai"
- and api_base
- and api_base.rstrip("/") != OPENAI_BASE
- and not api_key
- and not _has_environment_key(provider)
- ):
- # The OpenAI SDK requires a nonempty key even for servers without auth.
- return "not-required"
return api_key
def _configure_connection(
- current_config: dict[str, object], provider: str, api_base: str, api_key: str, model_default: str
+ current_config: dict[str, object],
+ provider: str,
+ api_base: str,
+ api_key: str,
+ model_default: str,
+ api_format: str | None,
) -> dict[str, object]:
action = ""
models: list[str] = []
+ settings = AgentSettings.model_validate(current_config)
+ provider_type = settings.providers[provider].type if provider in settings.providers else provider
typer.echo("Leave the API key blank to keep the current key or use environment credentials.")
while action != MANUAL_MODEL:
if action == EDIT_CONNECTION:
@@ -234,21 +207,20 @@ def _configure_connection(
if _endpoint(provider, api_base) != previous_endpoint:
api_key = model_default = ""
if action != RETRY_CONNECTION:
- api_key = _ask_key(provider, api_base, api_key)
- config = _connection_config(current_config, provider, api_base, api_key)
+ api_key = _ask_key(api_key) if provider_type != "codex" else ""
+ config = _connection_config(current_config, provider, api_base, api_key, api_format)
settings = AgentSettings.model_validate(configure.merge({}, current_config, config))
client_args = settings.model_client_kwargs(provider)
+ provider_type = settings.providers[provider].type if provider in settings.providers else provider
if (client_args["api_base"] or "", client_args["api_key"] or "") != (api_base, api_key):
typer.echo("BUB_* environment settings override this connection's URL or API key.")
- if should_use_openai_codex_provider(
- provider, model_default, api_key=client_args["api_key"], api_base=client_args["api_base"]
- ):
- typer.echo("Using OpenAI OAuth login. Model discovery is unavailable; enter a model ID manually.")
+ if provider_type == "codex":
+ typer.echo("Using Codex account login. Model discovery is unavailable; enter a model ID manually.")
break
typer.echo("Checking connection and fetching models...")
try:
- models = discover_models(provider, **client_args)
- except (NotImplementedError, UnsupportedProviderError) as exc:
+ models = discover_models(provider_type, **client_args)
+ except (NotImplementedError, UnsupportedFeatureError, ProviderNotFoundError) as exc:
typer.secho(_connection_error(exc), fg="yellow")
break
except Exception as exc:
@@ -271,8 +243,9 @@ def collect_model_config(current_config: dict[str, object]) -> dict[str, object]
if not separator:
current_provider, _, fallback = DEFAULT_MODEL.partition(":")
model_default = settings.model.strip() or fallback
+ current_provider = current_provider.lower()
current_endpoint = _endpoint(current_provider, settings.model_client_kwargs(current_provider)["api_base"])
- provider, api_base, api_key = _select_connection(current_config, current_provider, current_endpoint)
+ provider, api_base, api_key, api_format = _select_connection(current_config, current_provider, current_endpoint)
if (provider, _endpoint(provider, api_base)) != (current_provider, current_endpoint):
model_default = ""
- return _configure_connection(current_config, provider, api_base, api_key, model_default)
+ return _configure_connection(current_config, provider, api_base, api_key, model_default, api_format)
diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py
index 59d96c0b..ef11fb22 100644
--- a/src/bub/builtin/settings.py
+++ b/src/bub/builtin/settings.py
@@ -4,41 +4,48 @@
import pathlib
import re
import sys
-from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
-from any_llm import AnyLLM
-from any_llm.constants import LLMProvider
+import republic
from pydantic import BaseModel, Field, field_validator
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
-from bub import Settings, config, ensure_config
-from bub.builtin.commands import validate_command_prefix
+from bub import Settings, __version__, config, ensure_config
+from bub.builtin.model import BubModel, ModelCandidate
+from bub.core.commands import validate_command_prefix
if TYPE_CHECKING:
- from bub.tape import Tape
- from bub.tools import ToolContext
+ from bub.core.tape import Tape
+ from bub.core.tools import ToolContext
DEFAULT_MODEL = "openrouter:openrouter/free"
DEFAULT_MAX_TOKENS = 16384
-@dataclass(frozen=True)
-class ModelCandidate:
- provider: LLMProvider
- model_id: str
- name: str
- provider_name: str | None = None
+def _provider_env_name(provider: str) -> str:
+ return provider.upper().replace("-", "_")
+
+
+USER_AGENT = f"bub/{__version__}"
+"""Sent to model providers instead of Republic's default."""
class CustomProvider(BaseModel):
- """A named endpoint that speaks one of any-llm's provider APIs."""
+ """A named endpoint that speaks a registered Republic provider API."""
- type: LLMProvider
+ type: str
api_key: str | None = None
api_base: str | None = None
+ @field_validator("type")
+ @classmethod
+ def registered_type(cls, value: str) -> str:
+ provider = value.lower()
+ if provider not in republic.all_providers():
+ raise ValueError(f"Unknown Republic provider type: {value!r}")
+ return provider
+
class ProviderSpecificEnvSource(PydanticBaseSettingsSource):
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
@@ -54,11 +61,12 @@ def __call__(self) -> dict[str, Any]:
@staticmethod
def _provider_specific(setting_name: str) -> dict[str, str]:
+ env_names = {_provider_env_name(name): name for name in republic.all_providers()}
setting_regex = re.compile(rf"^BUB_(.+)_{setting_name.upper()}$")
result: dict[str, str] = {}
for key, value in os.environ.items():
if match := setting_regex.match(key):
- result[match.group(1).lower()] = value
+ result[env_names.get(match.group(1), match.group(1).lower())] = value
return result
@@ -107,36 +115,53 @@ def default_dict_args(cls, value: Any) -> Any:
def valid_command_prefix(cls, value: str) -> str:
return validate_command_prefix(value)
+ def chat_model(self, model: str | None = None, *, reasoning_effort: str | None = None) -> BubModel:
+ """Build the Republic model for ``model`` (the configured model by default).
+
+ Fallback models apply only to the configured model. ``reasoning_effort``
+ overrides the one in ``completion_args``.
+ """
+ options = dict(self.completion_args)
+ if reasoning_effort is not None:
+ options["reasoning_effort"] = reasoning_effort
+ return BubModel(
+ model or self.model,
+ fallbacks=(self.fallback_models or []) if (model or self.model) == self.model else (),
+ providers={name: provider.type for name, provider in self.providers.items()},
+ client_options=self.model_client_kwargs,
+ options=options,
+ max_tokens=self.max_tokens,
+ timeout=self.model_timeout_seconds,
+ )
+
def model_candidates(self, model: str) -> list[ModelCandidate]:
- candidate_names = [model]
- if model == self.model:
- candidate_names.extend(self.fallback_models or [])
-
- candidates: list[ModelCandidate] = []
- for candidate in candidate_names:
- prefix, sep, rest = candidate.partition(":")
- if sep and (custom := self.providers.get(prefix)):
- candidates.append(
- ModelCandidate(provider=custom.type, model_id=rest, name=candidate, provider_name=prefix)
- )
- continue
- provider, model_id = AnyLLM.split_model_provider(candidate)
- candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=candidate))
- return candidates
+ return self.chat_model().candidates(model)
def model_client_kwargs(self, provider: str) -> dict[str, Any]:
custom = self.providers.get(provider)
+ provider = provider if custom else provider.lower()
+ options = dict(self.client_args)
+ # Headers from client_args, including a User-Agent, take precedence.
+ options["headers"] = {"User-Agent": USER_AGENT, **(options.get("headers") or {})}
+ prefix = _provider_env_name(provider)
+ options.setdefault("env_prefix", f"BUB_{prefix}")
return {
- **self.client_args,
- "api_key": (custom and custom.api_key) or self._provider_value(self.api_key, provider),
- "api_base": (custom and custom.api_base) or self._provider_value(self.api_base, provider),
+ **options,
+ "api_key": (custom and custom.api_key) or self._credential_value(self.api_key, provider, "api_key", prefix),
+ "api_base": (custom and custom.api_base)
+ or self._credential_value(self.api_base, provider, "api_base", prefix),
}
+ @classmethod
+ def _credential_value(
+ cls, value: str | dict[str, str] | None, provider: str, setting: str, prefix: str
+ ) -> str | None:
+ explicit = cls._provider_value(value, provider)
+ return explicit or os.getenv(f"{prefix}_{setting.upper()}", explicit)
+
@staticmethod
def _provider_value(value: str | dict[str, str] | None, provider: str) -> str | None:
- if isinstance(value, dict):
- return value.get(provider)
- return value
+ return value.get(provider) if isinstance(value, dict) else value
@property
def home(self) -> pathlib.Path:
diff --git a/src/bub/builtin/shell_manager.py b/src/bub/builtin/shell_manager.py
index 9cebde9d..e6bea2a8 100644
--- a/src/bub/builtin/shell_manager.py
+++ b/src/bub/builtin/shell_manager.py
@@ -7,8 +7,7 @@
from contextvars import ContextVar
from dataclasses import dataclass, field
-from bub.builtin.environment import LocalEnvironment
-from bub.environment import Environment, Process
+from bub.environment import Environment, LocalEnvironment, Process
@dataclass(eq=False)
diff --git a/src/bub/builtin/spill.py b/src/bub/builtin/spill.py
index c024e243..2ebea5d9 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.tape import Tape, TapeEntry, TapeQuery
+from bub.core.tools import ToolContext, tool
from bub.errors import BubError, ErrorKind
-from bub.tape import Tape, TapeEntry, TapeQuery
-from bub.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
new file mode 100644
index 00000000..2da18e2c
--- /dev/null
+++ b/src/bub/builtin/store.py
@@ -0,0 +1,87 @@
+"""File tape store with the newest-first, deduplicated and fuzzy text search used by the builtin tools."""
+
+from __future__ import annotations
+
+import re
+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
+
+WORD_PATTERN = re.compile(r"[a-z0-9_/-]+")
+MIN_FUZZY_QUERY_LENGTH = 3
+MIN_FUZZY_SCORE = 80
+MAX_FUZZY_CANDIDATES = 128
+
+
+class FuzzyFileTapeStore(FileTapeStore):
+ """:class:`~bub.core.store.FileTapeStore` whose text queries also match approximately."""
+
+ def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]:
+ if not query._query:
+ result: Iterable[TapeEntry] = super().fetch_all(query)
+ return result
+ unlimited_query = replace(query, _limit=None)
+ entries: Iterable[TapeEntry] = super().fetch_all(unlimited_query)
+ return self._filter_entries(list(entries), query._query, query._limit or 20)
+
+ def _filter_entries(self, entries: list[TapeEntry], query: str, limit: int) -> list[TapeEntry]:
+ normalized_query = query.strip().lower()
+ if not normalized_query:
+ return []
+ results: list[TapeEntry] = []
+ seen: set[str] = set()
+
+ count = 0
+ for entry in reversed(entries):
+ payload_text = get_entry_text(entry).lower()
+ if payload_text in seen:
+ continue
+ seen.add(payload_text)
+
+ if normalized_query in payload_text or self._is_fuzzy_match(normalized_query, payload_text):
+ results.append(entry)
+ count += 1
+ if count >= limit:
+ break
+ return results
+
+ @staticmethod
+ def _is_fuzzy_match(normalized_query: str, payload_text: str) -> bool:
+ from rapidfuzz import fuzz, process
+
+ if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH:
+ return False
+
+ query_tokens = WORD_PATTERN.findall(normalized_query)
+ if not query_tokens:
+ return False
+ query_phrase = " ".join(query_tokens)
+ window_size = len(query_tokens)
+
+ source_tokens = WORD_PATTERN.findall(payload_text)
+ if not source_tokens:
+ return False
+
+ candidates: list[str] = []
+ for token in source_tokens:
+ candidates.append(token)
+ if len(candidates) >= MAX_FUZZY_CANDIDATES:
+ break
+
+ if window_size > 1:
+ max_window_start = len(source_tokens) - window_size + 1
+ for idx in range(max(0, max_window_start)):
+ candidates.append(" ".join(source_tokens[idx : idx + window_size]))
+ if len(candidates) >= MAX_FUZZY_CANDIDATES:
+ break
+
+ best_match = process.extractOne(
+ query_phrase,
+ candidates,
+ scorer=fuzz.WRatio,
+ score_cutoff=MIN_FUZZY_SCORE,
+ )
+ return best_match is not None
diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py
index b3391cbb..770d9d1d 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 bub.tools import REGISTRY, Tool, ToolContext, tool
if TYPE_CHECKING:
- from bub.builtin.agent import Agent
- from bub.tape import Tape
+ from bub.builtin.agent import BubAgent
+ from bub.core.tape import Tape
DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
DEFAULT_HEADERS = {"accept": "text/markdown"}
@@ -141,10 +141,10 @@ def _raise_for_failed_shell(returncode: int | None, output: str) -> None:
raise RuntimeError(f"command exited with code {returncode}\noutput:\n{body}")
-def _get_agent(context: ToolContext) -> Agent:
+def _get_agent(context: ToolContext) -> BubAgent:
if "_runtime_agent" not in context.state:
raise RuntimeError("no runtime agent found in tool context")
- return cast("Agent", context.state["_runtime_agent"])
+ return cast("BubAgent", context.state["_runtime_agent"])
class SearchInput(BaseModel):
@@ -458,7 +458,9 @@ async def tape_search(param: SearchInput, *, context: ToolContext) -> TapeSearch
@tool(context=True, name="tape.reset", renderer=lambda result: result["message"])
async def tape_reset(archive: bool = False, *, context: ToolContext) -> dict[str, str]:
"""Reset the current tape, optionally archiving it."""
- return {"message": await context.tape.reset(archive=archive)}
+ from bub.builtin.archive import archive_dir, reset_tape
+
+ return {"message": await reset_tape(context.tape, archive_to=archive_dir() if archive else None)}
@tool(context=True, name="tape.handoff", renderer=lambda result: f"anchor added: {result['anchor']}")
diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py
index b13881fd..c4caca2a 100644
--- a/src/bub/channels/cli/__init__.py
+++ b/src/bub/channels/cli/__init__.py
@@ -3,6 +3,7 @@
import math
from collections.abc import AsyncGenerator, AsyncIterable, Callable
from datetime import datetime
+from functools import cached_property
from hashlib import md5
from pathlib import Path
from time import monotonic
@@ -25,8 +26,7 @@
from rich.tree import Tree
import bub
-from bub.builtin.agent import Agent
-from bub.builtin.commands import strip_command_prefix
+from bub.builtin.agent import BubAgent
from bub.channels.admission import AdmitDecision, TurnSnapshot
from bub.channels.base import Interface
from bub.channels.cli.ansi_bridge import render_to_ansi
@@ -38,10 +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 EventBus, ToolCallFinished, ToolCallStarted
+from bub.core.tape import TapeInfo, session_tape_name
+from bub.core.tools import REGISTRY
from bub.envelope import Envelope, field_of
from bub.streaming import StreamEvent
-from bub.tape import TapeInfo
-from bub.tools import REGISTRY, tool_call_reporter
_GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment]
_PROMPT_REFRESH_INTERVAL: float = SPINNERS["dots"]["interval"] / 1000.0 # type: ignore[operator]
@@ -183,22 +185,30 @@ async def _print(self, *args: Any, **kwargs: Any) -> None:
await self._presenter.write(lambda: self._console.print(*args, **kwargs))
-class _CliToolCallReporter:
+class _CliToolCallRenderer:
def __init__(self, renderer: CliRenderer, presenter: TerminalPresenter) -> None:
self._renderer = renderer
self._presenter = presenter
- async def start(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
- await self._presenter.write(lambda: self._renderer.tool_call_start(name=name, args=args, kwargs=kwargs))
+ def subscribe(self, events: EventBus) -> None:
+ events.on(ToolCallStarted, self.on_before_tool_call)
+ events.on(ToolCallFinished, self.on_after_tool_call)
- async def success(self, name: str, result: object, elapsed_ms: float) -> None:
- await self._presenter.write(
- lambda: self._renderer.tool_call_success(name=name, result=result, elapsed_ms=elapsed_ms)
- )
+ async def on_before_tool_call(self, event: ToolCallStarted) -> None:
+ call = event.call
+ await self._presenter.write(lambda: self._renderer.tool_call_start(name=call.tool, kwargs=call.arguments))
- async def error(self, name: str, error: BaseException, elapsed_ms: float) -> None:
+ async def on_after_tool_call(self, event: ToolCallFinished) -> None:
+ result = event.result
+ if result.error is None:
+ await self._presenter.write(
+ lambda: self._renderer.tool_call_success(name=result.tool, elapsed_ms=result.duration_ms)
+ )
+ return
+ # Show the exception the tool raised rather than the executor's wrapping error.
+ error = result.error.__cause__ or result.error
await self._presenter.write(
- lambda: self._renderer.tool_call_error(name=name, error=error, elapsed_ms=elapsed_ms)
+ lambda: self._renderer.tool_call_error(name=result.tool, error=error, elapsed_ms=result.duration_ms)
)
@@ -209,9 +219,9 @@ class CliChannel(Interface):
_stop_event: asyncio.Event
_initial_prompt: str | None = None
- def __init__(self, on_receive: MessageHandler, agent: Agent) -> None:
+ def __init__(self, on_receive: MessageHandler, get_agent: Callable[[], BubAgent], workspace: Path) -> None:
self._on_receive = on_receive
- self._agent = agent
+ self._get_agent = get_agent
self._message_template = {
"chat_id": "cli_chat",
"channel": self.name,
@@ -227,15 +237,25 @@ def __init__(self, on_receive: MessageHandler, agent: Agent) -> None:
self._presenter = TerminalPresenter()
self._last_tape_info: TapeInfo | None = None
self._last_token_speed: float | None = None
- self._workspace = self._agent.framework.workspace
- self._prompt = self._build_prompt(self._workspace)
+ self._workspace = workspace
+
+ @cached_property
+ def _agent(self) -> BubAgent:
+ """The agent, created on first use, inside the framework's lifespan."""
+ agent = self._get_agent()
+ _CliToolCallRenderer(self._renderer, self._presenter).subscribe(agent.events)
+ return agent
+
+ @cached_property
+ def _prompt(self) -> PromptSession[str]:
+ return self._build_prompt(self._workspace)
def _suppress_logs(self) -> None:
with contextlib.suppress(ValueError):
logger.remove()
async def _refresh_tape_info(self) -> None:
- tape = self._agent.tape.session_tape(self._message_template["session_id"], self._workspace)
+ tape = self._agent.tape(session_tape_name(self._message_template["session_id"], self._workspace))
info = await tape.info()
self._last_tape_info = info
@@ -388,12 +408,11 @@ async def stream_events(
self._stream_printer = printer
self._invalidate_prompt()
try:
- with tool_call_reporter(_CliToolCallReporter(self._renderer, self._presenter)):
- async for event in stream:
- if event.kind == "usage":
- self._update_token_speed(event.data)
- if await printer.render(event):
- yield event
+ async for event in stream:
+ if event.kind == "usage":
+ self._update_token_speed(event.data)
+ if await printer.render(event):
+ yield event
finally:
try:
await printer.finish()
@@ -404,7 +423,7 @@ async def stream_events(
def _update_token_speed(self, data: dict[str, Any]) -> None:
usage = data.get("usage")
- tokens = usage.get("completion_tokens", usage.get("output_tokens")) if isinstance(usage, dict) else None
+ tokens = usage.get("output_tokens") if isinstance(usage, dict) else None
elapsed = data.get("elapsed_seconds")
self._last_token_speed = None
if (
diff --git a/src/bub/channels/cli/renderer.py b/src/bub/channels/cli/renderer.py
index f0f0248c..feb9251b 100644
--- a/src/bub/channels/cli/renderer.py
+++ b/src/bub/channels/cli/renderer.py
@@ -49,10 +49,10 @@ def input_echo(self, prompt: str, text: str, steering: bool = False) -> None:
mid = "[grey](steering)[/] " if steering else ""
self.console.print(f"[dim][bold]{prompt}[/]{mid}{text}[/]", new_line_start=True)
- def tool_call_start(self, *, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
- self.console.print(Text(_format_tool_call(name, args, kwargs), style="magenta"), new_line_start=True)
+ def tool_call_start(self, *, name: str, kwargs: dict[str, Any]) -> None:
+ self.console.print(Text(_format_tool_call(name, kwargs), style="magenta"), new_line_start=True)
- def tool_call_success(self, *, name: str, result: Any, elapsed_ms: float) -> None:
+ def tool_call_success(self, *, name: str, elapsed_ms: float) -> None:
self._tool_status(f"completed in {elapsed_ms:.0f} ms", style="green")
def tool_call_error(self, *, name: str, error: BaseException, elapsed_ms: float) -> None:
@@ -89,8 +89,8 @@ def _tool_status(self, label: str, *, style: str) -> None:
self.console.print(Text(f" ⎿ {label}", style=style), highlight=False)
-def _format_tool_call(name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
- params = _format_tool_params(args, kwargs)
+def _format_tool_call(name: str, kwargs: dict[str, Any]) -> str:
+ params = [f"{key}: {_format_tool_value(value)}" for key, value in kwargs.items()]
if not params:
return f"● {name}()"
@@ -102,15 +102,6 @@ def _format_tool_call(name: str, args: tuple[Any, ...], kwargs: dict[str, Any])
return _truncate(f"● {name}(\n{body}\n)", max_chars=MAX_TOOL_CALL_CHARS)
-def _format_tool_params(args: tuple[Any, ...], kwargs: dict[str, Any]) -> list[str]:
- params: list[str] = []
- for index, value in enumerate(args, start=1):
- params.append(f"arg{index}: {_format_tool_value(value)}")
- for key, value in kwargs.items():
- params.append(f"{key}: {_format_tool_value(value)}")
- return params
-
-
def _format_tool_payload(payload: Any, *, max_chars: int = MAX_TOOL_PAYLOAD_CHARS) -> str:
return _format_tool_value(payload, max_chars=max_chars, indent=2)
diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py
index d2cb5675..14bd6657 100644
--- a/src/bub/channels/manager.py
+++ b/src/bub/channels/manager.py
@@ -9,6 +9,7 @@
from pydantic_settings import SettingsConfigDict
from bub import config
+from bub.application import Outcome, WorkChanged
from bub.channels.admission import AdmitDecision, SessionTurnController, TurnSnapshot
from bub.channels.base import Channel, Interface, Lifecycle
from bub.channels.contracts import MessageHandler
@@ -44,7 +45,7 @@ class ChannelSettings(Settings):
stream_output: bool = Field(default=False, description="Whether to stream model output to channels in real-time.")
-# A tool call runs as a child task of ToolExecutor.execute_async's own asyncio.gather(),
+# A tool call runs as a child task of ToolExecutor.execute_many's own asyncio.gather(),
# so asyncio.current_task() inside one is not the _run_message task quit() needs to
# spare. This contextvar, set once per turn, survives that gather boundary.
_owning_task: contextvars.ContextVar[asyncio.Task | None] = contextvars.ContextVar(
@@ -68,6 +69,8 @@ def __init__(
else:
self._enabled_channels = self._settings.enabled_channels.split(",")
self._messages = asyncio.Queue[ChannelMessage]()
+ self._admitting = False
+ self._work_publication: asyncio.Task[WorkChanged] | None = None
self._started_channels: list[Channel] | None = None
self._session_controllers: dict[str, SessionTurnController] = {}
self._session_handlers: dict[str, MessageHandler] = {}
@@ -82,16 +85,36 @@ async def on_receive(self, message: ChannelMessage) -> None:
handler: MessageHandler
if self._channels[channel].needs_debounce:
handler = BufferedMessageHandler(
- self._messages.put,
+ self._enqueue,
active_time_window=self._settings.active_time_window,
max_wait_seconds=self._settings.max_wait_seconds,
debounce_seconds=self._settings.debounce_seconds,
)
else:
- handler = self._messages.put
+ handler = self._enqueue
self._session_handlers[session_id] = handler
await self._session_handlers[session_id](message)
+ async def _enqueue(self, message: ChannelMessage) -> None:
+ await self._messages.put(message)
+ self._admitting = True
+ self._notify_work()
+
+ def _notify_work(self, completed: Outcome | None = None) -> None:
+ if self._started_channels is None:
+ return
+ busy = self._admitting or any(controller.active_tasks for controller in self._session_controllers.values())
+ event = WorkChanged(busy, completed)
+ previous = self._work_publication
+
+ async def publish() -> WorkChanged:
+ # Preserve snapshot order even when a subscriber awaits.
+ if previous is not None:
+ await previous
+ return await self.framework.events.emit(event)
+
+ self._work_publication = asyncio.create_task(publish())
+
def get_channel(self, name: str) -> Channel | None:
return self._channels.get(name)
@@ -199,16 +222,18 @@ def _drop_empty_controller(self, session_id: str) -> None:
self._session_controllers.pop(session_id, None)
def _on_task_done(self, session_id: str, task: asyncio.Task) -> None:
+ outcome: Outcome
if task.cancelled():
logger.info("channel.manager task cancelled session_id={}", session_id)
+ outcome = "cancelled"
else:
- task.exception() # to log any exception
+ outcome = "failed" if task.exception() is not None else "succeeded"
controller = self._session_controllers.get(session_id)
- if controller is None:
- return
- controller.active_tasks.discard(task)
- self._schedule_pending(session_id)
- self._drop_empty_controller(session_id)
+ if controller is not None:
+ controller.active_tasks.discard(task)
+ self._schedule_pending(session_id)
+ self._drop_empty_controller(session_id)
+ self._notify_work(outcome)
async def _admit_message(self, message: ChannelMessage) -> bool:
try:
@@ -349,13 +374,15 @@ async def listen_and_run(self) -> None:
for channel in self.enabled_channels():
await channel.start(stop_event)
started_channels.append(channel)
+ self._notify_work()
logger.info("channel.manager started listening")
try:
while True:
message = await wait_until_stopped(self._messages.get(), stop_event)
- if not await self._admit_message(message):
- continue
- self._schedule_message(message)
+ if await self._admit_message(message):
+ self._schedule_message(message)
+ self._admitting = not self._messages.empty()
+ self._notify_work()
except asyncio.CancelledError:
logger.info("channel.manager received shutdown signal")
except Exception:
@@ -367,6 +394,10 @@ async def listen_and_run(self) -> None:
await self.shutdown()
finally:
self._started_channels = None
+ publication = self._work_publication
+ self._work_publication = None
+ if publication is not None:
+ await publication
logger.info("channel.manager stopped")
async def shutdown(self) -> None:
diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py
index 9027a2a3..3fd3f1ef 100644
--- a/src/bub/channels/message.py
+++ b/src/bub/channels/message.py
@@ -9,32 +9,6 @@
type MessageKind = Literal["error", "normal", "command"]
type MediaType = Literal["image", "audio", "video", "document"]
-_AUDIO_FORMAT_TO_MIME_TYPE = {
- "aiff": "audio/aiff",
- "flac": "audio/flac",
- "m4a": "audio/mp4",
- "mp3": "audio/mpeg",
- "ogg": "audio/ogg",
- "wav": "audio/wav",
- "webm": "audio/webm",
-}
-_AUDIO_MIME_TYPE_TO_FORMAT = {
- **{mime_type: audio_format for audio_format, mime_type in _AUDIO_FORMAT_TO_MIME_TYPE.items()},
- "audio/x-aiff": "aiff",
- "audio/x-flac": "flac",
- "audio/x-m4a": "m4a",
- "audio/x-wav": "wav",
-}
-
-
-def audio_format_from_mime_type(mime_type: str) -> str:
- normalized = mime_type.partition(";")[0].strip().lower()
- return _AUDIO_MIME_TYPE_TO_FORMAT.get(normalized, normalized.removeprefix("audio/") or "unknown")
-
-
-def audio_mime_type_from_format(audio_format: str) -> str:
- return _AUDIO_FORMAT_TO_MIME_TYPE.get(audio_format, f"audio/{audio_format}")
-
@dataclass
class MediaItem:
diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py
index c93fa126..c455de4a 100644
--- a/src/bub/channels/telegram.py
+++ b/src/bub/channels/telegram.py
@@ -15,11 +15,11 @@
from telegram.request import HTTPXRequest
from bub import config
-from bub.builtin.commands import strip_command_prefix, validate_command_prefix
from bub.channels.base import Channel
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
diff --git a/src/bub/configure.py b/src/bub/configure.py
index d6a1b2ac..e44c12e1 100644
--- a/src/bub/configure.py
+++ b/src/bub/configure.py
@@ -4,8 +4,13 @@
from pathlib import Path
from typing import Any
+from dotenv import find_dotenv, load_dotenv
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
+# Settings read the environment, so load ``.env`` before any of them is created.
+load_dotenv(find_dotenv(usecwd=True))
+DEFAULT_HOME = Path.home() / ".bub"
+
CONFIG_MAP: dict[str, list[type[BaseSettings]]] = {}
ROOT = ""
MISSING = object()
diff --git a/src/bub/core/__init__.py b/src/bub/core/__init__.py
new file mode 100644
index 00000000..b4d39dea
--- /dev/null
+++ b/src/bub/core/__init__.py
@@ -0,0 +1,74 @@
+"""Bub's agent kernel: agent loop, Republic model, tools, system prompt and tape.
+
+Nothing here reads settings or plugin hooks. The loop reports its progress as events
+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 (
+ AgentEvent,
+ ChatFinished,
+ CommandFinished,
+ EventBus,
+ EventHandler,
+ LLMCallDecision,
+ LLMCallFinished,
+ LLMCallRequest,
+ LLMCallResult,
+ LLMCallStarted,
+ RunFinished,
+ RunStarted,
+ StepFinished,
+ StepStarted,
+ TapeEvent,
+ ToolCall,
+ ToolCallDecision,
+ ToolCallFinished,
+ ToolCallResult,
+ ToolCallStarted,
+)
+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
+
+__all__ = [
+ "Agent",
+ "AgentEvent",
+ "ChatFinished",
+ "ChatModelClient",
+ "CommandFinished",
+ "EventBus",
+ "EventHandler",
+ "LLMCallDecision",
+ "LLMCallFinished",
+ "LLMCallRequest",
+ "LLMCallResult",
+ "LLMCallStarted",
+ "Model",
+ "ModelClient",
+ "PromptContext",
+ "PromptSection",
+ "RunFinished",
+ "RunStarted",
+ "StepFinished",
+ "StepStarted",
+ "SystemPrompt",
+ "Tape",
+ "TapeContext",
+ "TapeEvent",
+ "TapeRecorder",
+ "Tool",
+ "ToolCall",
+ "ToolCallDecision",
+ "ToolCallFinished",
+ "ToolCallResult",
+ "ToolCallStarted",
+ "ToolContext",
+ "default_tape_context",
+ "events_from",
+ "session_tape_name",
+ "tool",
+]
diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py
new file mode 100644
index 00000000..1b1bc5c2
--- /dev/null
+++ b/src/bub/core/agent.py
@@ -0,0 +1,784 @@
+"""Agent loop over tools, a system prompt and a tape store, calling its model or the one given to a run."""
+
+from __future__ import annotations
+
+import sys
+import time
+from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence
+from contextlib import AsyncExitStack, aclosing, asynccontextmanager
+from dataclasses import asdict, replace
+from datetime import UTC, datetime
+from json import JSONDecodeError
+from typing import Any, TypeVar
+
+import republic
+from loguru import logger
+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.events import (
+ AgentEvent,
+ ChatFinished,
+ CommandFinished,
+ EventBus,
+ LLMCallFinished,
+ LLMCallRequest,
+ LLMCallResult,
+ LLMCallStarted,
+ RunFinished,
+ RunStarted,
+ StepFinished,
+ StepStarted,
+ TapeEvent,
+)
+from bub.core.model import Model, ModelClient, is_context_length_error, model_client
+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.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.errors import BubError, ErrorKind
+from bub.prompt import UserContent
+from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
+from bub.tracing import Span, current_span
+from bub.turn import TurnState
+
+MAX_AUTO_HANDOFF_RETRIES = 1
+
+E = TypeVar("E", bound=AgentEvent)
+T = TypeVar("T", bound=TapeEvent)
+
+
+class Agent:
+ """Run the agent loop: call the model, execute its tool calls, repeat until it answers.
+
+ 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:
+
+ Args:
+ model: Default model of runs not given one; None requires each run to give one.
+ store: Tape store holding conversations; each run works on one named tape in it.
+ Defaults to an in-memory store.
+ tools: Tools the model may call. Bub tools are kept by name in ``tools``;
+ ``republic.tools`` built-in tools, which the provider runs itself, are kept in
+ ``builtin_tools`` and sent with every model call.
+ system_prompt: System prompt sections: text, or functions of the step's
+ :class:`PromptContext` (prompt, tools, tape, state, agent) returning text.
+ Add more with ``agent.system_prompt.add``.
+ context: How a tape's entries become model context; defaults to
+ :func:`default_tape_context`, which replays the recorded chat.
+ sidecars: Sidecar capabilities mounted on every tape.
+ max_steps: Maximum model calls in one run.
+ max_tool_concurrency: Maximum tool calls from one model response that run at
+ once. None runs them all concurrently; 1 runs them one at a time, in order.
+ name: Agent name, reported in ``invoke_agent`` traces.
+ 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``;
+ register handlers with ``agent.events.on``. A :class:`TapeRecorder`, registered first,
+ records them on the run's tape. ``LLMCallStarted`` and ``ToolCallStarted`` handlers
+ may change or short-circuit the call.
+
+ Subclasses customize a run by overriding :meth:`prepare_tools`,
+ :meth:`pending_messages`, :meth:`has_pending_messages` and :meth:`continue_prompt`,
+ each of which receives the run's tape.
+ """
+
+ def __init__(
+ self,
+ *,
+ model: Model | None = None,
+ store: TapeStore | AsyncTapeStore | None = None,
+ tools: Iterable[Tool | BuiltinTool] = (),
+ system_prompt: SystemPrompt | PromptSection | Iterable[PromptSection] = (),
+ context: TapeContext | None = None,
+ sidecars: Sequence[TapeSidecar] = (),
+ max_steps: int = sys.maxsize,
+ max_tool_concurrency: int | None = None,
+ name: str = "agent",
+ command_prefix: str | None = None,
+ ) -> None:
+ self.model = model
+ self.tools: dict[str, Tool] = {}
+ self.builtin_tools: list[BuiltinTool] = []
+ for tool in tools:
+ if isinstance(tool, Tool):
+ self.tools[tool.name] = tool
+ else:
+ self.builtin_tools.append(tool)
+ self.system_prompt = SystemPrompt.of(system_prompt)
+ if store is None:
+ store = InMemoryTapeStore()
+ self._tapes = Tape(
+ store if is_async_tape_store(store) else AsyncTapeStoreAdapter(store),
+ context or default_tape_context(),
+ sidecars=tuple(sidecars),
+ )
+ self.max_steps = max_steps
+ if max_tool_concurrency is not None and max_tool_concurrency < 1:
+ raise ValueError("max_tool_concurrency must be a positive integer or None")
+ self.max_tool_concurrency = max_tool_concurrency
+ self.name = name
+ self.command_prefix = None if command_prefix is None else validate_command_prefix(command_prefix)
+ self.events = EventBus()
+ TapeRecorder().subscribe(self.events)
+
+ def tape(self, name: str, *, state: TurnState | None = None) -> Tape:
+ """Return tape ``name`` in the agent's store, with ``state`` as its turn state.
+
+ Use it outside runs, for example to read a conversation or add an anchor; it
+ writes to the store directly. Runs work on a fork instead (see :meth:`session`).
+ """
+ tapes = self._tapes
+ return tapes.scoped(name, context=replace(tapes.context, state={} if state is None else state))
+
+ @asynccontextmanager
+ async def session(
+ self, name: str, *, state: TurnState | None = None, merge_back: bool = True
+ ) -> AsyncGenerator[Tape]:
+ """Open a fork of tape ``name`` for one run, with ``state`` as its turn state.
+
+ The fork merges back on exit unless ``merge_back`` is false.
+ """
+ async with self.tape(name, state=state).fork_tape(merge_back=merge_back) as tape:
+ await tape.ensure_bootstrap_anchor()
+ yield tape
+
+ @asynccontextmanager
+ async def run(
+ self,
+ prompt: str | list[UserContent],
+ *,
+ model: Model | None = None,
+ options: Mapping[str, Any] | None = None,
+ tape: str,
+ state: TurnState | None = None,
+ merge_back: bool = True,
+ metadata: dict[str, Any] | None = None,
+ ) -> AsyncGenerator[AsyncStreamEvents]:
+ """Run ``model``, or the agent's model, on tape ``tape``; iterate the yielded stream to drive the loop.
+
+ ::
+
+ async with agent.run(prompt, tape="session") as stream:
+ async for event in stream:
+ ...
+
+ Leaving the block closes the stream, even after an early exit: the tape fork
+ merges back and the trace span ends. See :meth:`start` for the parameters.
+ """
+ stream = await self.start(
+ prompt, model=model, options=options, tape=tape, state=state, merge_back=merge_back, metadata=metadata
+ )
+ async with aclosing(stream):
+ yield stream
+
+ async def start(
+ self,
+ prompt: str | list[UserContent],
+ *,
+ model: Model | None = None,
+ options: Mapping[str, Any] | None = None,
+ tape: str,
+ state: TurnState | None = None,
+ merge_back: bool = True,
+ metadata: dict[str, Any] | None = None,
+ ) -> AsyncStreamEvents:
+ """Start a run on tape ``tape`` and return its stream; the caller must close it.
+
+ Prefer :meth:`run`; use this to hand the stream to code that consumes and closes it.
+
+ The run calls ``model``, or the agent's ``model`` when it is None; a run that
+ calls a model without either raises ``ValueError``.
+
+ ``options`` are ``republic.ChatOptions`` (such as ``max_tokens`` or ``temperature``)
+ sent with every model call of the run.
+
+ The run works on a fork of the tape (see :meth:`session`) that stays open until
+ the stream closes, even if it is never consumed. ``state`` is the run's mutable
+ turn state, carried by events and tools; ``metadata`` is attached to the
+ ``RunStarted`` event. Each model step ends with a ``final`` event; the run ends
+ when a step makes no tool calls and no messages are pending. A context-length
+ error triggers one automatic tape handoff and a retry of the prompt. An empty
+ prompt yields an error without opening the tape. The run is traced as an
+ ``invoke_agent`` span.
+ """
+ span = Span(
+ f"invoke_agent {self.name}",
+ {
+ "gen_ai.operation.name": "invoke_agent",
+ "gen_ai.agent.name": self.name,
+ "gen_ai.conversation.id": (state or {}).get("session_id", tape),
+ },
+ )
+ stack = AsyncExitStack()
+ if isinstance(prompt, str):
+ prompt = [prompt]
+ try:
+ with span.activate():
+ span.messages("gen_ai.input.messages", [republic.user(*prompt)])
+ if not any(prompt):
+ return _fixed_stream("error: empty prompt", ok=False, span=span, on_close=stack.aclose)
+ session = await stack.enter_async_context(self.session(tape, state=state, merge_back=merge_back))
+ return await self.run_on(
+ session, prompt, model=model, options=options, metadata=metadata, span=span, on_close=stack.aclose
+ )
+ except BaseException as exc:
+ span.fail(exc)
+ try:
+ with span.activate():
+ await stack.aclose()
+ finally:
+ span.end()
+ raise
+
+ async def run_on(
+ self,
+ tape: Tape,
+ prompt: list[UserContent],
+ *,
+ model: Model | None = None,
+ options: Mapping[str, Any] | None = None,
+ metadata: dict[str, Any] | None = None,
+ span: Span | None = None,
+ on_close: Callable[[], Awaitable[None]] | None = None,
+ ) -> AsyncStreamEvents:
+ """Start a run on an already open tape; the caller owns the tape's lifecycle.
+
+ A command prompt (see :meth:`command_line`) runs as a command instead, without calling the model.
+ ``span`` traces the run's stream, and ``on_close`` runs after the stream closes.
+ """
+ if (line := self.command_line(prompt)) is not None:
+ return _fixed_stream(await self.run_command(tape, line), ok=True, span=span, on_close=on_close)
+ client = self._model_client(model)
+ started = time.monotonic()
+ await self._emit(RunStarted, tape, model=client.spec, prompt=prompt, metadata=dict(metadata or {}))
+ state = StreamState()
+ tracker = _RunTracker(span)
+
+ async def close() -> None:
+ try:
+ elapsed_ms = int((time.monotonic() - started) * 1000)
+ await self._emit(
+ RunFinished,
+ tape,
+ status=tracker.status,
+ steps=tracker.steps,
+ elapsed_ms=elapsed_ms,
+ error=tracker.error,
+ )
+ finally:
+ if on_close is not None:
+ await on_close()
+
+ return AsyncStreamEvents(
+ self._run_steps(tape, client, dict(options or {}), prompt, state, tracker),
+ state=state,
+ on_close=close,
+ span=span,
+ )
+
+ async def run_once(
+ self,
+ prompt: list[UserContent] | None,
+ *,
+ model: Model | None = None,
+ options: Mapping[str, Any] | None = None,
+ tape: Tape,
+ state: StreamState | None = None,
+ ) -> AsyncGenerator[StreamEvent, None]:
+ """Make one call to ``model``, or the agent's model, on an open tape and execute the tool calls it returns.
+
+ ``prompt`` is the new user message; ``None`` continues the existing conversation.
+
+ ``state``, when given, receives the call's usage.
+ """
+ client = self._model_client(model)
+ options = dict(options or {})
+ if state is None:
+ state = StreamState()
+ turn_state = tape.context.state
+ tools = await self.prepare_tools(tape, prompt)
+ prompt_context = PromptContext(agent=self, tape=tape, prompt=prompt or [], tools=tools, model=client.spec)
+ system_prompt = await self.system_prompt.render(prompt_context) or None
+ if (span := current_span()) and span.recording:
+ span.set(**{"gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in tools]})
+ pending = await self.pending_messages(tape)
+
+ run_id = generate_run_id()
+ messages, new_messages = await self._build_messages(
+ tape, model=client.spec, run_id=run_id, system_prompt=system_prompt, prompt=prompt, pending=pending
+ )
+ request = LLMCallRequest(
+ run_id=run_id,
+ model=client.spec,
+ messages=messages,
+ tool_names=tuple(tool_item.name for tool_item in tools),
+ max_tokens=options.get("max_tokens", client.max_tokens),
+ )
+ before = await self._emit(LLMCallStarted, tape, request=request)
+ request, decision = before.request, before.decision
+ if decision is not None:
+ await self._emit(
+ ChatFinished,
+ tape,
+ run_id=run_id,
+ model=request.model,
+ system_prompt=system_prompt,
+ new_messages=new_messages,
+ response_text=decision.text,
+ )
+ yield StreamEvent("text", {"delta": decision.text})
+ yield StreamEvent("final", {"ok": True, "text": decision.text})
+ return
+ completion_started = time.monotonic()
+ async with self._traced_completion(tape, client, options, request, tools, state) as (
+ provider,
+ completion,
+ events,
+ ):
+ async for event in events:
+ yield event
+ response = completion.response
+ completion_elapsed = time.monotonic() - completion_started
+ text = response.text or response.refusal or ""
+
+ yield StreamEvent("usage", {"usage": state.usage, "elapsed_seconds": completion_elapsed})
+
+ tool_calls = response.tool_calls
+ if tool_calls:
+ tool_map = {tool_item.name: tool_item for tool_item in tools}
+ serialized_tool_calls = [asdict(tool_call) for tool_call in tool_calls]
+ tool_invocations = [_tool_invocation(tool_call, tool_map) for tool_call in tool_calls]
+ yield StreamEvent("tool_call", {"tool_calls": serialized_tool_calls})
+ turn_state["_runtime_tool_names"] = tuple(tool_map)
+ context = ToolContext(tape=tape, run_id=run_id, state=turn_state, events=self.events)
+ execution = await ToolExecutor(events=self.events, max_concurrency=self.max_tool_concurrency).execute_many(
+ tool_invocations,
+ context=context,
+ call_ids=[call.id for call in tool_calls],
+ )
+ tool_results = execution.tool_results
+ else:
+ serialized_tool_calls, tool_results = [], None
+ assistant_fields = {
+ key: value
+ for key, value in {
+ "reasoning": response.reasoning,
+ "provider_data": [
+ asdict(part) for part in response.message.parts if isinstance(part, republic.ProviderData)
+ ],
+ }.items()
+ if value
+ }
+ await self._emit(
+ ChatFinished,
+ tape,
+ run_id=run_id,
+ model=request.model,
+ system_prompt=system_prompt,
+ new_messages=new_messages,
+ response_text=(text or None) if tool_calls else text,
+ tool_calls=serialized_tool_calls,
+ tool_results=tool_results,
+ usage=state.usage,
+ provider=provider,
+ assistant_fields=assistant_fields,
+ )
+ if tool_calls:
+ yield StreamEvent("tool_result", {"tool_results": tool_results})
+ yield StreamEvent("final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": tool_results})
+ else:
+ yield StreamEvent("final", {"ok": True, "text": text})
+
+ def _model_client(self, model: Model | None) -> ModelClient:
+ if model is None:
+ model = self.model
+ if model is None:
+ raise ValueError("no model to run: pass model to the run or to the agent")
+ return model_client(model)
+
+ def command_line(self, prompt: list[UserContent]) -> str | None:
+ """Return the command of a text prompt starting with the command prefix, or None."""
+ if self.command_prefix is None or len(prompt) != 1 or not isinstance(prompt[0], str):
+ return None
+ return strip_command_prefix(prompt[0], self.command_prefix)
+
+ async def run_command(self, tape: Tape, line: str) -> str:
+ """Run a command on ``tape`` and return its rendered output; record it as a ``command`` event.
+
+ ``name args...`` calls the tool ``name`` with positional and then ``key=value``
+ arguments, positional ones taking the tool's parameters in order. Any other line runs
+ through the ``bash`` tool when the agent has one. Commands call tools without model
+ calls, emitting tool events; the outcome is reported as a ``CommandFinished`` event.
+ """
+ if not line:
+ raise ValueError("empty command")
+ name, tokens = parse_command(line)
+ start = time.monotonic()
+ context = ToolContext(
+ tape=tape, run_id="run_command", state=tape.context.state, command=True, events=self.events
+ )
+ output = ""
+ status = "ok"
+ try:
+ if name not in self.tools:
+ if "bash" not in self.tools:
+ raise ValueError("bash tool is not available") # noqa: TRY301
+ command_tool = self.tools["bash"]
+ arguments = {"command": line}
+ else:
+ command_tool = self.tools[name]
+ arguments = parse_command_args(tokens).bind(list(command_tool.parameters.get("properties", {})))
+ result = await ToolExecutor(self.events, render=False).execute(command_tool, arguments, context=context)
+ output = command_tool.render(result)
+ except Exception as exc:
+ status = "error"
+ output = f"{exc!s}"
+ raise
+ else:
+ return output
+ finally:
+ elapsed_ms = int((time.monotonic() - start) * 1000)
+ await self._emit(
+ CommandFinished, tape, line=line, name=name, status=status, elapsed_ms=elapsed_ms, output=output
+ )
+
+ async def prepare_tools(self, tape: Tape, prompt: list[UserContent] | None) -> list[Tool]:
+ """Return the model-facing tools for the next model call."""
+ return model_tools(self.tools.values())
+
+ async def pending_messages(self, tape: Tape) -> list[list[UserContent]]:
+ """Return user messages that arrived during the run, sent before the next prompt."""
+ return []
+
+ def has_pending_messages(self, tape: Tape) -> bool:
+ """Return whether messages are waiting, which continues the run for another step."""
+ return False
+
+ async def continue_prompt(
+ self, tape: Tape, prompt: list[UserContent] | None, state: StreamState
+ ) -> list[UserContent] | None:
+ """Return a user message for the next step, or None to continue the conversation as is."""
+ return None
+
+ async def _emit(self, event_type: type[T], tape: Tape, **fields: Any) -> T:
+ return await self.events.emit(event_type(state=tape.context.state, tape=tape, **fields))
+
+ async def _run_steps(
+ self,
+ tape: Tape,
+ client: ModelClient,
+ options: dict[str, Any],
+ prompt: list[UserContent],
+ state: StreamState,
+ tracker: _RunTracker,
+ ) -> AsyncGenerator[StreamEvent, None]:
+ auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES
+ next_prompt: list[UserContent] | None = prompt
+ try:
+ for step in range(1, self.max_steps + 1):
+ tracker.steps = step
+ start = time.monotonic()
+ should_continue = False
+ logger.info("loop.step step={} tape={} model={}", step, tape.name, client.spec)
+ await self._emit(StepStarted, tape, step=step, prompt=next_prompt)
+ try:
+ output = self.run_once(next_prompt, model=client, options=options, tape=tape, state=state)
+ async with aclosing(output):
+ async for event in output:
+ tracker.observe(event)
+ yield event
+ if event.kind == "error":
+ await self._record_step(tape, step, start, "error", error=event.data.get("message", ""))
+ elif event.kind == "final":
+ should_continue = bool(event.data.get("tool_calls") or event.data.get("tool_results"))
+ except Exception as exc:
+ error_message = f"{exc!s}"
+ if auto_handoff_remaining > 0 and is_context_length_error(error_message):
+ auto_handoff_remaining -= 1
+ logger.warning(
+ "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}",
+ tape.name,
+ step,
+ )
+ await tape.handoff(
+ name="auto_handoff/context_overflow",
+ state={"reason": "context_length_exceeded", "error": error_message},
+ )
+ await self._record_step(tape, step, start, "auto_handoff", error=error_message)
+ next_prompt = prompt
+ continue
+ await self._record_step(tape, step, start, "error", error=error_message)
+ raise
+
+ if not (should_continue or self.has_pending_messages(tape)):
+ await self._record_step(tape, step, start, "ok")
+ tracker.status = "ok"
+ return
+
+ next_prompt = await self.continue_prompt(tape, next_prompt, state)
+ await self._record_step(tape, step, start, "continue")
+
+ raise RuntimeError(f"max_steps_reached={self.max_steps}") # noqa: TRY301
+ except Exception as exc:
+ tracker.status, tracker.error = "error", f"{exc!s}"
+ raise
+ finally:
+ tracker.end_trace()
+
+ async def _record_step(self, tape: Tape, step: int, start: float, status: str, *, error: str | None = None) -> None:
+ elapsed_ms = int((time.monotonic() - start) * 1000)
+ await self._emit(StepFinished, tape, step=step, status=status, elapsed_ms=elapsed_ms, error=error)
+
+ async def _build_messages(
+ self,
+ tape: Tape,
+ *,
+ model: str,
+ run_id: str,
+ system_prompt: str | None,
+ prompt: list[UserContent] | None,
+ pending: list[list[UserContent]],
+ ) -> tuple[list[republic.Message], list[republic.Message]]:
+ try:
+ messages = await tape.read_messages()
+ except BubError as exc:
+ await self._emit(
+ ChatFinished,
+ tape,
+ run_id=run_id,
+ model=model,
+ system_prompt=system_prompt,
+ new_messages=[],
+ response_text=None,
+ error=exc,
+ context_error=exc,
+ )
+ raise
+ contents = [*pending, *([] if prompt is None else [prompt])]
+ new_messages = [republic.user(*content) for content in contents]
+ if system_prompt:
+ messages = [republic.system(system_prompt), *messages]
+ messages.extend(new_messages)
+ return messages, new_messages
+
+ @asynccontextmanager
+ async def _traced_completion(
+ self,
+ tape: Tape,
+ client: ModelClient,
+ options: dict[str, Any],
+ request: LLMCallRequest,
+ tools: list[Tool],
+ state: StreamState,
+ ) -> AsyncGenerator[tuple[str, republic.Stream[Any], AsyncIterator[StreamEvent]]]:
+ provider, _, model_id = request.model.partition(":")
+ span = Span(
+ f"chat {model_id or request.model}",
+ {
+ "gen_ai.operation.name": "chat",
+ "gen_ai.provider.name": provider,
+ "gen_ai.request.model": model_id or request.model,
+ "gen_ai.request.max_tokens": request.max_tokens,
+ "gen_ai.conversation.id": tape.context.state.get("session_id"),
+ "bub.run_id": request.run_id,
+ "bub.tape": tape.name,
+ },
+ )
+ span.messages("gen_ai.input.messages", request.messages)
+ if span.recording:
+ span.set(**{"gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in tools]})
+ started = datetime.now(UTC)
+ # Partial output for hook errors and cancellation traces.
+ text: list[str] = []
+ calls: list[republic.ToolCall] = []
+
+ try:
+ call_options = {
+ **options,
+ "tools": [
+ *(republic.Tool(item.name, item.description, item.parameters) for item in tools),
+ *self.builtin_tools,
+ ],
+ }
+ if request.max_tokens is not None:
+ call_options["max_tokens"] = request.max_tokens
+ async with AsyncExitStack() as stack:
+ with span.activate():
+ provider, stream = await stack.enter_async_context(
+ client.open_stream(list(request.messages), spec=request.model, options=call_options)
+ )
+ events = await stack.enter_async_context(aclosing(_stream_events(stream, span, state, text, calls)))
+ yield provider, stream, events
+ except BaseException as exc:
+ span.fail(exc)
+ if isinstance(exc, Exception):
+ await self._fire_llm_call_finished(tape, request, "".join(text), calls, state, started, error=exc)
+ raise
+ else:
+ completed = stream.response
+ await self._fire_llm_call_finished(
+ tape, request, completed.text or completed.refusal or "", completed.tool_calls, state, started
+ )
+ finally:
+ usage = state.usage or {}
+ span.set(**{
+ "gen_ai.usage.input_tokens": usage.get("input_tokens"),
+ "gen_ai.usage.output_tokens": usage.get("output_tokens"),
+ })
+ span.messages(
+ "gen_ai.output.messages",
+ [{"role": "assistant", "content": "".join(text), "tool_calls": [asdict(call) for call in calls]}],
+ )
+ span.end()
+
+ async def _fire_llm_call_finished(
+ self,
+ tape: Tape,
+ request: LLMCallRequest,
+ text: str,
+ tool_calls: list[republic.ToolCall],
+ state: StreamState,
+ started: datetime,
+ error: Exception | None = None,
+ ) -> None:
+ duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000)
+ result = LLMCallResult(
+ run_id=request.run_id,
+ text=text or None,
+ tool_calls=[asdict(call) for call in tool_calls],
+ usage=state.usage,
+ error=error,
+ duration_ms=duration_ms,
+ )
+ await self._emit(LLMCallFinished, tape, request=request, result=result)
+
+
+def events_from(items: Iterable[StreamEvent]) -> AsyncStreamEvents:
+ """Stream fixed events."""
+
+ async def generator() -> AsyncIterator[StreamEvent]:
+ for item in items:
+ yield item
+
+ return AsyncStreamEvents(generator())
+
+
+def _fixed_stream(
+ text: str, *, ok: bool, span: Span | None, on_close: Callable[[], Awaitable[None]] | None
+) -> AsyncStreamEvents:
+ """Stream a run that answers ``text`` without calling the model, such as a command."""
+ if span is not None:
+ span.messages("gen_ai.output.messages", [{"role": "assistant", "content": text}])
+
+ async def generator() -> AsyncIterator[StreamEvent]:
+ yield StreamEvent("text", {"delta": text})
+ yield StreamEvent("final", {"text": text, "ok": ok})
+
+ return AsyncStreamEvents(generator(), on_close=on_close, span=span)
+
+
+class _RunTracker:
+ """How a run ended, for ``RunFinished``, and its output messages, for its ``invoke_agent`` span."""
+
+ def __init__(self, span: Span | None) -> None:
+ self.status = "cancelled"
+ self.steps = 0
+ self.error: str | None = None
+ self._span = span
+ self._messages: list[dict[str, Any]] = []
+ self._text: list[str] = []
+ self._calls: list[dict[str, Any]] = []
+
+ def observe(self, event: StreamEvent) -> None:
+ if self._span is None or not self._span.recording:
+ return
+ if event.kind == "text":
+ self._text.append(str(event.data.get("delta", "")))
+ elif event.kind == "tool_call":
+ self._calls = event.data.get("tool_calls", [])
+ self._messages.append({"role": "assistant", "content": "".join(self._text), "tool_calls": self._calls})
+ self._text.clear()
+ elif event.kind == "tool_result":
+ for call, result in zip(self._calls, event.data.get("tool_results", []), strict=False):
+ self._messages.append({"role": "tool", "tool_call_id": call["id"], "content": result})
+ elif event.kind == "error":
+ self._span.fail(RuntimeError(str(event.data.get("message", "agent failed"))))
+
+ def end_trace(self) -> None:
+ if self._span is None:
+ return
+ if self._text:
+ self._messages.append({"role": "assistant", "content": "".join(self._text)})
+ self._span.messages("gen_ai.output.messages", self._messages)
+
+
+def generate_run_id() -> str:
+ return f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}"
+
+
+async def _stream_events(
+ stream: republic.Stream[Any],
+ span: Span,
+ state: StreamState,
+ text: list[str],
+ calls: list[republic.ToolCall],
+) -> AsyncGenerator[StreamEvent, None]:
+ native_events = aiter(stream)
+ while True:
+ with span.activate():
+ try:
+ item = await anext(native_events)
+ except StopAsyncIteration:
+ break
+ match item:
+ case TextDelta(chunk=delta) | RefusalDelta(chunk=delta):
+ text.append(delta)
+ yield StreamEvent("text", {"delta": delta})
+ case ReasoningDelta(chunk=delta):
+ yield StreamEvent("reasoning", {"delta": delta})
+ case ToolCallReady(call=call):
+ calls.append(call)
+ case Completed(response=completed):
+ state.usage = {
+ **asdict(completed.token_usage),
+ "total_tokens": completed.token_usage.total_tokens,
+ }
+ span.set(**{
+ "gen_ai.response.model": completed.model,
+ "gen_ai.response.id": completed.id,
+ "gen_ai.response.finish_reasons": [completed.finish_reason] if completed.finish_reason else None,
+ })
+
+
+def _tool_invocation(tool_call: republic.ToolCall, tool_map: dict[str, Tool]) -> tuple[Tool, dict[str, Any]]:
+ """Resolve a model tool call to (runtime tool, arguments).
+
+ An unknown tool name is not treated as a fatal error: it is surfaced as a
+ placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and
+ ``ToolCallStarted`` handlers can recover it into a guidance
+ ``tool_result`` instead of interrupting the turn. If no hook replaces the
+ call, the placeholder raises a clear tool error rather than succeeding with
+ an empty result.
+ """
+ tool_name = tool_call.name
+ try:
+ arguments = tool_call.args
+ except JSONDecodeError as exc:
+ raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc
+ if not isinstance(arguments, dict):
+ raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.")
+ tool_obj = tool_map.get(tool_name)
+ if tool_obj is None:
+
+ def raise_unknown_tool(**_: Any) -> None:
+ raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.")
+
+ return Tool(name=tool_name, handler=raise_unknown_tool), arguments
+ return tool_obj, arguments
diff --git a/src/bub/core/codemode/__init__.py b/src/bub/core/codemode/__init__.py
new file mode 100644
index 00000000..50062a5c
--- /dev/null
+++ b/src/bub/core/codemode/__init__.py
@@ -0,0 +1,306 @@
+"""Code mode: let the model call tools from Python it writes, through one ``run_code`` tool.
+
+:class:`CodeMode` bundles what an agent needs: the ``run_code`` tool, a system prompt
+section describing the code-callable tools as a Python stub, and the ``attach_*`` media
+tools. It works with a bare :class:`bub.core.Agent`::
+
+ code_mode = CodeMode([search, fetch], models="openai:gpt-5")
+ agent = Agent(model=model, tools=[code_mode.run_code], system_prompt=["You are ...", code_mode.prompt])
+
+Code runs in an :class:`bub.environment.Environment`, a Python process on the host by
+default. Calls the code makes go through the running agent's tool events, so hooks and
+handlers see them with ``context.code_mode`` set.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextvars
+import hashlib
+import mimetypes
+import uuid
+from collections.abc import Callable, Iterable, Mapping
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Any
+
+import republic
+
+from bub.core.codemode.republic_bridge import (
+ REPUBLIC_CALL_PREFIX,
+ ModelResolver,
+ RepublicSession,
+ decode_value,
+ default_model_resolver,
+ encode_value,
+)
+from bub.core.codemode.stub import _identifier, render_tool_stub
+from bub.core.system_prompt import PromptContext
+from bub.core.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools
+from bub.environment import CallTool, CodeFailed, Environment, LocalEnvironment
+from bub.errors import BubError, ErrorKind
+from bub.prompt import UserContent
+from bub.turn import TurnState
+
+__all__ = [
+ "DEFAULT_RUN_CODE_TIMEOUT_SECONDS",
+ "MEDIA_TOOLS",
+ "RUN_CODE_TOOL_NAME",
+ "CodeMode",
+ "ModelResolver",
+ "attach_audio",
+ "attach_image",
+ "attach_video",
+ "default_model_resolver",
+ "render_code_mode_prompt",
+ "render_tool_stub",
+ "write_stub",
+]
+
+RUN_CODE_TOOL_NAME = "run_code"
+DEFAULT_RUN_CODE_TIMEOUT_SECONDS = 120
+
+CodeTools = Iterable[Tool] | Callable[[TurnState], Iterable[Tool] | None]
+"""The tools callable from code: fixed, or chosen per run from the turn state (None disables ``run_code``)."""
+
+
+@dataclass(frozen=True)
+class _CodeRun:
+ environment: Environment
+ media: list[UserContent]
+
+
+_ACTIVE_RUN: contextvars.ContextVar[_CodeRun | None] = contextvars.ContextVar("bub_run_code", default=None)
+
+
+class CodeMode:
+ """The ``run_code`` tool and the system prompt section for a set of code-callable tools.
+
+ Args:
+ tools: Tools callable from code as ``tools.(...)``. A callable receives the
+ turn state and returns the tools for that run, or None when code mode is off.
+ environment: Where the code runs, or a callable returning it for the turn state.
+ Defaults to a :class:`bub.environment.LocalEnvironment` in the working directory.
+ models: What ``republic.get_*model(spec)`` in the code resolves to: a default
+ ``provider:model`` spec for ``get_model()``, or a :data:`ModelResolver`. By default
+ specs must be explicit and clients read credentials from the environment.
+ stub_dir: Write the tool stub to a file in this directory (or the one returned for
+ the turn state) and point the prompt at it, for agents that can read files.
+ None puts the stub in the system prompt itself.
+ media_tools: Also offer ``attach_image``, ``attach_audio`` and ``attach_video`` to
+ the code, which attach media to the ``run_code`` result.
+ name: Name of the ``run_code`` tool.
+ """
+
+ def __init__(
+ self,
+ tools: CodeTools = (),
+ *,
+ environment: Environment | Callable[[TurnState], Environment] | None = None,
+ models: str | ModelResolver | None = None,
+ stub_dir: Path | Callable[[TurnState], Path] | None = None,
+ media_tools: bool = True,
+ name: str = RUN_CODE_TOOL_NAME,
+ ) -> None:
+ self._tools = tools if callable(tools) else list(tools)
+ self._environment = LocalEnvironment() if environment is None else environment
+ self.resolve_model = models if callable(models) else default_model_resolver(models)
+ self.stub_dir = stub_dir
+ self.media_tools = media_tools
+ self.run_code: Tool = Tool.from_callable(
+ self._run_code, name=name, description=_RUN_CODE_DESCRIPTION, context=True, exposure="direct"
+ )
+ """The model-facing tool; add it to the agent's tools."""
+
+ def code_tools(self, state: TurnState) -> list[Tool] | None:
+ """Return the model-safe tools callable from code in this run, or None when code mode is off."""
+ tools = self._tools(state) if callable(self._tools) else self._tools
+ if tools is None:
+ return None
+ result = model_tools(tools)
+ if self.media_tools:
+ names = {tool_item.name for tool_item in result}
+ result.extend(tool_item for tool_item in MEDIA_TOOLS if tool_item.name not in names)
+ return result
+
+ def environment(self, state: TurnState) -> Environment:
+ """Return the environment the code runs in for this run."""
+ return self._environment(state) if callable(self._environment) else self._environment
+
+ def prompt(self, context: PromptContext) -> str | None:
+ """System prompt section describing the code-callable tools; add it to the agent's system prompt."""
+ state = context.state
+ if (tools := self.code_tools(state)) is None:
+ return None
+ stub = render_tool_stub(tools)
+ if self.stub_dir is not None:
+ directory = self.stub_dir(state) if callable(self.stub_dir) else self.stub_dir
+ return render_code_mode_prompt(write_stub(stub, directory), tool_name=self.run_code.name)
+ return render_code_mode_prompt(stub, tool_name=self.run_code.name)
+
+ async def _run_code(
+ self, code: str, timeout_seconds: int = DEFAULT_RUN_CODE_TIMEOUT_SECONDS, *, context: ToolContext
+ ) -> str | dict[str, Any]:
+ code_tools = self.code_tools(context.state)
+ if code_tools is None:
+ raise BubError(ErrorKind.INVALID_INPUT, "Code mode is not enabled for this run.")
+ tools_by_name = {_identifier(item.name): item for item in code_tools}
+ code_context = replace(context, code_mode=True)
+ republic_session = RepublicSession(code_context, self.resolve_model)
+ call_tool = _code_tool_caller(tools_by_name, code_context, republic_session)
+ environment = self.environment(context.state)
+
+ output: list[str] = []
+ media: list[UserContent] = []
+ token = _ACTIVE_RUN.set(_CodeRun(environment, media))
+ try:
+ async with asyncio.timeout(timeout_seconds) as deadline:
+ await environment.run_code(code, tools=list(tools_by_name), call_tool=call_tool, write=output.append)
+ except TimeoutError:
+ if not deadline.expired():
+ raise
+ raise BubError(
+ ErrorKind.TOOL, f"Code timed out after {timeout_seconds} seconds", details={"output": "".join(output)}
+ ) from None
+ except CodeFailed as exc:
+ raise BubError(
+ ErrorKind.TOOL,
+ f"Code raised {exc.error}",
+ details={"output": "".join(output), "traceback": exc.traceback},
+ ) from exc
+ except BubError as exc:
+ raise BubError(exc.kind, exc.message, details={"output": "".join(output), **(exc.details or {})}) from exc
+ finally:
+ _ACTIVE_RUN.reset(token)
+ await republic_session.aclose()
+ if media:
+ return content_result(["".join(output), *media] if output else media)
+ return "".join(output)
+
+
+_RUN_CODE_DESCRIPTION = """Run Python code in the environment and return everything it prints.
+
+Tools are async functions available as `tools.(...)`: await them with keyword arguments
+(top-level `await` is allowed). See the tool stub referenced in the system prompt for their
+signatures and result types. The `republic` module provides media loaders and models. Media passed
+to `tools.attach_image/audio/video`, as an object, path or URL, is attached to the result. The code is stopped after timeout_seconds."""
+
+
+def write_stub(content: str, directory: Path) -> Path:
+ """Write a stub into ``directory``; the file name depends only on the content, so unchanged stubs keep their path."""
+ path = directory / f"tools-{hashlib.sha256(content.encode()).hexdigest()[:12]}.pyi"
+ if not path.exists():
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ temp_path.write_text(content, encoding="utf-8")
+ temp_path.replace(path)
+ return path
+
+
+def render_code_mode_prompt(stub: Path | str, *, tool_name: str = RUN_CODE_TOOL_NAME) -> str:
+ """Render the code mode prompt for a stub file path, or for the stub text itself."""
+ if isinstance(stub, Path):
+ where = f"in the stub file: {stub}\nRead the stub before calling a tool you have not used yet. "
+ else:
+ where = f"in this stub:\n```python\n{stub.rstrip()}\n```\n"
+ return (
+ "\n"
+ f"More tools are available as async Python functions `tools.(...)` inside `{tool_name}`. "
+ f"Their signatures, result types and documentation are {where}"
+ "Always `await` tool calls (top-level `await` "
+ "is allowed) and pass keyword arguments; they return structured values and raise on failure. "
+ f"`{tool_name}` returns only what the code prints, so print the results you need, and combine "
+ "several tool calls in one run when possible. To look at an image, audio or video, pass its path, URL "
+ "or `republic` media object to `tools.attach_image(image=...)`, `tools.attach_audio(audio=...)` or "
+ f"`tools.attach_video(video=...)`: it is attached to the `{tool_name}` result.\n"
+ "The code can also use the `republic` module (see the end of the stub) to call models with "
+ "`republic.get_model()`, "
+ "`republic.get_embedding_model(spec)` and `republic.get_decision_model(spec)`; `get_model()` without a "
+ "spec is the default model of this session.\n"
+ ""
+ )
+
+
+def _code_tool_caller(
+ tools_by_name: Mapping[str, Tool], context: ToolContext, republic_session: RepublicSession
+) -> CallTool:
+ """Serve the code's ``tools.`` and ``republic`` calls."""
+ # Code consumes structured results, so this executor does not render them to text.
+ executor = ToolExecutor(context.events, render=False)
+
+ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
+ arguments = decode_value(arguments)
+ if name.startswith(REPUBLIC_CALL_PREFIX):
+ return encode_value(await republic_session.call(name, arguments))
+ tool_item = tools_by_name.get(name)
+ if tool_item is None:
+ raise BubError(ErrorKind.INVALID_INPUT, f"unknown tool: tools.{name}")
+ return encode_value(await executor.execute(tool_item, arguments, context=context))
+
+ return call_tool
+
+
+_REMOTE_MEDIA_PREFIXES = ("http://", "https://", "gs://", "data:")
+_MEDIA_CLASSES: dict[str, type[republic.Image | republic.Audio | republic.Video]] = {
+ "image": republic.Image,
+ "audio": republic.Audio,
+ "video": republic.Video,
+}
+
+
+async def _load_media(
+ kind: str, source: str, environment: Environment
+) -> republic.Image | republic.Audio | republic.Video:
+ """Load media from a URL, a data URL, or a file path in the environment."""
+ if source.startswith(_REMOTE_MEDIA_PREFIXES):
+ media: republic.Image | republic.Audio | republic.Video = getattr(republic, kind)(source)
+ return media
+ media_type, _ = mimetypes.guess_type(source)
+ if media_type is None:
+ raise ValueError(f"cannot guess the media type of {source!r}")
+ data = await environment.read_bytes(environment.resolve_path(source))
+ return _MEDIA_CLASSES[kind](media_type, data=data)
+
+
+async def _attach(kind: str, item: republic.Image | republic.Audio | republic.Video | str) -> str:
+ run = _ACTIVE_RUN.get()
+ if run is None:
+ raise BubError(ErrorKind.INVALID_INPUT, f"tools.attach_{kind} can only be called from {RUN_CODE_TOOL_NAME}.")
+ if isinstance(item, str):
+ try:
+ item = await _load_media(kind, item, run.environment)
+ except (OSError, ValueError) as exc:
+ raise BubError(ErrorKind.INVALID_INPUT, f"Cannot load the {kind}: {exc}") from exc
+ run.media.append(item)
+ return f"The {kind} is attached to the {RUN_CODE_TOOL_NAME} result."
+
+
+async def _attach_image(image: republic.Image | str, *, context: ToolContext) -> str:
+ """Attach an image to the result of this `run_code` call, so you can see it.
+
+ `image` is a `republic.Image`, a file path in the environment, an http(s) URL, or a data URL.
+ """
+ return await _attach("image", image)
+
+
+async def _attach_audio(audio: republic.Audio | str, *, context: ToolContext) -> str:
+ """Attach audio to the result of this `run_code` call, so you can hear it.
+
+ `audio` is a `republic.Audio`, a file path in the environment, an http(s) URL, or a data URL.
+ """
+ return await _attach("audio", audio)
+
+
+async def _attach_video(video: republic.Video | str, *, context: ToolContext) -> str:
+ """Attach a video to the result of this `run_code` call, so you can watch it.
+
+ `video` is a `republic.Video`, a file path in the environment, an http(s) URL, or a data URL.
+ """
+ return await _attach("video", video)
+
+
+attach_image = Tool.from_callable(_attach_image, name="attach_image", context=True, exposure="code")
+attach_audio = Tool.from_callable(_attach_audio, name="attach_audio", context=True, exposure="code")
+attach_video = Tool.from_callable(_attach_video, name="attach_video", context=True, exposure="code")
+MEDIA_TOOLS = (attach_image, attach_audio, attach_video)
+"""Code-only tools attaching media to the ``run_code`` result; they work only inside ``run_code``."""
diff --git a/src/bub/core/codemode/republic_bridge.py b/src/bub/core/codemode/republic_bridge.py
new file mode 100644
index 00000000..833744d6
--- /dev/null
+++ b/src/bub/core/codemode/republic_bridge.py
@@ -0,0 +1,285 @@
+"""Serve the ``republic`` module of ``run_code``: Republic values over JSON and model calls made by the host.
+
+Model-written code never sees provider credentials: its models are proxies whose calls arrive
+here as tool calls named ``republic.`` (see ``runner_child``), and a
+:data:`ModelResolver` turns their specs into Republic specs and client options.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import binascii
+import uuid
+from collections.abc import Awaitable, Callable, Mapping
+from contextlib import AsyncExitStack
+from dataclasses import asdict
+from typing import Any
+
+import republic
+from pydantic_core import to_jsonable_python
+from republic.decisions import Choice, Noul, Question, Score
+from republic.events import (
+ CitationAdded,
+ Completed,
+ Event,
+ ImageReady,
+ ReasoningDelta,
+ RefusalDelta,
+ TextDelta,
+ UsageDelta,
+)
+
+from bub.core.tools import ToolContext
+from bub.errors import BubError, ErrorKind
+
+REPUBLIC_CALL_PREFIX = "republic."
+REPUBLIC_KEY = "$republic"
+
+ModelResolver = Callable[[str | None, ToolContext], tuple[str, Mapping[str, Any]]]
+"""Resolve the spec the code passed to ``republic.get_*model`` (None for ``get_model()``) to a Republic
+``provider:model`` spec and the client options to open it with. Raise ``ValueError`` to reject the spec."""
+
+
+def default_model_resolver(default: str | None = None) -> ModelResolver:
+ """Pass specs through, with ``default`` for ``get_model()``; clients read credentials from the environment."""
+
+ def resolve(spec: str | None, context: ToolContext) -> tuple[str, Mapping[str, Any]]:
+ spec = spec or default
+ if not spec:
+ raise ValueError("No default model: pass a 'provider:model' spec.")
+ return spec, {}
+
+ return resolve
+
+
+_MEDIA_CLASSES: dict[str, type[republic.Image | republic.Audio | republic.Video]] = {
+ "image": republic.Image,
+ "audio": republic.Audio,
+ "video": republic.Video,
+}
+_QUESTION_CLASSES: dict[str, type[Noul | Choice | Score]] = {"noul": Noul, "choice": Choice, "score": Score}
+_MESSAGE_BUILDERS: dict[str, Callable[..., republic.Message]] = {
+ "system": republic.system,
+ "user": republic.user,
+ "assistant": republic.assistant,
+}
+
+
+def decode_value(value: Any) -> Any:
+ """Turn ``{"$republic": ...}`` objects sent by the code into Republic values."""
+ if isinstance(value, list):
+ return [decode_value(item) for item in value]
+ if not isinstance(value, dict):
+ return value
+ kind = value.get(REPUBLIC_KEY)
+ if kind is None:
+ return {key: decode_value(item) for key, item in value.items()}
+ try:
+ if kind in _MEDIA_CLASSES:
+ data = value.get("data")
+ return _MEDIA_CLASSES[kind](
+ value["media_type"],
+ data=None if data is None else base64.b64decode(data, validate=True),
+ url=value.get("url"),
+ )
+ if kind in _QUESTION_CLASSES:
+ arguments = {"instructions": value["instructions"]}
+ if value.get("criteria") is not None or kind != "noul":
+ arguments["criteria"] = value.get("criteria")
+ return _QUESTION_CLASSES[kind](**arguments)
+ if kind == "message":
+ return _MESSAGE_BUILDERS[value["role"]](*decode_value(value.get("content") or []))
+ except (KeyError, TypeError, ValueError, binascii.Error) as exc:
+ raise BubError(ErrorKind.INVALID_INPUT, f"Invalid republic {kind} value: {exc}") from exc
+ raise BubError(ErrorKind.INVALID_INPUT, f"Unknown republic value: {kind!r}")
+
+
+def encode_value(value: Any) -> Any:
+ """Turn Republic media in a result into ``{"$republic": ...}`` objects the code decodes."""
+ if isinstance(value, republic.Image | republic.Audio | republic.Video):
+ data = None if value.data is None else value.base64_data
+ return {REPUBLIC_KEY: value.kind, "media_type": value.media_type, "data": data, "url": value.url}
+ if isinstance(value, list | tuple):
+ return [encode_value(item) for item in value]
+ if isinstance(value, Mapping):
+ return {key: encode_value(item) for key, item in value.items()}
+ return value
+
+
+def _encode_message(message: republic.Message) -> dict[str, Any]:
+ content = [
+ part.text if isinstance(part, republic.Text) else encode_value(part)
+ for part in message.parts
+ if isinstance(part, republic.Text | republic.Image | republic.Audio | republic.Video)
+ ]
+ return {REPUBLIC_KEY: "message", "role": message.role, "content": content}
+
+
+def _chat_messages(messages: list[Any], options: dict[str, Any]) -> list[republic.Message]:
+ if "tools" in options or "tool_choice" in options:
+ raise BubError(ErrorKind.INVALID_INPUT, "republic models in run_code do not support tools.")
+ if not all(isinstance(message, republic.Message) for message in messages):
+ raise BubError(ErrorKind.INVALID_INPUT, "chat() takes text or republic messages.")
+ return messages
+
+
+def _encode_response(response: republic.Response[Any]) -> dict[str, Any]:
+ return {
+ "text": response.text,
+ "reasoning": response.reasoning,
+ "refusal": response.refusal,
+ "finish_reason": response.finish_reason,
+ "model": response.model,
+ "token_usage": asdict(response.token_usage),
+ "message": _encode_message(response.message),
+ }
+
+
+def _encode_event(event: Event) -> dict[str, Any]:
+ match event:
+ case TextDelta(chunk):
+ return {"type": "text", "chunk": chunk}
+ case ReasoningDelta(chunk):
+ return {"type": "reasoning", "chunk": chunk}
+ case RefusalDelta(chunk):
+ return {"type": "refusal", "chunk": chunk}
+ case UsageDelta(usage):
+ return {"type": "usage", "usage": asdict(usage)}
+ case ImageReady(image):
+ return {"type": "image", "image": encode_value(image)}
+ case CitationAdded(citation):
+ return {"type": "citation", "citation": to_jsonable_python(citation)}
+ case Completed(response):
+ return {"type": "completed", "response": _encode_response(response)}
+ case _:
+ return {"type": "other", "event": type(event).__name__}
+
+
+_END = object()
+
+
+class _StreamPump:
+ """Read a Republic stream in the background so each ``stream_next`` returns every event that has arrived."""
+
+ def __init__(self, stack: AsyncExitStack, stream: republic.Stream[Any]) -> None:
+ self._stack = stack
+ self._queue: asyncio.Queue[Any] = asyncio.Queue()
+ self._task = asyncio.create_task(self._pump(stream))
+
+ async def _pump(self, stream: republic.Stream[Any]) -> None:
+ try:
+ async for event in stream:
+ self._queue.put_nowait(_encode_event(event))
+ except Exception as exc:
+ self._queue.put_nowait(exc)
+ finally:
+ self._queue.put_nowait(_END)
+
+ async def next(self) -> dict[str, Any]:
+ items = [await self._queue.get()]
+ while not self._queue.empty():
+ items.append(self._queue.get_nowait())
+ events = [item for item in items if isinstance(item, dict)]
+ if error := next((item for item in items if isinstance(item, Exception)), None):
+ if events:
+ # Deliver the events received before the failure first.
+ self._queue.put_nowait(error)
+ self._queue.put_nowait(_END)
+ return {"events": events, "done": False}
+ raise error
+ return {"events": events, "done": _END in items}
+
+ async def aclose(self) -> None:
+ self._task.cancel()
+ await asyncio.gather(self._task, return_exceptions=True)
+ await self._stack.aclose()
+
+
+class RepublicSession:
+ """Serve the ``republic.`` calls of one ``run_code``; ``aclose`` closes streams left open."""
+
+ def __init__(self, context: ToolContext, resolve_model: ModelResolver) -> None:
+ self.context = context
+ self._resolve_model = resolve_model
+ self._streams: dict[str, _StreamPump] = {}
+ self._operations: dict[str, Callable[..., Awaitable[Any]]] = {
+ "chat": self._chat,
+ "stream_open": self._stream_open,
+ "stream_next": self._stream_next,
+ "stream_close": self._stream_close,
+ "embed": self._embed,
+ "decide": self._decide,
+ }
+
+ async def call(self, name: str, arguments: dict[str, Any]) -> Any:
+ """Run the ``republic.`` call ``name`` made by the code's ``republic`` module."""
+ operation = self._operations.get(name.removeprefix(REPUBLIC_CALL_PREFIX))
+ if operation is None:
+ raise BubError(ErrorKind.INVALID_INPUT, f"unknown republic operation: {name}")
+ try:
+ return await operation(**arguments)
+ except BubError:
+ raise
+ except TypeError as exc:
+ raise BubError(ErrorKind.INVALID_INPUT, f"{name} failed: {exc}") from exc
+ except Exception as exc:
+ raise BubError(ErrorKind.TOOL, f"{name} failed: {type(exc).__name__}: {exc}") from exc
+
+ def _model_spec(self, spec: str | None) -> tuple[str, Mapping[str, Any]]:
+ try:
+ return self._resolve_model(spec, self.context)
+ except ValueError as exc:
+ raise BubError(ErrorKind.INVALID_INPUT, str(exc)) from exc
+
+ async def aclose(self) -> None:
+ streams, self._streams = list(self._streams.values()), {}
+ await asyncio.gather(*(stream.aclose() for stream in streams), return_exceptions=True)
+
+ async def _chat(self, spec: str | None, messages: list[Any], options: dict[str, Any]) -> Any:
+ messages = _chat_messages(messages, options)
+ model_spec, client_kwargs = self._model_spec(spec)
+ async with republic.get_model(model_spec, **client_kwargs) as model:
+ response = await model.chat(messages, **options)
+ return _encode_response(response)
+
+ async def _stream_open(self, spec: str | None, messages: list[Any], options: dict[str, Any]) -> Any:
+ messages = _chat_messages(messages, options)
+ model_spec, client_kwargs = self._model_spec(spec)
+ async with AsyncExitStack() as stack:
+ model = await stack.enter_async_context(republic.get_model(model_spec, **client_kwargs))
+ stream = await stack.enter_async_context(model.stream(messages, **options))
+ stream_id = uuid.uuid4().hex
+ self._streams[stream_id] = _StreamPump(stack.pop_all(), stream)
+ return {"stream": stream_id}
+
+ async def _stream_next(self, stream: str) -> Any:
+ pump = self._streams.get(stream)
+ if pump is None:
+ raise BubError(ErrorKind.INVALID_INPUT, "The stream is closed.")
+ result = await pump.next()
+ if result["done"]:
+ await self._stream_close(stream)
+ return result
+
+ async def _stream_close(self, stream: str) -> None:
+ if (pump := self._streams.pop(stream, None)) is not None:
+ await pump.aclose()
+
+ async def _embed(self, spec: str, texts: list[str], dimensions: int | None = None) -> Any:
+ model_spec, client_kwargs = self._model_spec(spec)
+ async with republic.get_embedding_model(model_spec, **client_kwargs) as model:
+ response = await model.embed_many(texts, dimensions=dimensions)
+ return {"vectors": response.vectors, "model": response.model, "token_usage": asdict(response.token_usage)}
+
+ async def _decide(self, spec: str, state: Any, questions: dict[str, Question]) -> Any:
+ if not all(isinstance(question, Noul | Choice | Score) for question in questions.values()):
+ raise BubError(ErrorKind.INVALID_INPUT, "decide() takes republic.decisions questions.")
+ model_spec, client_kwargs = self._model_spec(spec)
+ async with republic.get_decision_model(model_spec, **client_kwargs) as model:
+ response = await model.decide(state, questions=questions)
+ return {
+ "answers": {key: asdict(answer) for key, answer in response.answers.items()},
+ "model": response.model,
+ "token_usage": asdict(response.token_usage),
+ }
diff --git a/src/bub/builtin/codemode/code_runner.py b/src/bub/core/codemode/runner.py
similarity index 96%
rename from src/bub/builtin/codemode/code_runner.py
rename to src/bub/core/codemode/runner.py
index faffeb73..f9c86156 100644
--- a/src/bub/builtin/codemode/code_runner.py
+++ b/src/bub/core/codemode/runner.py
@@ -1,6 +1,6 @@
"""Run ``Environment.run_code`` code in a Python process started with ``Environment.spawn``.
-The process runs ``code_runner_child`` and talks to Bub over stdin/stdout with
+The process runs ``runner_child`` and talks to Bub over stdin/stdout with
JSON lines; see that module for the protocol.
"""
@@ -17,7 +17,7 @@
from pydantic_core import to_jsonable_python
-from bub.builtin.codemode import code_runner_child
+from bub.core.codemode import runner_child
from bub.environment import CodeFailed
from bub.errors import BubError, ErrorKind
@@ -25,7 +25,7 @@
from bub.environment import CallTool, Environment, Process
_STOP_TIMEOUT_SECONDS = 3.0
-_CHILD_SOURCE = Path(code_runner_child.__file__).read_text(encoding="utf-8")
+_CHILD_SOURCE = Path(runner_child.__file__).read_text(encoding="utf-8")
class _Session:
diff --git a/src/bub/core/codemode/runner_child.py b/src/bub/core/codemode/runner_child.py
new file mode 100644
index 00000000..a6d05cfb
--- /dev/null
+++ b/src/bub/core/codemode/runner_child.py
@@ -0,0 +1,584 @@
+"""Child side of ``run_code_in_subprocess``: run the code and forward ``tools.*`` calls to Bub.
+
+Bub runs this file with ``python -c`` inside the environment, so it must only use
+the standard library. Both directions exchange JSON lines:
+
+- Bub writes ``{"type": "run", "code", "filename", "tools"}`` to stdin first,
+ then ``{"type": "result", "id", "value"}`` or ``{"type": "error", "id", "message"}``
+ for each tool call.
+- The runner writes ``{"type": "call", "id", "name", "arguments"}`` to stdout for
+ each tool call, ``{"type": "output", "data"}`` for what the code writes to
+ ``sys.stdout``, and ends with ``{"type": "done"}`` or
+ ``{"type": "failed", "error", "traceback"}``.
+
+The code also gets a ``republic`` module (importable and preset as a global). Its models
+call Bub through tool calls named ``republic.``, so credentials stay in Bub.
+Republic values travel as JSON objects with a ``"$republic"`` key, such as
+``{"$republic": "image", "media_type", "data", "url"}`` with base64 ``data``.
+"""
+
+from __future__ import annotations
+
+import ast
+import asyncio
+import base64
+import builtins
+import inspect
+import io
+import itertools
+import json
+import linecache
+import sys
+import threading
+import time
+import traceback
+import types
+from typing import Any
+
+_OUTPUT_FLUSH_SIZE = 1 << 13
+_OUTPUT_FLUSH_INTERVAL = 0.2
+
+
+class BubError(Exception):
+ """A tool call failed or was denied on the Bub side."""
+
+
+_REPUBLIC_KEY = "$republic"
+
+
+class _Media:
+ kind = "media"
+
+ def __init__(self, media_type: str, data: bytes | None = None, url: str | None = None) -> None:
+ if data is None and url is None:
+ raise ValueError(f"{type(self).__name__} needs data or a url")
+ self.media_type = media_type
+ self.data = data
+ self.url = url
+
+ @property
+ def base64_data(self) -> str:
+ if self.data is None:
+ raise ValueError(f"This {self.kind} has no inline data")
+ return base64.b64encode(self.data).decode("ascii")
+
+ @property
+ def data_url(self) -> str:
+ if self.url is not None:
+ return self.url
+ return f"data:{self.media_type};base64,{self.base64_data}"
+
+ def __eq__(self, other: object) -> bool:
+ return type(other) is type(self) and (self.media_type, self.data, self.url) == (
+ other.media_type,
+ other.data,
+ other.url,
+ )
+
+ def __hash__(self) -> int:
+ return hash((type(self), self.media_type, self.data, self.url))
+
+ def __repr__(self) -> str:
+ source = f"url={self.url!r}" if self.url is not None else f"data=<{len(self.data or b'')} bytes>"
+ return f"{type(self).__name__}(media_type={self.media_type!r}, {source})"
+
+ def _to_json(self) -> dict[str, Any]:
+ data = None if self.data is None else self.base64_data
+ return {_REPUBLIC_KEY: self.kind, "media_type": self.media_type, "data": data, "url": self.url}
+
+
+class Image(_Media):
+ kind = "image"
+
+
+class Audio(_Media):
+ kind = "audio"
+
+
+class Video(_Media):
+ kind = "video"
+
+
+_MEDIA_CLASSES: dict[str, type[_Media]] = {"image": Image, "audio": Audio, "video": Video}
+
+
+class Message:
+ """A chat message whose content mixes text and media."""
+
+ def __init__(self, role: str, content: list[Any]) -> None:
+ self.role = role
+ self.content = content
+
+ @property
+ def text(self) -> str:
+ return "".join(item for item in self.content if isinstance(item, str))
+
+ def __repr__(self) -> str:
+ return f"Message(role={self.role!r}, content={self.content!r})"
+
+ def _to_json(self) -> dict[str, Any]:
+ return {_REPUBLIC_KEY: "message", "role": self.role, "content": self.content}
+
+
+def system(text: str) -> Message:
+ return Message("system", [text])
+
+
+def user(*content: Any) -> Message:
+ return Message("user", list(content))
+
+
+def assistant(*content: Any) -> Message:
+ return Message("assistant", list(content))
+
+
+class _Question:
+ kind = "question"
+
+ def __init__(self, instructions: Any, criteria: Any = None) -> None:
+ self.instructions = instructions
+ self.criteria = criteria
+
+ def __repr__(self) -> str:
+ return f"{type(self).__name__}(instructions={self.instructions!r}, criteria={self.criteria!r})"
+
+ def _to_json(self) -> dict[str, Any]:
+ return {_REPUBLIC_KEY: self.kind, "instructions": self.instructions, "criteria": self.criteria}
+
+
+class Noul(_Question):
+ """A yes/no question answered with the probability of yes."""
+
+ kind = "noul"
+
+
+class Choice(_Question):
+ """Pick one option; ``criteria`` maps options to descriptions or lists option names."""
+
+ kind = "choice"
+
+ def __init__(self, instructions: Any, criteria: Any) -> None:
+ super().__init__(instructions, criteria)
+
+
+class Score(_Question):
+ """Rate along ordered levels, from lowest to highest."""
+
+ kind = "score"
+
+ def __init__(self, instructions: Any, criteria: Any) -> None:
+ super().__init__(instructions, criteria)
+
+
+class _Record(types.SimpleNamespace):
+ """A result whose fields are attributes."""
+
+
+class DecisionResponse(_Record):
+ def __getattr__(self, name: str) -> Any:
+ if name.startswith("_"):
+ raise AttributeError(name)
+ try:
+ return self.__dict__["answers"][name]
+ except KeyError:
+ raise AttributeError(name) from None
+
+
+class EmbeddingResponse(_Record):
+ @property
+ def vector(self) -> list[float]:
+ vectors: list[list[float]] = self.vectors
+ return vectors[0]
+
+
+def _record(value: Any, record_class: type[_Record] = _Record) -> Any:
+ if isinstance(value, dict) and _REPUBLIC_KEY not in value:
+ return record_class(**{key: _record(item) for key, item in value.items()})
+ return value
+
+
+def _json_default(value: Any) -> Any:
+ if isinstance(value, _Media | Message | _Question):
+ return value._to_json()
+ if isinstance(value, set | frozenset):
+ return list(value)
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
+
+
+def _json_object(value: dict[str, Any]) -> Any:
+ kind = value.get(_REPUBLIC_KEY)
+ if kind in _MEDIA_CLASSES:
+ data = value.get("data")
+ return _MEDIA_CLASSES[kind](
+ value["media_type"], data=None if data is None else base64.b64decode(data), url=value.get("url")
+ )
+ if kind == "message":
+ return Message(value["role"], list(value.get("content") or []))
+ return value
+
+
+class _Model:
+ def __init__(self, call: Any, spec: str | None) -> None:
+ self._call = call
+ self.spec = spec
+
+ async def __aenter__(self) -> Any:
+ return self
+
+ async def __aexit__(self, *exc_info: Any) -> None:
+ return None
+
+ def __repr__(self) -> str:
+ return f"{type(self).__name__}({self.spec!r})"
+
+
+class TextDelta(_Record):
+ """``chunk`` of answer text."""
+
+
+class ReasoningDelta(_Record):
+ """``chunk`` of reasoning text, or its summary."""
+
+
+class RefusalDelta(_Record):
+ """``chunk`` of text explaining why the model declines to answer."""
+
+
+class UsageDelta(_Record):
+ """``usage`` added since the previous usage event."""
+
+
+class ImageReady(_Record):
+ """An ``image`` generated by the model."""
+
+
+class CitationAdded(_Record):
+ """A ``citation`` of a source."""
+
+
+class Completed(_Record):
+ """The last event, carrying the full ``response``."""
+
+
+class OtherEvent(_Record):
+ """An ``event`` (its type name) that is not forwarded to code."""
+
+
+_EVENT_CLASSES: dict[str, type[_Record]] = {
+ "text": TextDelta,
+ "reasoning": ReasoningDelta,
+ "refusal": RefusalDelta,
+ "usage": UsageDelta,
+ "image": ImageReady,
+ "citation": CitationAdded,
+ "completed": Completed,
+}
+
+
+def _event(data: dict[str, Any]) -> _Record:
+ kind = data.pop("type")
+ event_class = _EVENT_CLASSES.get(kind, OtherEvent)
+ return event_class(**{key: _record(value) for key, value in data.items()})
+
+
+def _chat_arguments(spec: str | None, prompt: Any, options: dict[str, Any]) -> dict[str, Any]:
+ if isinstance(prompt, str | Message):
+ prompt = [prompt]
+ messages = [user(item) if isinstance(item, str) else item for item in prompt]
+ return {"spec": spec, "messages": messages, "options": options}
+
+
+class Stream:
+ """An in-progress response: ``async with`` it, then ``async for`` its events.
+
+ After the iteration ends, the full response is ``stream.response``, with shortcuts
+ ``text``, ``reasoning`` and ``token_usage``.
+ """
+
+ def __init__(self, call: Any, arguments: dict[str, Any]) -> None:
+ self._call = call
+ self._arguments = arguments
+ self._id: str | None = None
+ self._iterated = False
+ self._response: Any = None
+
+ async def __aenter__(self) -> Stream:
+ self._id = (await self._call("republic.stream_open", self._arguments))["stream"]
+ return self
+
+ async def __aexit__(self, *exc_info: Any) -> None:
+ if self._id is not None:
+ stream_id, self._id = self._id, None
+ await self._call("republic.stream_close", {"stream": stream_id})
+
+ async def __aiter__(self) -> Any:
+ if self._id is None:
+ raise RuntimeError("Enter the stream with 'async with' before iterating it")
+ if self._iterated:
+ raise RuntimeError("A stream can only be iterated once; read stream.response instead")
+ self._iterated = True
+ while True:
+ batch = await self._call("republic.stream_next", {"stream": self._id})
+ for data in batch["events"]:
+ event = _event(data)
+ if isinstance(event, Completed):
+ self._response = event.response
+ yield event
+ if batch["done"]:
+ self._id = None
+ return
+
+ @property
+ def response(self) -> Any:
+ if self._response is None:
+ raise RuntimeError("The stream has not been fully consumed")
+ return self._response
+
+ @property
+ def text(self) -> str:
+ text: str = self.response.text
+ return text
+
+ @property
+ def reasoning(self) -> str:
+ reasoning: str = self.response.reasoning
+ return reasoning
+
+ @property
+ def token_usage(self) -> Any:
+ return self.response.token_usage
+
+
+class ChatModel(_Model):
+ """A chat model served by Bub."""
+
+ async def chat(self, prompt: Any, **options: Any) -> Any:
+ """Send the conversation and wait for the complete response."""
+ return _record(await self._call("republic.chat", _chat_arguments(self.spec, prompt, options)))
+
+ def stream(self, prompt: Any, **options: Any) -> Stream:
+ """Stream the response. Use as ``async with model.stream(...) as stream``."""
+ return Stream(self._call, _chat_arguments(self.spec, prompt, options))
+
+
+class EmbeddingModel(_Model):
+ """An embedding model served by Bub."""
+
+ async def embed(self, text: str, *, dimensions: int | None = None) -> Any:
+ """Embed one text; the vector is ``response.vector``."""
+ return await self.embed_many([text], dimensions=dimensions)
+
+ async def embed_many(self, texts: list[str], *, dimensions: int | None = None) -> Any:
+ """Embed several texts in one request; ``response.vectors`` follows input order."""
+ arguments = {"spec": self.spec, "texts": list(texts), "dimensions": dimensions}
+ return _record(await self._call("republic.embed", arguments), EmbeddingResponse)
+
+
+class DecisionModel(_Model):
+ """A decision model served by Bub."""
+
+ async def decide(self, state: Any, *, questions: dict[str, _Question]) -> Any:
+ """Answer every question about ``state``. Answers come back under the same ids."""
+ result = await self._call("republic.decide", {"spec": self.spec, "state": state, "questions": questions})
+ response = _record(result, DecisionResponse)
+ response.answers = {key: _record(answer) for key, answer in result["answers"].items()}
+ return response
+
+
+def _republic_module(call: Any) -> types.ModuleType:
+ module = types.ModuleType("republic", "Republic models and media, served by Bub.")
+ events = types.ModuleType("republic.events")
+ events.__dict__.update(
+ TextDelta=TextDelta,
+ ReasoningDelta=ReasoningDelta,
+ RefusalDelta=RefusalDelta,
+ UsageDelta=UsageDelta,
+ ImageReady=ImageReady,
+ CitationAdded=CitationAdded,
+ Completed=Completed,
+ OtherEvent=OtherEvent,
+ )
+ decisions = types.ModuleType("republic.decisions")
+ decisions.__dict__.update(Noul=Noul, Choice=Choice, Score=Score, DecisionResponse=DecisionResponse)
+
+ def get_model(spec: str | None = None) -> ChatModel:
+ """A chat model for a ``"provider:model"`` spec; defaults to the model running this session."""
+ return ChatModel(call, spec)
+
+ def get_embedding_model(spec: str) -> EmbeddingModel:
+ """An embedding model for a ``"provider:model"`` spec."""
+ return EmbeddingModel(call, spec)
+
+ def get_decision_model(spec: str) -> DecisionModel:
+ """A decision model for a ``"provider:model"`` spec."""
+ return DecisionModel(call, spec)
+
+ module.__dict__.update(
+ Image=Image,
+ Audio=Audio,
+ Video=Video,
+ Message=Message,
+ ChatModel=ChatModel,
+ EmbeddingModel=EmbeddingModel,
+ DecisionModel=DecisionModel,
+ EmbeddingResponse=EmbeddingResponse,
+ system=system,
+ user=user,
+ assistant=assistant,
+ decisions=decisions,
+ events=events,
+ Stream=Stream,
+ get_model=get_model,
+ get_embedding_model=get_embedding_model,
+ get_decision_model=get_decision_model,
+ )
+ return module
+
+
+class _Channel:
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
+ self._loop = loop
+ self._out = sys.stdout
+ self._send_lock = threading.Lock()
+ self._ids = itertools.count()
+ self._pending: dict[int, asyncio.Future[Any]] = {}
+ self._first: asyncio.Future[dict[str, Any]] = loop.create_future()
+
+ def send(self, message: dict[str, Any]) -> None:
+ line = json.dumps(message, default=_json_default) + "\n"
+ with self._send_lock:
+ self._out.write(line)
+ self._out.flush()
+
+ def start(self) -> asyncio.Future[dict[str, Any]]:
+ # A daemon thread keeps reading stdin without blocking interpreter exit.
+ threading.Thread(target=self._read, daemon=True).start()
+ return self._first
+
+ def _read(self) -> None:
+ for line in sys.stdin:
+ self._loop.call_soon_threadsafe(self._dispatch, json.loads(line, object_hook=_json_object))
+ self._loop.call_soon_threadsafe(self._close)
+
+ def _dispatch(self, message: dict[str, Any]) -> None:
+ if not self._first.done():
+ self._first.set_result(message)
+ return
+ future = self._pending.pop(message.get("id"), None) # type: ignore[arg-type]
+ if future is None or future.done():
+ return
+ if message.get("type") == "result":
+ future.set_result(message.get("value"))
+ else:
+ future.set_exception(BubError(message.get("message", "tool call failed")))
+
+ def _close(self) -> None:
+ if not self._first.done():
+ self._first.set_exception(EOFError("Bub closed the connection"))
+ for future in self._pending.values():
+ if not future.done():
+ future.set_exception(BubError("Bub closed the connection"))
+ self._pending.clear()
+
+ async def call(self, name: str, arguments: dict[str, Any]) -> Any:
+ call_id = next(self._ids)
+ future = self._loop.create_future()
+ self._pending[call_id] = future
+ try:
+ self.send({"type": "call", "id": call_id, "name": name, "arguments": arguments})
+ return await future
+ finally:
+ self._pending.pop(call_id, None)
+
+
+class _Output(io.TextIOBase):
+ """Forward what the code prints to Bub in chunks, so output survives a timeout.
+
+ A daemon thread also flushes periodically, because blocking code stalls the event loop.
+ """
+
+ def __init__(self, channel: _Channel) -> None:
+ self._channel = channel
+ self._parts: list[str] = []
+ self._size = 0
+ self._lock = threading.Lock()
+ threading.Thread(target=self._flush_periodically, daemon=True).start()
+
+ def writable(self) -> bool:
+ return True
+
+ def write(self, text: str) -> int:
+ with self._lock:
+ self._parts.append(text)
+ self._size += len(text)
+ full = self._size >= _OUTPUT_FLUSH_SIZE
+ if full:
+ self.flush()
+ return len(text)
+
+ def flush(self) -> None:
+ with self._lock:
+ if self._parts:
+ data = "".join(self._parts)
+ self._parts.clear()
+ self._size = 0
+ self._channel.send({"type": "output", "data": data})
+
+ def _flush_periodically(self) -> None:
+ while True:
+ time.sleep(_OUTPUT_FLUSH_INTERVAL)
+ self.flush()
+
+
+def _tool_function(channel: _Channel, output: _Output, name: str) -> Any:
+ async def call(*args: Any, **kwargs: Any) -> Any:
+ if args:
+ raise TypeError(f"tools.{name}() accepts keyword arguments only")
+ output.flush()
+ return await channel.call(name, kwargs)
+
+ call.__name__ = name
+ return call
+
+
+async def _main() -> None:
+ channel = _Channel(asyncio.get_running_loop())
+ run = await channel.start()
+ code, filename = run["code"], run["filename"]
+ output = _Output(channel)
+ sys.stdout = output
+
+ async def call(name: str, arguments: dict[str, Any]) -> Any:
+ output.flush()
+ return await channel.call(name, arguments)
+
+ # Shadow any installed Republic: models must go through Bub, which holds the credentials.
+ republic = sys.modules["republic"] = _republic_module(call)
+ sys.modules["republic.decisions"] = republic.decisions
+ sys.modules["republic.events"] = republic.events
+ namespace = {
+ "__name__": "__run_code__",
+ "__builtins__": builtins,
+ "tools": types.SimpleNamespace(**{name: _tool_function(channel, output, name) for name in run["tools"]}),
+ "republic": republic,
+ }
+ linecache.cache[filename] = (len(code), None, code.splitlines(keepends=True), filename)
+ try:
+ compiled = compile(code, filename, "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT)
+ # With top-level await enabled, eval returns a coroutine when the code awaits anything.
+ result = eval(compiled, namespace) # noqa: S307
+ if inspect.iscoroutine(result):
+ await result
+ except (Exception, SystemExit) as exc:
+ error = traceback.TracebackException.from_exception(exc)
+ # Keep only frames from the model's code; runner frames are noise to the model.
+ error.stack = traceback.StackSummary.from_list([frame for frame in error.stack if frame.filename == filename])
+ output.flush()
+ channel.send({"type": "failed", "error": f"{type(exc).__name__}: {exc}", "traceback": "".join(error.format())})
+ else:
+ output.flush()
+ channel.send({"type": "done"})
+
+
+if __name__ == "__main__":
+ asyncio.run(_main())
diff --git a/src/bub/core/codemode/stub.py b/src/bub/core/codemode/stub.py
new file mode 100644
index 00000000..f57726bc
--- /dev/null
+++ b/src/bub/core/codemode/stub.py
@@ -0,0 +1,277 @@
+"""Render tools as a Python stub that tells model-written code their signatures and result types."""
+
+from __future__ import annotations
+
+import keyword
+import re
+from collections.abc import Iterable
+from typing import Any
+
+from bub.core.tools import Tool, model_tools
+
+_STUB_HEADER = '''"""Tools available inside `run_code` as `tools.(...)`.
+
+Every tool is an async function: `await` it (top-level `await` is allowed) and pass keyword
+arguments only. Each call returns the structured value described by its return type and
+raises an exception when the tool fails. Use `asyncio.gather` to run independent calls
+concurrently.
+
+The `republic` module is also available (preset as a global and importable); it is documented
+at the end of this file.
+"""
+
+from typing import Any, Literal, NotRequired, TypedDict
+
+import republic'''
+
+_REPUBLIC_STUB = """# ---------------------------------------------------------------------------
+# `republic`: media and models inside `run_code`. Models are served by the host with
+# its credentials and settings; every model method is async. Only the members below exist.
+#
+# class republic.Image / republic.Audio / republic.Video:
+# __init__(media_type: str, data: bytes | None = None, url: str | None = None)
+# media_type: str; data: bytes | None; url: str | None
+# base64_data: str; data_url: str
+# `tools.attach_image/audio/video` also take a file path in the environment, an http(s)/gs URL
+# or a data URL instead, and load it for you.
+#
+# republic.system(text: str) -> Message
+# republic.user(*content: str | Image | Audio | Video) -> Message
+# republic.assistant(*content: str | Image | Audio | Video) -> Message
+# Message has .role, .content (list of text and media) and .text.
+#
+# republic.get_model(spec: str | None = None) -> ChatModel
+# A "provider:model" spec; None is the model running this session.
+# await model.chat(prompt: str | Message | list[str | Message], **options) -> response
+# options: max_tokens, temperature, top_p, top_k, stop, seed, reasoning_effort, extra_body...
+# (no tools). A plain string is a user message.
+# response.text, .reasoning, .refusal, .finish_reason, .model,
+# .token_usage.input_tokens / .output_tokens, .message (append it to continue the conversation)
+# model.stream(prompt, **options) -> Stream # same arguments as chat(); not awaited
+# async with model.stream(...) as stream:
+# async for event in stream: ...
+# Events from republic.events: TextDelta(.chunk), ReasoningDelta(.chunk), RefusalDelta(.chunk),
+# UsageDelta(.usage), ImageReady(.image), CitationAdded(.citation), Completed(.response, last).
+# After iterating: stream.response (as returned by chat()), stream.text, .reasoning, .token_usage.
+# republic.get_embedding_model(spec: str) -> EmbeddingModel
+# await model.embed(text: str, *, dimensions: int | None = None) -> response # response.vector
+# await model.embed_many(texts: list[str], *, dimensions: int | None = None) -> response # response.vectors
+# republic.get_decision_model(spec: str) -> DecisionModel
+# await model.decide(state, *, questions: dict[str, Question]) -> response
+# Questions from republic.decisions: Noul(instructions, criteria=None) (yes/no),
+# Choice(instructions, criteria) (pick one), Score(instructions, criteria) (ordered levels).
+# response.answers[id] or response.: .noul | .choice/.probabilities/.confidence | .score/...
+#
+# Example:
+# model = republic.get_model()
+# chart = republic.Image("image/png", data=open("chart.png", "rb").read())
+# reply = await model.chat([republic.user("Describe this chart.", chart)])
+# print(reply.text)
+# async with model.stream("Write a haiku.") as stream:
+# async for event in stream:
+# if isinstance(event, republic.events.TextDelta):
+# print(event.chunk, end="")"""
+_JSON_TYPES = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None"}
+
+
+def _identifier(name: str) -> str:
+ ident = re.sub(r"\W", "_", name)
+ if not ident or ident[0].isdigit() or keyword.iskeyword(ident):
+ ident = f"_{ident}"
+ return ident
+
+
+def _is_identifier(name: str) -> bool:
+ return name.isidentifier() and not keyword.iskeyword(name)
+
+
+def _pascal(name: str) -> str:
+ return "".join(part[:1].upper() + part[1:] for part in re.split(r"[^0-9A-Za-z]+", name))
+
+
+_MEDIA_FIELDS = {"media_type", "data", "url"}
+
+
+def _media_class(schema: dict[str, Any]) -> str | None:
+ """Recognize the JSON schema of ``republic.Image``, ``Audio`` and ``Video`` parameters."""
+ title = schema.get("title")
+ properties = schema.get("properties")
+ if title in ("Image", "Audio", "Video") and isinstance(properties, dict) and set(properties) == _MEDIA_FIELDS:
+ return str(title)
+ return None
+
+
+def _docstring(text: str, indent: str = " ") -> list[str]:
+ text = text.strip().replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
+ if not text:
+ return []
+ lines = text.splitlines()
+ if len(lines) == 1:
+ return [f'{indent}"""{lines[0]}"""']
+ return [f'{indent}"""{lines[0]}', *(f"{indent}{line}".rstrip() for line in lines[1:]), f'{indent}"""']
+
+
+class _StubBuilder:
+ """Render tool JSON schemas as Python stub declarations."""
+
+ def __init__(self) -> None:
+ self.classes: dict[str, str] = {}
+ self._refs: dict[tuple[int, str], str] = {}
+
+ def function(self, tool_item: Tool) -> str:
+ name = _identifier(tool_item.name)
+ schema = tool_item.parameters or {}
+ defs = self._defs(schema)
+ properties = schema.get("properties") or {}
+ required = set(schema.get("required") or [])
+ params: list[str] = []
+ arg_docs: list[str] = []
+ for key, prop in properties.items():
+ hint = self.type_hint(prop, defs, f"{_pascal(name)}{_pascal(key)}")
+ if key in required:
+ params.append(f"{key}: {hint}")
+ elif isinstance(prop, dict) and "default" in prop:
+ params.append(f"{key}: {hint} = {prop['default']!r}")
+ else:
+ params.append(f"{key}: {hint} = ...")
+ description = prop.get("description") if isinstance(prop, dict) else None
+ if description:
+ arg_docs.append(f" {key}: {' '.join(description.split())}")
+ if not all(_is_identifier(key) for key in properties):
+ signature = "**kwargs: Any"
+ elif params:
+ signature = f"*, {', '.join(params)}"
+ else:
+ signature = ""
+
+ output = tool_item.output_schema
+ returns = "Any" if output is None else self.type_hint(output, self._defs(output), f"{_pascal(name)}Result")
+ doc = tool_item.description.strip()
+ if arg_docs:
+ doc = f"{doc}\n\nArgs:\n" + "\n".join(arg_docs) if doc else "Args:\n" + "\n".join(arg_docs)
+ body = _docstring(doc) or [" ..."]
+ return "\n".join([f"async def {name}({signature}) -> {returns}:", *body])
+
+ def type_hint( # noqa: C901
+ self, schema: Any, defs: dict[str, Any], name_hint: str, ref_key: tuple[int, str] | None = None
+ ) -> str:
+ if not isinstance(schema, dict) or not schema:
+ return "Any"
+ if (media := _media_class(schema)) is not None:
+ return f"republic.{media}"
+ if local_defs := self._defs(schema):
+ # Schemas of single parameters carry their own definitions.
+ defs = {**defs, **local_defs}
+ if isinstance(ref := schema.get("$ref"), str):
+ return self._ref(ref, defs)
+ for key in ("anyOf", "oneOf"):
+ if isinstance(options := schema.get(key), list):
+ return self._union(self.type_hint(option, defs, name_hint) for option in options)
+ if isinstance(all_of := schema.get("allOf"), list) and len(all_of) == 1:
+ return self.type_hint(all_of[0], defs, name_hint, ref_key)
+ if "const" in schema:
+ return f"Literal[{schema['const']!r}]"
+ if isinstance(enum := schema.get("enum"), list) and enum:
+ return f"Literal[{', '.join(repr(value) for value in enum)}]"
+
+ schema_type = schema.get("type")
+ if isinstance(schema_type, list):
+ return self._union(self.type_hint({**schema, "type": item}, defs, name_hint) for item in schema_type)
+ if schema_type == "array":
+ if isinstance(prefix := schema.get("prefixItems"), list):
+ return f"tuple[{', '.join(self.type_hint(item, defs, name_hint) for item in prefix)}]"
+ return f"list[{self.type_hint(schema.get('items'), defs, f'{name_hint}Item')}]"
+ if schema_type == "object":
+ if isinstance(properties := schema.get("properties"), dict) and properties:
+ return self._typed_dict(schema, properties, defs, schema.get("title") or name_hint, ref_key)
+ extra = schema.get("additionalProperties")
+ if isinstance(extra, dict) and extra:
+ return f"dict[str, {self.type_hint(extra, defs, f'{name_hint}Value')}]"
+ return "dict[str, Any]"
+ return _JSON_TYPES.get(schema_type, "Any") if isinstance(schema_type, str) else "Any"
+
+ @staticmethod
+ def _defs(schema: dict[str, Any]) -> dict[str, Any]:
+ defs = schema.get("$defs", schema.get("definitions"))
+ return defs if isinstance(defs, dict) else {}
+
+ @staticmethod
+ def _union(hints: Iterable[str]) -> str:
+ unique = list(dict.fromkeys(hints))
+ return "Any" if "Any" in unique else " | ".join(unique)
+
+ def _ref(self, ref: str, defs: dict[str, Any]) -> str:
+ name = ref.rsplit("/", 1)[-1]
+ key = (id(defs), name)
+ if key in self._refs:
+ return self._refs[key]
+ target = defs.get(name)
+ if not isinstance(target, dict):
+ return "Any"
+ hint = self.type_hint(target, defs, name, ref_key=key)
+ self._refs[key] = hint
+ return hint
+
+ def _typed_dict(
+ self,
+ schema: dict[str, Any],
+ properties: dict[str, Any],
+ defs: dict[str, Any],
+ title: str,
+ ref_key: tuple[int, str] | None,
+ ) -> str:
+ base = _identifier(_pascal(title) or "Result")
+ class_name = base
+ index = 2
+ while class_name in self.classes:
+ class_name = f"{base}{index}"
+ index += 1
+ # Reserve the name first so recursive references resolve to this class.
+ self.classes[class_name] = ""
+ if ref_key is not None:
+ self._refs[ref_key] = class_name
+
+ required = set(schema.get("required") or [])
+ fields: list[tuple[str, str, str | None]] = []
+ for key, prop in properties.items():
+ hint = self.type_hint(prop, defs, f"{class_name}{_pascal(key)}")
+ if key not in required:
+ hint = f"NotRequired[{hint}]"
+ description = prop.get("description") if isinstance(prop, dict) else None
+ fields.append((key, hint, description))
+ source = self._class_source(class_name, fields, schema.get("description"))
+
+ # Reuse an identical class that was declared under the same title by another tool.
+ for existing, existing_source in self.classes.items():
+ if (
+ existing != class_name
+ and re.fullmatch(rf"{re.escape(base)}\d*", existing)
+ and existing_source == source.replace(class_name, existing)
+ ):
+ del self.classes[class_name]
+ if ref_key is not None:
+ self._refs[ref_key] = existing
+ return existing
+ self.classes[class_name] = source
+ return class_name
+
+ @staticmethod
+ def _class_source(name: str, fields: list[tuple[str, str, str | None]], description: object) -> str:
+ if not all(_is_identifier(key) for key, _, _ in fields):
+ items = ", ".join(f"{key!r}: {hint}" for key, hint, _ in fields)
+ return f"{name} = TypedDict({name!r}, {{{items}}})"
+ lines = [f"class {name}(TypedDict):"]
+ if isinstance(description, str):
+ lines.extend(_docstring(description))
+ for key, hint, field_description in fields:
+ if field_description:
+ lines.append(f" # {' '.join(field_description.split())}")
+ lines.append(f" {key}: {hint}")
+ return "\n".join(lines)
+
+
+def render_tool_stub(tools: Iterable[Tool]) -> str:
+ """Render a Python stub declaring one function per model-facing tool."""
+ builder = _StubBuilder()
+ functions = [builder.function(tool_item) for tool_item in model_tools(tools)]
+ return "\n\n\n".join([_STUB_HEADER, *builder.classes.values(), *functions, _REPUBLIC_STUB]) + "\n"
diff --git a/src/bub/core/commands.py b/src/bub/core/commands.py
new file mode 100644
index 00000000..d93ec6a5
--- /dev/null
+++ b/src/bub/core/commands.py
@@ -0,0 +1,64 @@
+"""Command prompts: prefix rules and argument parsing."""
+
+from __future__ import annotations
+
+import shlex
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+
+def validate_command_prefix(prefix: str) -> str:
+ if not prefix or any(char.isspace() for char in prefix):
+ raise ValueError("command_prefix must be non-empty and contain no whitespace")
+ return prefix
+
+
+def strip_command_prefix(text: str, prefix: str) -> str | None:
+ """Return the command without its prefix, or None for ordinary text.
+
+ Strip surrounding whitespace and exactly one prefix. A bare prefix returns
+ an empty string so execution can report an empty command.
+ Callers validate the prefix when configuring it.
+ """
+ text = text.strip()
+ if text.startswith(prefix):
+ return text[len(prefix) :].strip()
+ return None
+
+
+@dataclass(frozen=True)
+class CommandArgs:
+ positional: list[str]
+ kwargs: dict[str, str]
+
+ def bind(self, names: Sequence[str]) -> dict[str, str]:
+ """Return all arguments by name, naming the positional ones after ``names`` in order."""
+ if len(self.positional) > len(names):
+ raise ValueError(f"expected at most {len(names)} positional arguments, got {len(self.positional)}")
+ arguments = dict(zip(names, self.positional, strict=False))
+ if repeated := arguments.keys() & self.kwargs.keys():
+ raise ValueError(f"argument '{sorted(repeated)[0]}' given both positionally and by keyword")
+ return {**arguments, **self.kwargs}
+
+
+def parse_command(line: str) -> tuple[str, list[str]]:
+ """Split a command line into its name and argument tokens, shell-style."""
+ words = shlex.split(line.strip())
+ if not words:
+ return "", []
+ return words[0], words[1:]
+
+
+def parse_command_args(tokens: list[str]) -> CommandArgs:
+ """Parse positional arguments followed by ``key=value`` arguments."""
+ positional: list[str] = []
+ kwargs: dict[str, str] = {}
+ for token in tokens:
+ if "=" in token:
+ key, value = token.split("=", 1)
+ kwargs[key] = value
+ elif kwargs:
+ raise ValueError(f"positional argument '{token}' cannot appear after keyword arguments")
+ else:
+ positional.append(token)
+ return CommandArgs(positional=positional, kwargs=kwargs)
diff --git a/src/bub/core/context.py b/src/bub/core/context.py
new file mode 100644
index 00000000..3e649c24
--- /dev/null
+++ b/src/bub/core/context.py
@@ -0,0 +1,70 @@
+"""Tape context helpers."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Iterable
+from typing import Any
+
+import republic
+
+from bub.core.tape import TapeContext, TapeEntry, to_message
+from bub.core.tools import result_content
+
+
+def default_tape_context() -> TapeContext:
+ """Return a context that replays the chat recorded by the agent loop, tool calls included, since the last anchor."""
+
+ return TapeContext(select=_select_messages)
+
+
+def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[republic.Message]:
+ messages: list[republic.Message] = []
+ calls: dict[str, republic.ToolCall] = {}
+ pending_calls: list[republic.ToolCall] = []
+
+ for entry in entries:
+ match entry.kind:
+ case "anchor":
+ messages.append(_anchor_message(entry))
+ case "message":
+ if isinstance(entry.payload, dict):
+ messages.append(to_message(entry.payload, calls))
+ case "tool_call":
+ pending_calls = _append_tool_call_entry(messages, calls, entry)
+ case "tool_result":
+ _append_tool_result_entry(messages, pending_calls, entry)
+ pending_calls = []
+ return messages
+
+
+def _anchor_message(entry: TapeEntry) -> republic.Message:
+ payload = entry.payload
+ content = f"[Anchor created: {payload.get('name')}]: {json.dumps(payload.get('state'), ensure_ascii=False)}"
+ return republic.assistant(content)
+
+
+def _append_tool_call_entry(
+ messages: list[republic.Message], calls: dict[str, republic.ToolCall], entry: TapeEntry
+) -> list[republic.ToolCall]:
+ raw_calls = entry.payload.get("calls")
+ if not isinstance(raw_calls, list) or not (tool_calls := [item for item in raw_calls if isinstance(item, dict)]):
+ return []
+ fields = {key: entry.payload[key] for key in ("reasoning", "provider_data") if key in entry.payload}
+ message = to_message(
+ {"role": "assistant", "content": entry.payload.get("content") or "", "tool_calls": tool_calls, **fields},
+ calls,
+ )
+ messages.append(message)
+ return list(message.tool_calls)
+
+
+def _append_tool_result_entry(
+ messages: list[republic.Message], pending_calls: list[republic.ToolCall], entry: TapeEntry
+) -> None:
+ results: Any = entry.payload.get("results")
+ if not isinstance(results, list):
+ return
+ # Results without a recorded call cannot be expressed as tool messages.
+ for call, result in zip(pending_calls, results, strict=False):
+ messages.append(republic.tool(call, *result_content(result)))
diff --git a/src/bub/core/events.py b/src/bub/core/events.py
new file mode 100644
index 00000000..8a40eeb9
--- /dev/null
+++ b/src/bub/core/events.py
@@ -0,0 +1,324 @@
+"""Events emitted by the agent loop, their payloads, and the bus delivering them to handlers."""
+
+from __future__ import annotations
+
+import inspect
+from collections.abc import Callable
+from contextlib import suppress
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
+
+import republic
+
+from bub.errors import BubError
+from bub.prompt import UserContent
+from bub.turn import TurnState
+
+if TYPE_CHECKING:
+ from bub.core.tape import Tape
+ from bub.core.tools import ToolContext
+
+
+@dataclass(frozen=True)
+class LLMCallRequest:
+ """Outgoing agent-loop LLM request exposed to interception hooks.
+
+ Hooks may return a modified copy (``dataclasses.replace``) to change the
+ model or messages (``republic.Message`` objects) for this call. Tool objects are not exposed;
+ ``tool_names`` is observational and altering the toolset is out of scope.
+ """
+
+ run_id: str
+ model: str
+ messages: list[republic.Message]
+ tool_names: tuple[str, ...] = ()
+ max_tokens: int | None = None
+
+
+@dataclass(frozen=True)
+class LLMCallResult:
+ """Terminal outcome of one LLM call exposed to interception hooks.
+
+ For streaming completions this is the fully accumulated final state, not
+ a per-chunk view. ``error`` is the original raised exception (and other
+ fields are best effort) when the call failed. Cancellation and consumer
+ close are not observed.
+ """
+
+ run_id: str
+ text: str | None = None
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
+ usage: dict[str, Any] | None = None
+ error: Exception | None = None
+ duration_ms: int = 0
+
+
+@dataclass(frozen=True)
+class LLMCallDecision:
+ """Short-circuit verdict returned by a ``before_llm_call`` hook."""
+
+ action: Literal["finish"] = "finish"
+ text: str = ""
+
+ @classmethod
+ def finish(cls, text: str) -> LLMCallDecision:
+ """Skip the provider call and emit ``text`` as its final output."""
+
+ return cls(action="finish", text=text)
+
+
+@dataclass(frozen=True)
+class ToolCall:
+ """One tool invocation exposed to interception hooks.
+
+ ``context`` is the ``ToolContext`` the call runs with, when one is available; for
+ example, ``context.code_mode`` marks calls made from model-written code (``run_code``).
+ """
+
+ run_id: str
+ tool: str
+ arguments: dict[str, Any]
+ context: ToolContext | None = field(default=None, repr=False, compare=False)
+
+
+@dataclass(frozen=True)
+class ToolCallDecision:
+ """Verdict returned by a ``before_tool_call`` hook.
+
+ ``proceed`` continues with optional argument changes. ``replace`` skips
+ the handler and uses the supplied result. ``deny`` skips the handler and
+ surfaces the supplied message as a tool error.
+ """
+
+ action: Literal["proceed", "replace", "deny"] = "proceed"
+ arguments: dict[str, Any] | None = None
+ result: Any = None
+ message: str | None = None
+
+ @classmethod
+ def proceed(cls, arguments: dict[str, Any] | None = None) -> ToolCallDecision:
+ return cls(action="proceed", arguments=arguments)
+
+ @classmethod
+ def replace(cls, result: Any) -> ToolCallDecision:
+ return cls(action="replace", result=result)
+
+ @classmethod
+ def deny(cls, message: str) -> ToolCallDecision:
+ return cls(action="deny", message=message)
+
+
+@dataclass
+class ToolCallResult:
+ """Terminal outcome exposed to hooks; model-facing results may be replaced in place."""
+
+ run_id: str
+ tool: str
+ arguments: dict[str, Any]
+ result: Any = None
+ error: Exception | None = None
+ duration_ms: int = 0
+
+
+# Agent events carry the turn ``state``; loop events also carry the run's ``tape``. ``Before*``
+# events are mutable: handlers may replace the request or call, or set a decision, which stops delivery.
+
+
+class Event:
+ """Base event shared by publishers using the event bus."""
+
+ @property
+ def stopped(self) -> bool:
+ """Whether delivery to the remaining handlers stops."""
+ return False
+
+
+@dataclass(kw_only=True)
+class AgentEvent(Event):
+ """Base class of agent-loop events; a handler for it receives every agent event."""
+
+ state: TurnState
+
+
+@dataclass(kw_only=True)
+class TapeEvent(AgentEvent):
+ """An event of a run, carrying the run's tape (whose ``context.state`` is ``state``)."""
+
+ tape: Tape
+
+
+@dataclass(kw_only=True)
+class RunStarted(TapeEvent):
+ """A run began; ``metadata`` is caller-supplied context recorded with it."""
+
+ model: str
+ prompt: list[UserContent]
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(kw_only=True)
+class RunFinished(TapeEvent):
+ """A run ended after ``steps`` model steps, with ``status`` ``ok``, ``error`` or ``cancelled``.
+
+ Every ``RunStarted`` is followed by one ``RunFinished`` when the run's stream closes:
+ ``cancelled`` when it closes before the run ends, including before it is iterated.
+ """
+
+ status: str
+ steps: int
+ elapsed_ms: int
+ error: str | None = None
+
+
+@dataclass(kw_only=True)
+class StepStarted(TapeEvent):
+ """A model step began; ``prompt`` is None when the step continues the conversation."""
+
+ step: int
+ prompt: list[UserContent] | None
+
+
+@dataclass(kw_only=True)
+class StepFinished(TapeEvent):
+ """A model step ended with ``status`` ``ok``, ``continue``, ``error`` or ``auto_handoff``."""
+
+ step: int
+ status: str
+ elapsed_ms: int
+ error: str | None = None
+
+
+@dataclass(kw_only=True)
+class CommandFinished(TapeEvent):
+ """A command prompt ran (see ``Agent.run_command``), with ``status`` ``ok`` or ``error``.
+
+ ``line`` is the command without its prefix, ``name`` the command name, and ``output``
+ the rendered result or the error message.
+ """
+
+ line: str
+ name: str
+ status: str
+ elapsed_ms: int
+ output: str
+
+
+@dataclass(kw_only=True)
+class LLMCallStarted(TapeEvent):
+ """An LLM call is about to be sent.
+
+ Handlers may replace ``request`` (``dataclasses.replace``) or set ``decision`` to
+ finish without calling the model; setting a decision stops delivery.
+ """
+
+ request: LLMCallRequest
+ decision: LLMCallDecision | None = None
+
+ @property
+ def stopped(self) -> bool:
+ return self.decision is not None
+
+
+@dataclass(kw_only=True)
+class LLMCallFinished(TapeEvent):
+ """An LLM call completed or failed with an ``Exception``; cancellation is not observed."""
+
+ request: LLMCallRequest
+ result: LLMCallResult
+
+
+@dataclass(kw_only=True)
+class ToolCallStarted(AgentEvent):
+ """A tool call is about to run.
+
+ Handlers may replace ``call`` to change its arguments, or set ``decision`` to
+ ``ToolCallDecision.replace(result)`` or ``deny(message)``, which stops delivery.
+ """
+
+ call: ToolCall
+ decision: ToolCallDecision = field(default_factory=lambda: ToolCallDecision.proceed())
+
+ @property
+ def stopped(self) -> bool:
+ return self.decision.action != "proceed"
+
+
+@dataclass(kw_only=True)
+class ToolCallFinished(AgentEvent):
+ """A tool call ended (success, failure, denial or replacement); assign ``result.result`` to change it."""
+
+ call: ToolCall
+ result: ToolCallResult
+
+
+@dataclass(kw_only=True)
+class ChatFinished(TapeEvent):
+ """One model step's exchange is complete and ready to be recorded.
+
+ Also emitted when the step fails before calling the model (``context_error``) or a
+ ``before_llm_call`` decision finishes it.
+ """
+
+ run_id: str
+ model: str
+ system_prompt: str | None
+ new_messages: list[republic.Message]
+ response_text: str | None
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
+ tool_results: list[Any] | None = None
+ usage: dict[str, Any] | None = None
+ provider: str | None = None
+ assistant_fields: dict[str, Any] = field(default_factory=dict)
+ error: BubError | None = None
+ context_error: BubError | None = None
+
+
+E = TypeVar("E", bound=Event)
+EventHandler = Callable[[E], object]
+"""A sync or async function of an event; its result is awaited if awaitable, then ignored."""
+
+
+class EventBus:
+ """Deliver events to handlers registered by event type, in registration order.
+
+ A handler registered for a base class receives its subclasses. Handler exceptions
+ propagate to the publisher.
+ """
+
+ def __init__(self) -> None:
+ self._handlers: list[tuple[type[Event], EventHandler[Any]]] = []
+
+ @overload
+ def on(self, event_type: type[E]) -> Callable[[EventHandler[E]], EventHandler[E]]: ...
+
+ @overload
+ def on(self, event_type: type[E], handler: EventHandler[E]) -> EventHandler[E]: ...
+
+ def on(self, event_type: type[E], handler: EventHandler[E] | None = None) -> Any:
+ """Register ``handler`` for ``event_type`` and return it.
+
+ Without ``handler``, return a decorator registering the decorated function::
+
+ @events.on(ToolCallStarted)
+ def guard(event: ToolCallStarted) -> None: ...
+ """
+ if handler is None:
+ return lambda handler: self.on(event_type, handler)
+ self._handlers.append((event_type, handler))
+ return handler
+
+ def off(self, event_type: type[E], handler: EventHandler[E]) -> None:
+ """Remove a handler registered for ``event_type``; do nothing if it is not registered."""
+ with suppress(ValueError):
+ self._handlers.remove((event_type, handler))
+
+ async def emit(self, event: E) -> E:
+ """Deliver ``event`` and return it, as modified by the handlers."""
+ for event_type, handler in list(self._handlers):
+ if event.stopped:
+ break
+ if isinstance(event, event_type):
+ result = handler(event)
+ if inspect.isawaitable(result):
+ await result
+ return event
diff --git a/src/bub/core/model.py b/src/bub/core/model.py
new file mode 100644
index 00000000..2ec1d48a
--- /dev/null
+++ b/src/bub/core/model.py
@@ -0,0 +1,83 @@
+"""Models the agent loop calls: Republic chat models, or clients streaming like them."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import AsyncIterator, Mapping
+from contextlib import AbstractAsyncContextManager, asynccontextmanager
+from typing import Any, Protocol
+
+import republic
+
+CONTEXT_LENGTH_PATTERNS = re.compile(
+ r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum",
+ re.IGNORECASE,
+)
+
+
+def is_context_length_error(error_msg: str) -> bool:
+ """Check whether an error message indicates a context-length / prompt-too-long failure."""
+ return bool(CONTEXT_LENGTH_PATTERNS.search(error_msg))
+
+
+class ModelClient(Protocol):
+ """A model source with its own policy (fallbacks, credentials, timeouts) for the agent loop."""
+
+ @property
+ def spec(self) -> str:
+ """The default ``provider:model`` spec."""
+ ...
+
+ max_tokens: int | None
+ """Default output token limit reported to hooks, or None."""
+
+ def open_stream(
+ self, messages: list[republic.Message], *, spec: str, options: Mapping[str, Any]
+ ) -> AbstractAsyncContextManager[tuple[str, republic.Stream[Any]]]:
+ """Open a streaming completion for ``spec``; yield the provider name and the stream.
+
+ ``spec`` is the default spec unless a ``LLMCallStarted`` handler redirected the call;
+ ``options`` are the call's ``republic.ChatOptions``, tools included.
+ """
+ ...
+
+
+Model = republic.ChatModel | ModelClient
+"""What a run calls: a ``republic.ChatModel``, or a :class:`ModelClient`."""
+
+
+class ChatModelClient:
+ """Adapt a ``republic.ChatModel`` to :class:`ModelClient`; the caller owns the model's lifecycle.
+
+ A call redirected to another model of the same provider reuses its client; one
+ redirected to another provider uses a client configured from Republic's environment.
+ """
+
+ max_tokens: int | None = None
+
+ def __init__(self, model: republic.ChatModel) -> None:
+ self.model = model
+
+ @property
+ def spec(self) -> str:
+ return f"{self.model.provider.name}:{self.model.name}"
+
+ @asynccontextmanager
+ async def open_stream(
+ self, messages: list[republic.Message], *, spec: str, options: Mapping[str, Any]
+ ) -> AsyncIterator[tuple[str, republic.Stream[Any]]]:
+ provider, _, name = spec.partition(":")
+ if spec == self.spec:
+ async with self.model.stream(messages, **options) as stream:
+ yield provider, stream
+ elif provider == self.model.provider.name:
+ async with self.model.provider.get_model(name).stream(messages, **options) as stream:
+ yield provider, stream
+ else:
+ async with republic.get_model(spec) as model, model.stream(messages, **options) as stream:
+ yield provider, stream
+
+
+def model_client(model: Model) -> ModelClient:
+ """Return ``model`` as a :class:`ModelClient`, adapting a ``republic.ChatModel``."""
+ return ChatModelClient(model) if isinstance(model, republic.ChatModel) else model
diff --git a/src/bub/core/recorder.py b/src/bub/core/recorder.py
new file mode 100644
index 00000000..d2446841
--- /dev/null
+++ b/src/bub/core/recorder.py
@@ -0,0 +1,92 @@
+"""Record agent events on a tape."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+import republic
+
+from bub.core.events import (
+ ChatFinished,
+ CommandFinished,
+ EventBus,
+ RunFinished,
+ RunStarted,
+ StepFinished,
+ StepStarted,
+)
+from bub.prompt import UserContent
+
+
+class TapeRecorder:
+ """Write the conversation and loop progress of agent events to the event's tape.
+
+ The agent loop reads its context back from the tape, so an agent always records
+ its own events.
+ """
+
+ def subscribe(self, events: EventBus) -> None:
+ events.on(RunStarted, self.on_run_started)
+ events.on(RunFinished, self.on_run_finished)
+ events.on(StepStarted, self.on_step_started)
+ events.on(StepFinished, self.on_step_finished)
+ events.on(ChatFinished, self.on_chat_finished)
+ events.on(CommandFinished, self.on_command_finished)
+
+ async def on_run_started(self, event: RunStarted) -> None:
+ await event.tape.append_event(
+ "loop.start", {"model": event.model, "prompt": prompt_payload(event.prompt), **event.metadata}
+ )
+
+ async def on_run_finished(self, event: RunFinished) -> None:
+ payload: dict[str, Any] = {"status": event.status, "steps": event.steps, "elapsed_ms": event.elapsed_ms}
+ if event.error is not None:
+ payload["error"] = event.error
+ payload["date"] = datetime.now(UTC).isoformat()
+ await event.tape.append_event("loop.finish", payload)
+
+ async def on_step_started(self, event: StepStarted) -> None:
+ prompt = None if event.prompt is None else prompt_payload(event.prompt)
+ await event.tape.append_event("loop.step.start", {"step": event.step, "prompt": prompt})
+
+ async def on_step_finished(self, event: StepFinished) -> None:
+ payload: dict[str, Any] = {"step": event.step, "elapsed_ms": event.elapsed_ms, "status": event.status}
+ if event.error is not None:
+ payload["error"] = event.error
+ payload["date"] = datetime.now(UTC).isoformat()
+ await event.tape.append_event("loop.step", payload)
+
+ async def on_command_finished(self, event: CommandFinished) -> None:
+ await event.tape.append_event(
+ "command",
+ {
+ "raw": event.line,
+ "name": event.name,
+ "status": event.status,
+ "elapsed_ms": event.elapsed_ms,
+ "output": event.output,
+ "date": datetime.now(UTC).isoformat(),
+ },
+ )
+
+ async def on_chat_finished(self, event: ChatFinished) -> None:
+ await event.tape.record_chat(
+ run_id=event.run_id,
+ system_prompt=event.system_prompt,
+ new_messages=event.new_messages,
+ response_text=event.response_text,
+ context_error=event.context_error,
+ tool_calls=event.tool_calls,
+ tool_results=event.tool_results,
+ error=event.error,
+ assistant_fields=event.assistant_fields,
+ provider=event.provider,
+ model=event.model,
+ usage=event.usage,
+ )
+
+
+def prompt_payload(content: list[UserContent]) -> object:
+ """Serialize prompt content for tape events."""
+ return republic.user(*content).to_dict().get("content")
diff --git a/src/bub/core/sidecars.py b/src/bub/core/sidecars.py
new file mode 100644
index 00000000..2ca767ee
--- /dev/null
+++ b/src/bub/core/sidecars.py
@@ -0,0 +1,20 @@
+"""Contracts for tapes mounted beside a session tape."""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+SIDECAR_TAPE_MARKER = "__sidecar__"
+
+
+class TapeSidecar(Protocol):
+ """A named capability backed by a sibling tape."""
+
+ @property
+ def name(self) -> str: ...
+
+
+def sidecar_tape_name(owner: str, sidecar: str) -> str:
+ """Return the physical tape name for a mounted sidecar."""
+
+ return f"{owner}{SIDECAR_TAPE_MARKER}{sidecar}"
diff --git a/src/bub/core/store.py b/src/bub/core/store.py
new file mode 100644
index 00000000..51604ed7
--- /dev/null
+++ b/src/bub/core/store.py
@@ -0,0 +1,486 @@
+from __future__ import annotations
+
+import asyncio
+import inspect
+import itertools
+import json
+import threading
+from collections.abc import Coroutine, Iterable, Sequence
+from dataclasses import asdict, dataclass, field, replace
+from datetime import UTC, datetime, time
+from datetime import date as date_type
+from pathlib import Path
+from typing import Any, NoReturn, Protocol, Self, overload
+
+from loguru import logger
+from typing_extensions import TypeIs
+
+from bub.core.tape import TapeEntry
+from bub.errors import BubError, ErrorKind
+
+
+class TapeStore(Protocol):
+ """Append-only tape storage interface."""
+
+ def list_tapes(self) -> list[str]: ...
+
+ def reset(self, tape: str) -> None: ...
+
+ def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: ...
+
+ def append(self, tape: str, entry: TapeEntry) -> None: ...
+
+
+class AsyncTapeStore(Protocol):
+ """Async append-only tape storage interface."""
+
+ async def list_tapes(self) -> list[str]: ...
+
+ async def reset(self, tape: str) -> None: ...
+
+ async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: ...
+
+ async def append(self, tape: str, entry: TapeEntry) -> None: ...
+
+
+def is_async_tape_store(store: TapeStore | AsyncTapeStore) -> TypeIs[AsyncTapeStore]:
+ return hasattr(store, "append") and inspect.iscoroutinefunction(store.append)
+
+
+@dataclass(frozen=True)
+class TapeQuery[T: TapeStore | AsyncTapeStore]:
+ tape: str
+ store: T
+ _query: str | None = None
+ _after_anchor: str | None = None
+ _after_last: bool = False
+ _between_anchors: tuple[str, str] | None = None
+ _between_dates: tuple[str, str] | None = None
+ _kinds: tuple[str, ...] = field(default_factory=tuple)
+ _limit: int | None = None
+
+ def query(self, value: str) -> Self:
+ return replace(self, _query=value)
+
+ def after_anchor(self, name: str) -> Self:
+ if not name:
+ return replace(self, _after_anchor=None, _after_last=False)
+ return replace(self, _after_anchor=name, _after_last=False)
+
+ def last_anchor(self) -> Self:
+ return replace(self, _after_anchor=None, _after_last=True)
+
+ def between_anchors(self, start: str, end: str) -> Self:
+ return replace(self, _between_anchors=(start, end))
+
+ def between_dates(self, start: str | date_type, end: str | date_type) -> Self:
+ start_value = start.isoformat() if isinstance(start, date_type) else start
+ end_value = end.isoformat() if isinstance(end, date_type) else end
+ return replace(self, _between_dates=(start_value, end_value))
+
+ def kinds(self, *kinds: str) -> Self:
+ return replace(self, _kinds=kinds)
+
+ def limit(self, value: int) -> Self:
+ return replace(self, _limit=value)
+
+ @overload
+ def all(self: TapeQuery[TapeStore]) -> Iterable[TapeEntry]: ...
+
+ @overload
+ async def all(self: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: ...
+
+ def all(self) -> Iterable[TapeEntry] | Coroutine[None, None, Iterable[TapeEntry]]:
+ return self.store.fetch_all(self)
+
+
+def _anchor_index(
+ entries: Sequence[TapeEntry],
+ name: str | None,
+ *,
+ default: int,
+ forward: bool,
+ start: int = 0,
+) -> int:
+ rng = range(start, len(entries)) if forward else range(len(entries) - 1, start - 1, -1)
+ for idx in rng:
+ entry = entries[idx]
+ if entry.kind != "anchor":
+ continue
+ if name is not None and entry.payload.get("name") != name:
+ continue
+ return idx
+ return default
+
+
+def _parse_datetime_boundary(value: str, *, is_end: bool) -> datetime:
+ if "T" not in value and " " not in value:
+ try:
+ parsed_date = date_type.fromisoformat(value)
+ except ValueError:
+ pass
+ else:
+ boundary_time = time.max if is_end else time.min
+ return datetime.combine(parsed_date, boundary_time, tzinfo=UTC)
+ try:
+ parsed = datetime.fromisoformat(value)
+ except ValueError:
+ try:
+ parsed_date = date_type.fromisoformat(value)
+ except ValueError as exc:
+ raise BubError(ErrorKind.INVALID_INPUT, f"Invalid ISO date or datetime: '{value}'.") from exc
+ boundary_time = time.max if is_end else time.min
+ parsed = datetime.combine(parsed_date, boundary_time, tzinfo=UTC)
+ if parsed.tzinfo is None:
+ return parsed.replace(tzinfo=UTC)
+ return parsed.astimezone(UTC)
+
+
+def _entry_in_datetime_range(entry: TapeEntry, start_dt: datetime, end_dt: datetime) -> bool:
+ entry_dt = _parse_datetime_boundary(entry.date, is_end=False)
+ return start_dt <= entry_dt <= end_dt
+
+
+def _entry_matches_query(entry: TapeEntry, query: str) -> bool:
+ needle = query.casefold()
+ haystack = json.dumps(
+ {
+ "kind": entry.kind,
+ "date": entry.date,
+ "payload": entry.payload,
+ "meta": entry.meta,
+ },
+ sort_keys=True,
+ default=str,
+ ).casefold()
+ return needle in haystack
+
+
+class InMemoryQueryMixin:
+ """Mixin to implement in-memory query support for simple stores."""
+
+ def read(self, tape: str) -> list[TapeEntry] | None:
+ raise NotImplementedError("InMemoryQueryMixin requires a read() method to be implemented.")
+
+ def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: # noqa: C901
+ entries = self.read(query.tape) or []
+ start_index = 0
+ end_index: int | None = None
+
+ if query._between_anchors is not None:
+ start_name, end_name = query._between_anchors
+ start_idx = _anchor_index(entries, start_name, default=-1, forward=False)
+ if start_idx < 0:
+ raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{start_name}' was not found.")
+ end_idx = _anchor_index(entries, end_name, default=-1, forward=True, start=start_idx + 1)
+ if end_idx < 0:
+ raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{end_name}' was not found.")
+ start_index = min(start_idx + 1, len(entries))
+ end_index = min(max(start_index, end_idx), len(entries))
+ elif query._after_last:
+ anchor_index = _anchor_index(entries, None, default=-1, forward=False)
+ if anchor_index < 0:
+ raise BubError(ErrorKind.NOT_FOUND, "No anchors found in tape.")
+ start_index = min(anchor_index + 1, len(entries))
+ elif query._after_anchor is not None:
+ anchor_index = _anchor_index(entries, query._after_anchor, default=-1, forward=False)
+ if anchor_index < 0:
+ raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found.")
+ start_index = min(anchor_index + 1, len(entries))
+
+ sliced = entries[start_index:end_index]
+ if query._between_dates is not None:
+ start_date, end_date = query._between_dates
+ start_dt = _parse_datetime_boundary(start_date, is_end=False)
+ end_dt = _parse_datetime_boundary(end_date, is_end=True)
+ if start_dt > end_dt:
+ raise BubError(ErrorKind.INVALID_INPUT, "Start date must be earlier than or equal to end date.")
+ sliced = [entry for entry in sliced if _entry_in_datetime_range(entry, start_dt, end_dt)]
+ if query._query:
+ sliced = [entry for entry in sliced if _entry_matches_query(entry, query._query)]
+ if query._kinds:
+ sliced = [entry for entry in sliced if entry.kind in query._kinds]
+ if query._limit is not None:
+ sliced = sliced[: query._limit]
+ return sliced
+
+
+class InMemoryTapeStore(InMemoryQueryMixin):
+ """In-memory tape storage."""
+
+ def __init__(self) -> None:
+ self._tapes: dict[str, list[TapeEntry]] = {}
+ self._next_id: dict[str, int] = {}
+
+ def list_tapes(self) -> list[str]:
+ return sorted(self._tapes.keys())
+
+ def reset(self, tape: str) -> None:
+ self._tapes.pop(tape, None)
+ self._next_id.pop(tape, None)
+
+ def read(self, tape: str) -> list[TapeEntry] | None:
+ entries = self._tapes.get(tape)
+ if entries is None:
+ return None
+ return [entry.copy() for entry in entries]
+
+ def append(self, tape: str, entry: TapeEntry) -> None:
+ next_id = self._next_id.get(tape, 1)
+ self._next_id[tape] = next_id + 1
+ stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta), entry.date)
+ self._tapes.setdefault(tape, []).append(stored)
+
+
+class AsyncTapeStoreAdapter:
+ """Adapt a sync TapeStore to AsyncTapeStore."""
+
+ def __init__(self, store: TapeStore) -> None:
+ self._store = store
+
+ async def list_tapes(self) -> list[str]:
+ return await asyncio.to_thread(self._store.list_tapes)
+
+ async def reset(self, tape: str) -> None:
+ await asyncio.to_thread(self._store.reset, tape)
+
+ async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]:
+ return await asyncio.to_thread(self._store.fetch_all, query)
+
+ async def append(self, tape: str, entry: TapeEntry) -> None:
+ await asyncio.to_thread(self._store.append, tape, entry)
+
+
+class UnavailableTapeStore:
+ """Sync TapeStore sentinel that always fails with a clear message."""
+
+ def __init__(self, message: str) -> None:
+ self._message = message
+
+ def _raise(self) -> NoReturn:
+ raise BubError(ErrorKind.INVALID_INPUT, self._message)
+
+ def list_tapes(self) -> list[str]:
+ self._raise()
+
+ def reset(self, tape: str) -> None:
+ self._raise()
+
+ def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]:
+ self._raise()
+
+ def append(self, tape: str, entry: TapeEntry) -> None:
+ self._raise()
+
+
+class ForkTapeStore:
+ def __init__(self, parent: AsyncTapeStore, tape: str, *, sidecars: Iterable[str] = ()) -> None:
+ self._parent = parent
+ self._store = InMemoryTapeStore()
+ self._tape = tape
+ self._sidecars = tuple(dict.fromkeys(sidecars))
+ self._managed_tapes = {tape, *self._sidecars}
+ self._reset_tapes: set[str] = set()
+
+ async def list_tapes(self) -> list[str]:
+ return await self._parent.list_tapes()
+
+ async def reset(self, tape: str) -> None:
+ if tape not in self._managed_tapes:
+ await self._parent.reset(tape)
+ return
+ self._store.reset(tape)
+ self._reset_tapes.add(tape)
+
+ async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]:
+ if query.tape not in self._managed_tapes:
+ return await self._parent.fetch_all(query)
+
+ parent_entries: Iterable[TapeEntry] = []
+ if query.tape not in self._reset_tapes:
+ try:
+ parent_entries = await self._parent.fetch_all(query)
+ except Exception:
+ parent_entries = []
+ this_entries: list[TapeEntry] = []
+ for entry in self._store.read(query.tape) or []:
+ if entry.kind == "anchor": # noqa: SIM102
+ if query._after_last or (query._after_anchor and entry.payload.get("name") == query._after_anchor):
+ this_entries.clear()
+ parent_entries = []
+ continue
+ if query._kinds and entry.kind not in query._kinds:
+ continue
+ this_entries.append(entry)
+ entries = itertools.chain(parent_entries, this_entries)
+ return itertools.islice(entries, query._limit) if query._limit is not None else entries
+
+ @staticmethod
+ def _redact_prompt(prompt: list[dict]) -> Any:
+ if not isinstance(prompt, list):
+ return prompt
+ new_prompt = []
+ for part in prompt:
+ if part.get("type") == "text":
+ new_prompt.append(part)
+ return new_prompt
+
+ @staticmethod
+ def _redact_payload(payload: dict) -> None:
+ if "content" in payload:
+ payload["content"] = ForkTapeStore._redact_prompt(payload["content"])
+ elif "prompt" in payload:
+ payload["prompt"] = ForkTapeStore._redact_prompt(payload["prompt"])
+
+ async def append(self, tape: str, entry: TapeEntry) -> None:
+ self._redact_payload(entry.payload)
+ if tape not in self._managed_tapes:
+ await self._parent.append(tape, entry)
+ return
+ self._store.append(tape, entry)
+
+ async def merge_back(self) -> None:
+ total = 0
+ for sidecar in self._sidecars:
+ entries = self._store.read(sidecar) or []
+ try:
+ if sidecar in self._reset_tapes:
+ await self._parent.reset(sidecar)
+ for entry in entries:
+ await self._parent.append(sidecar, entry)
+ except Exception as exc:
+ logger.warning('Failed to merge sidecar "{}" into tape "{}": {}', sidecar, self._tape, exc)
+ self._store.append(
+ self._tape,
+ TapeEntry.event(
+ "sidecar.merge",
+ {"tape": sidecar, "status": "error", "error": str(exc)},
+ context=False,
+ ),
+ )
+ else:
+ total += len(entries)
+
+ if self._tape in self._reset_tapes:
+ await self._parent.reset(self._tape)
+ entries = self._store.read(self._tape) or []
+ for entry in entries:
+ await self._parent.append(self._tape, entry)
+ total += len(entries)
+ if total:
+ logger.info('Merged {} entries into tape fork "{}"', total, self._tape)
+
+
+class FileTapeStore(InMemoryQueryMixin):
+ """TapeStore implementation that persists tapes as JSONL files under a directory."""
+
+ def __init__(self, directory: Path) -> None:
+ self._directory = directory
+ self._directory.mkdir(parents=True, exist_ok=True)
+ self._tape_files: dict[str, TapeFile] = {}
+
+ def _tape_file(self, tape: str) -> TapeFile:
+ if tape not in self._tape_files:
+ self._tape_files[tape] = TapeFile(self._directory / f"{tape}.jsonl")
+ return self._tape_files[tape]
+
+ def list_tapes(self) -> list[str]:
+ return sorted(file.stem for file in self._directory.glob("*.jsonl") if file.stem.count("__") == 1)
+
+ def reset(self, tape: str) -> None:
+ self._tape_file(tape).reset()
+
+ def append(self, tape: str, entry: TapeEntry) -> None:
+ self._tape_file(tape).append(entry)
+
+ def read(self, tape: str) -> list[TapeEntry] | None:
+ return self._tape_file(tape).read()
+
+
+class TapeFile:
+ """Helper for one tape file."""
+
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self._lock = threading.Lock()
+ self._read_entries: list[TapeEntry] = []
+ self._read_offset = 0
+
+ def _next_id(self) -> int:
+ if self._read_entries:
+ return self._read_entries[-1].id + 1
+ return 1
+
+ def _reset(self) -> None:
+ self._read_entries = []
+ self._read_offset = 0
+
+ def reset(self) -> None:
+ with self._lock:
+ if self.path.exists():
+ self.path.unlink()
+ self._reset()
+
+ def read(self) -> list[TapeEntry]:
+ with self._lock:
+ return self._read_locked()
+
+ def _read_locked(self) -> list[TapeEntry]:
+ if not self.path.exists():
+ self._reset()
+ return []
+
+ file_size = self.path.stat().st_size
+ if file_size < self._read_offset:
+ # The file was truncated or replaced, so cached entries are stale.
+ self._reset()
+
+ with self.path.open("r", encoding="utf-8") as handle:
+ handle.seek(self._read_offset)
+ for raw_line in handle:
+ line = raw_line.strip()
+ if not line:
+ continue
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ entry = self.entry_from_payload(payload)
+ if entry is not None:
+ self._read_entries.append(entry)
+ self._read_offset = handle.tell()
+
+ return list(self._read_entries)
+
+ @staticmethod
+ def entry_from_payload(payload: object) -> TapeEntry | None:
+ if not isinstance(payload, dict):
+ return None
+ entry_id = payload.get("id")
+ kind = payload.get("kind")
+ entry_payload = payload.get("payload")
+ meta = payload.get("meta")
+ if not isinstance(entry_id, int):
+ return None
+ if not isinstance(kind, str):
+ return None
+ if not isinstance(entry_payload, dict):
+ return None
+ if not isinstance(meta, dict):
+ meta = {}
+ if "date" in payload:
+ date = payload["date"]
+ else:
+ date = datetime.fromtimestamp(payload.get("timestamp", 0.0), tz=UTC).isoformat()
+ return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), date)
+
+ def append(self, entry: TapeEntry) -> None:
+ with self._lock:
+ # Keep cache and offset in sync before allocating new IDs.
+ self._read_locked()
+ with self.path.open("a", encoding="utf-8") as handle:
+ next_id = self._next_id()
+ stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta), entry.date)
+ handle.write(json.dumps(asdict(stored), ensure_ascii=False) + "\n")
+ self._read_entries.append(stored)
+ self._read_offset = handle.tell()
diff --git a/src/bub/core/system_prompt.py b/src/bub/core/system_prompt.py
new file mode 100644
index 00000000..aa01739b
--- /dev/null
+++ b/src/bub/core/system_prompt.py
@@ -0,0 +1,91 @@
+"""System prompts assembled from registered sections."""
+
+from __future__ import annotations
+
+import inspect
+from collections.abc import Awaitable, Callable, Iterable
+from contextlib import suppress
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, TypeVar
+
+from bub.prompt import UserContent
+from bub.turn import TurnState
+
+if TYPE_CHECKING:
+ from bub.core.agent import Agent
+ from bub.core.tape import Tape
+ from bub.core.tools import Tool
+
+
+@dataclass(frozen=True)
+class PromptContext:
+ """What a system prompt section sees when rendered for one model step."""
+
+ agent: Agent
+ tape: Tape
+ prompt: list[UserContent]
+ """The step's new user message; empty when the step continues the conversation."""
+ tools: list[Tool]
+ """Model-facing tools offered in this step."""
+ model: str
+ """The ``provider:model`` spec called in this step."""
+
+ @property
+ def state(self) -> TurnState:
+ """The run's turn state."""
+ return self.tape.context.state
+
+ @property
+ def available_tools(self) -> list[Tool]:
+ """All tools the agent was given, before per-step selection."""
+ return list(self.agent.tools.values())
+
+
+PromptSection = str | Callable[[PromptContext], str | None | Awaitable[str | None]]
+"""Fixed text, or a function of the step's context returning text (empty or None to skip)."""
+
+S = TypeVar("S", bound=PromptSection)
+
+
+class SystemPrompt:
+ """Ordered system prompt sections, rendered for each model step and joined by blank lines."""
+
+ def __init__(self, sections: Iterable[PromptSection] = ()) -> None:
+ self._sections: list[PromptSection] = list(sections)
+
+ @classmethod
+ def of(cls, value: SystemPrompt | PromptSection | Iterable[PromptSection]) -> SystemPrompt:
+ """Build a system prompt from one section, sections, or a copy of another system prompt."""
+ if isinstance(value, SystemPrompt):
+ return cls(value.sections)
+ if isinstance(value, str) or callable(value):
+ return cls([value])
+ return cls(value)
+
+ @property
+ def sections(self) -> tuple[PromptSection, ...]:
+ return tuple(self._sections)
+
+ def add(self, section: S) -> S:
+ """Append a section and return it, so this also works as a decorator::
+
+ @system_prompt.add
+ def tools(context: PromptContext) -> str: ...
+ """
+ self._sections.append(section)
+ return section
+
+ def remove(self, section: PromptSection) -> None:
+ """Remove a section; do nothing if it is not present."""
+ with suppress(ValueError):
+ self._sections.remove(section)
+
+ async def render(self, context: PromptContext) -> str:
+ blocks: list[str] = []
+ for section in list(self._sections):
+ text = section if isinstance(section, str) else section(context)
+ if inspect.isawaitable(text):
+ text = await text
+ if text:
+ blocks.append(text)
+ return "\n\n".join(blocks)
diff --git a/src/bub/core/tape.py b/src/bub/core/tape.py
new file mode 100644
index 00000000..36c881b3
--- /dev/null
+++ b/src/bub/core/tape.py
@@ -0,0 +1,521 @@
+"""Append-only tape primitives owned by Bub."""
+
+from __future__ import annotations
+
+import contextlib
+import hashlib
+import inspect
+import json
+from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping, MutableMapping
+from dataclasses import asdict, dataclass, field, replace
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+import republic
+
+from bub import tracing
+from bub.core.sidecars import TapeSidecar, sidecar_tape_name
+from bub.errors import BubError, ErrorKind
+
+__all__ = [
+ "LAST_ANCHOR",
+ "AnchorSelector",
+ "AnchorSummary",
+ "AsyncTapeStore",
+ "AsyncTapeStoreAdapter",
+ "ContextSelector",
+ "InMemoryQueryMixin",
+ "InMemoryTapeStore",
+ "SelectedMessages",
+ "Tape",
+ "TapeContext",
+ "TapeEntry",
+ "TapeInfo",
+ "TapeQuery",
+ "TapeStore",
+ "UnavailableTapeStore",
+ "build_messages",
+ "is_async_tape_store",
+ "session_tape_name",
+ "to_message",
+ "to_messages",
+ "utc_now",
+]
+
+if TYPE_CHECKING:
+ from bub.core.store import (
+ AsyncTapeStore,
+ AsyncTapeStoreAdapter,
+ InMemoryQueryMixin,
+ InMemoryTapeStore,
+ TapeQuery,
+ TapeStore,
+ UnavailableTapeStore,
+ is_async_tape_store,
+ )
+
+
+_STORE_EXPORTS = frozenset({
+ "AsyncTapeStore",
+ "AsyncTapeStoreAdapter",
+ "InMemoryQueryMixin",
+ "InMemoryTapeStore",
+ "TapeQuery",
+ "TapeStore",
+ "UnavailableTapeStore",
+ "is_async_tape_store",
+})
+
+
+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
+
+ return getattr(store, name)
+
+
+def __dir__() -> list[str]:
+ return sorted({*globals(), *_STORE_EXPORTS})
+
+
+def utc_now() -> str:
+ return datetime.now(UTC).isoformat()
+
+
+@dataclass(frozen=True)
+class TapeEntry:
+ """A single append-only entry in a tape."""
+
+ id: int
+ kind: str
+ payload: dict[str, Any]
+ meta: dict[str, Any] = field(default_factory=dict)
+ date: str = field(default_factory=utc_now)
+
+ def copy(self) -> TapeEntry:
+ return TapeEntry(self.id, self.kind, dict(self.payload), dict(self.meta), self.date)
+
+ @classmethod
+ def message(cls, message: dict[str, Any], **meta: Any) -> TapeEntry:
+ return cls(id=0, kind="message", payload=dict(message), meta=dict(meta))
+
+ @classmethod
+ def system(cls, content: str, **meta: Any) -> TapeEntry:
+ return cls(id=0, kind="system", payload={"content": content}, meta=dict(meta))
+
+ @classmethod
+ def anchor(cls, name: str, state: dict[str, Any] | None = None, **meta: Any) -> TapeEntry:
+ payload: dict[str, Any] = {"name": name}
+ if state is not None:
+ payload["state"] = dict(state)
+ return cls(id=0, kind="anchor", payload=payload, meta=dict(meta))
+
+ @classmethod
+ def tool_call(cls, calls: list[dict[str, Any]], *, content: str = "", **meta: Any) -> TapeEntry:
+ payload: dict[str, Any] = {"calls": calls, "content": content}
+ return cls(id=0, kind="tool_call", payload=payload, meta=dict(meta))
+
+ @classmethod
+ def tool_result(cls, results: list[Any], **meta: Any) -> TapeEntry:
+ return cls(id=0, kind="tool_result", payload={"results": results}, meta=dict(meta))
+
+ @classmethod
+ def error(cls, error: BubError, **meta: Any) -> TapeEntry:
+ return cls(id=0, kind="error", payload=error.as_dict(), meta=dict(meta))
+
+ @classmethod
+ def event(cls, name: str, data: dict[str, Any] | None = None, **meta: Any) -> TapeEntry:
+ payload: dict[str, Any] = {"name": name}
+ if data is not None:
+ payload["data"] = dict(data)
+ return cls(id=0, kind="event", payload=payload, meta=dict(meta))
+
+
+class _LastAnchor:
+ def __repr__(self) -> str:
+ return "LAST_ANCHOR"
+
+
+LAST_ANCHOR = _LastAnchor()
+type AnchorSelector = str | None | _LastAnchor
+type SelectedMessages = list[republic.Message] | Coroutine[Any, Any, list[republic.Message]]
+type ContextSelector = Callable[[Iterable[TapeEntry], "TapeContext"], SelectedMessages]
+
+
+@dataclass(frozen=True)
+class TapeContext:
+ """Rules for selecting tape entries into a prompt context."""
+
+ anchor: AnchorSelector = LAST_ANCHOR
+ select: ContextSelector | None = None
+ state: dict[str, Any] = field(default_factory=dict)
+
+ def build_query(self, query: TapeQuery) -> TapeQuery:
+ if self.anchor is None:
+ return query
+ if isinstance(self.anchor, _LastAnchor):
+ return query.last_anchor()
+ return query.after_anchor(self.anchor)
+
+
+def build_messages(entries: Iterable[TapeEntry], context: TapeContext) -> SelectedMessages:
+ if context.select is not None:
+ return context.select(entries, context)
+ return _default_messages(entries)
+
+
+def _default_messages(entries: Iterable[TapeEntry]) -> list[republic.Message]:
+ return to_messages(
+ entry.payload for entry in entries if entry.kind == "message" and isinstance(entry.payload, dict)
+ )
+
+
+def to_messages(payloads: Iterable[Mapping[str, Any]]) -> list[republic.Message]:
+ """Convert stored message payloads to Republic messages, resolving tool results to earlier calls."""
+ calls: dict[str, republic.ToolCall] = {}
+ return [to_message(payload, calls) for payload in payloads]
+
+
+def to_message(
+ payload: Mapping[str, Any], calls: MutableMapping[str, republic.ToolCall] | None = None
+) -> republic.Message:
+ """Convert one stored message payload; assistant tool calls are added to ``calls``.
+
+ Payloads are ``republic.Message.to_dict()`` output. Older Bub payloads, with media URLs,
+ ``tool_call_id``, ``reasoning`` and ``provider_data`` fields, and tool calls in the Chat
+ Completions ``{"function": {"name", "arguments"}}`` form, are converted to that form first.
+ """
+ calls = {} if calls is None else calls
+ role = payload.get("role")
+ if role not in {"system", "user", "assistant", "tool"}:
+ raise BubError(ErrorKind.INVALID_INPUT, f"Unknown message role: {role}")
+ 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
+
+ raw_content = render_result(raw_content)
+ content = _content_parts(raw_content)
+ if isinstance(tool_calls := data.get("tool_calls"), list):
+ data["tool_calls"] = [_tool_call_dict(call) for call in tool_calls]
+ if role == "tool" and "tool_call" not in data:
+ call_id = data.pop("tool_call_id", None)
+ if not isinstance(call_id, str) or not call_id:
+ raise BubError(ErrorKind.INVALID_INPUT, "Tool result without a tool call ID.")
+ # Republic announces calls missing from the conversation before their results.
+ call = calls.get(call_id) or republic.ToolCall(call_id, data.pop("name", None) or "", "{}")
+ data["tool_call"] = asdict(call)
+ if reasoning := data.pop("reasoning", None):
+ content.append({"type": "reasoning", "text": reasoning})
+ content.extend({"type": "provider_data", **item} for item in data.pop("provider_data", None) or [])
+ data["content"] = content
+ try:
+ message = republic.Message.from_dict(data)
+ except (KeyError, TypeError, ValueError) as exc:
+ raise BubError(ErrorKind.INVALID_INPUT, f"Invalid {role} message: {exc}") from exc
+ if role == "system" and any(not isinstance(part, republic.Text) for part in message.parts):
+ raise BubError(ErrorKind.INVALID_INPUT, "System messages only support text content.")
+ calls.update((call.id, call) for call in message.tool_calls)
+ return message
+
+
+def _tool_call_dict(call: object) -> object:
+ """Convert a Chat Completions tool call, as recorded by older Bub versions, to Republic's form."""
+ if not isinstance(call, dict) or not isinstance(function := call.get("function"), dict):
+ return call
+ arguments = function.get("arguments")
+ return {
+ "id": call.get("id") or "",
+ "name": function.get("name") or "",
+ "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments or {}),
+ }
+
+
+def _content_parts(content: object) -> list[dict[str, Any]]:
+ if content is None:
+ return []
+ if isinstance(content, str):
+ return [{"type": "text", "text": content}]
+ if not isinstance(content, list):
+ raise BubError(ErrorKind.INVALID_INPUT, "Expected text or a list of message content blocks.")
+ parts: list[dict[str, Any]] = []
+ for block in content:
+ match block:
+ case {"type": "image" | "audio" | "video" as kind, "url": str(url), "media_type": str(mime)} if (
+ url.startswith("data:") and "data" not in block
+ ):
+ # Older payloads keep inline media as data URLs.
+ media = {"image": republic.image, "audio": republic.audio, "video": republic.video}[kind](
+ url, media_type=mime
+ )
+ parts.append({"type": kind, "media_type": media.media_type, "data": media.base64_data})
+ case {"type": "image" | "audio" | "video" as kind} if "url" not in block and "data" not in block:
+ raise BubError(ErrorKind.INVALID_INPUT, f"The {kind} content block needs a url or data.")
+ case {"type": "text" | "reasoning" | "image" | "audio" | "video" | "provider_data"}:
+ parts.append(block)
+ case _:
+ kind = block.get("type") if isinstance(block, dict) else type(block).__name__
+ raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported message content block: {kind}")
+ return parts
+
+
+@dataclass(frozen=True)
+class TapeInfo:
+ """Runtime tape info summary."""
+
+ name: str
+ entries: int
+ anchors: int
+ last_anchor: str | None
+ entries_since_last_anchor: int
+ last_token_usage: int | None
+ last_token_cache_hit_rate: float | None
+
+
+@dataclass(frozen=True)
+class AnchorSummary:
+ """Rendered anchor summary."""
+
+ name: str
+ state: dict[str, object]
+
+
+def session_tape_name(session_id: str, workspace: Path) -> str:
+ """Return the tape name of a session in a workspace."""
+ workspace_hash = hashlib.md5(str(workspace.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
+ return workspace_hash + "__" + hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
+
+
+@dataclass(frozen=True)
+class Tape:
+ """Tape abstraction for recording agent interactions."""
+
+ store: AsyncTapeStore
+ context: TapeContext
+ sidecars: tuple[TapeSidecar, ...] = field(default=(), repr=False)
+ _name: str | None = field(default=None, repr=False)
+
+ @property
+ def name(self) -> str:
+ if self._name is None:
+ raise ValueError("tape is not scoped")
+ return self._name
+
+ def with_context(self, context: TapeContext) -> Tape:
+ return replace(self, context=context)
+
+ 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
+
+ return TapeQuery(tape=self.name, store=self.store)
+
+ def get_sidecar(self, name: str) -> TapeSidecar | None:
+ """Return a mounted sidecar by its public name."""
+
+ return next((sidecar for sidecar in self.sidecars if sidecar.name == name), None)
+
+ def sidecar_tape_name(self, name: str) -> str:
+ """Return the sibling tape name for a mounted sidecar."""
+
+ if self.get_sidecar(name) is None:
+ raise KeyError(f"tape sidecar {name!r} is not mounted")
+ return sidecar_tape_name(self.name, name)
+
+ async def info(self) -> TapeInfo:
+ entries = list(await self.store.fetch_all(self.query()))
+ anchors = [(i, entry) for i, entry in enumerate(entries) if entry.kind == "anchor"]
+ if anchors:
+ last_anchor = anchors[-1][1].payload.get("name")
+ entries_since_last_anchor = len(entries) - anchors[-1][0] - 1
+ else:
+ last_anchor = None
+ entries_since_last_anchor = len(entries)
+ last_token_usage: int | None = None
+ last_token_cache_hit_rate: float | None = None
+ for entry in reversed(entries):
+ if entry.kind == "event" and entry.payload.get("name") == "run":
+ data = entry.payload.get("data")
+ usage = data.get("usage") if isinstance(data, Mapping) else None
+ if not isinstance(usage, Mapping):
+ continue
+ token_usage = usage.get("total_tokens")
+ if not isinstance(token_usage, int) or isinstance(token_usage, bool):
+ continue
+ last_token_usage = token_usage
+ input_tokens = usage.get("input_tokens")
+ cached_tokens = usage.get("cached_tokens")
+ if (
+ isinstance(input_tokens, int)
+ and not isinstance(input_tokens, bool)
+ and input_tokens > 0
+ and isinstance(cached_tokens, int)
+ and not isinstance(cached_tokens, bool)
+ ):
+ last_token_cache_hit_rate = cached_tokens / input_tokens
+ break
+ return TapeInfo(
+ name=self.name,
+ entries=len(entries),
+ anchors=len(anchors),
+ last_anchor=str(last_anchor) if last_anchor else None,
+ entries_since_last_anchor=entries_since_last_anchor,
+ last_token_usage=last_token_usage,
+ last_token_cache_hit_rate=last_token_cache_hit_rate,
+ )
+
+ async def ensure_bootstrap_anchor(self) -> None:
+ anchors = list(await self.store.fetch_all(self.query().kinds("anchor")))
+ if not anchors:
+ await self.handoff(name="session/start", state={"owner": "human"})
+
+ async def anchors(self, limit: int = 20) -> list[AnchorSummary]:
+ entries = list(await self.store.fetch_all(self.query().kinds("anchor")))
+ results: list[AnchorSummary] = []
+ for entry in entries[-limit:]:
+ name = str(entry.payload.get("name", "-"))
+ state = entry.payload.get("state")
+ state_dict: dict[str, object] = dict(state) if isinstance(state, dict) else {}
+ results.append(AnchorSummary(name=name, state=state_dict))
+ return results
+
+ async def search(self, query: TapeQuery[AsyncTapeStore]) -> list[TapeEntry]:
+ return list(await self.store.fetch_all(query))
+
+ async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None:
+ tracing.event(f"bub.{name}", **payload)
+ await self.store.append(self.name, TapeEntry.event(name, payload, **(tracing.correlation() | meta)))
+
+ async def read_messages(self) -> list[republic.Message]:
+ query = self.context.build_query(self.query())
+ entries = await self.store.fetch_all(query)
+ context_entries = (entry for entry in entries if entry.meta.get("context") is not False)
+ messages = build_messages(context_entries, self.context)
+ if inspect.isawaitable(messages):
+ messages = await messages
+ return messages
+
+ async def handoff(
+ self,
+ *,
+ name: str,
+ state: dict[str, Any] | None = None,
+ **meta: Any,
+ ) -> list[TapeEntry]:
+ tape_name = self.name
+ meta = tracing.correlation() | meta
+ tracing.event("bub.handoff", anchor=name, state=state)
+ entry = TapeEntry.anchor(name, state=state, **meta)
+ event = TapeEntry.event("handoff", {"name": name, "state": state or {}}, **meta)
+ await self.store.append(tape_name, entry)
+ await self.store.append(tape_name, event)
+ return [entry, event]
+
+ async def record_chat( # noqa: C901
+ self,
+ *,
+ run_id: str,
+ system_prompt: str | None,
+ new_messages: list[republic.Message],
+ response_text: str | None,
+ context_error: BubError | None = None,
+ tool_calls: list[dict[str, Any]] | None = None,
+ tool_results: list[Any] | None = None,
+ error: BubError | None = None,
+ provider: str | None = None,
+ model: str | None = None,
+ usage: dict[str, Any] | None = None,
+ assistant_fields: dict[str, Any] | None = None,
+ ) -> None:
+ tape_name = self.name
+ meta = {"run_id": run_id, **tracing.correlation()}
+ if system_prompt:
+ await self.store.append(tape_name, TapeEntry.system(system_prompt, **meta))
+ if context_error is not None:
+ await self.store.append(tape_name, TapeEntry.error(context_error, **meta))
+ for message in new_messages:
+ await self.store.append(tape_name, TapeEntry.message(message.to_dict(), **meta))
+ if tool_calls:
+ call_entry = TapeEntry.tool_call(tool_calls, content=response_text or "", **meta)
+ call_entry.payload.update(assistant_fields or {})
+ await self.store.append(tape_name, call_entry)
+ if tool_results is not None:
+ await self.store.append(tape_name, TapeEntry.tool_result(tool_results, **meta))
+ if error is not None and error is not context_error:
+ await self.store.append(tape_name, TapeEntry.error(error, **meta))
+ if response_text is not None and not tool_calls:
+ await self.store.append(
+ tape_name,
+ TapeEntry.message({"role": "assistant", "content": response_text, **(assistant_fields or {})}, **meta),
+ )
+
+ data: dict[str, Any] = {"status": "error" if error is not None else "ok"}
+ if usage is not None:
+ data["usage"] = usage
+ if provider:
+ data["provider"] = provider
+ if model:
+ data["model"] = model
+ await self.store.append(tape_name, TapeEntry.event("run", data, **meta))
+
+ @staticmethod
+ def _sidecar_lifecycle_data(
+ *, sidecar: str, status: str, reason: str, error: Exception | None = None
+ ) -> dict[str, Any]:
+ data: dict[str, Any] = {"sidecar": sidecar, "status": status, "reason": reason}
+ if error is not None:
+ data["error"] = str(error)
+ return data
+
+ async def _try_reset_sidecar(self, sidecar: TapeSidecar, *, reason: str) -> dict[str, Any]:
+ try:
+ await self.store.reset(sidecar_tape_name(self.name, sidecar.name))
+ except Exception as exc:
+ return self._sidecar_lifecycle_data(sidecar=sidecar.name, status="error", reason=reason, error=exc)
+ return self._sidecar_lifecycle_data(sidecar=sidecar.name, status="ok", reason=reason)
+
+ async def reset_sidecar(self, name: str, *, reason: str = "gc") -> str:
+ """Reset one mounted sidecar and record the outcome on the main tape."""
+
+ sidecar = self.get_sidecar(name)
+ if sidecar is None:
+ raise KeyError(f"tape sidecar {name!r} is not mounted")
+ reset_data = await self._try_reset_sidecar(sidecar, reason=reason)
+ await self.append_event("sidecar.reset", reset_data, context=False)
+ return f"{name} reset failed: {reset_data['error']}" if reset_data["status"] == "error" else "ok"
+
+ async def reset(self, *, state: dict[str, Any] | None = None, keep_sidecars: Iterable[str] = ()) -> None:
+ """Clear the tape and start a new context with a ``session/start`` anchor.
+
+ ``state`` is added to the anchor's state. Mounted sidecars are reset too, except
+ those named in ``keep_sidecars``; each reset is recorded as ``sidecar.reset``.
+ """
+ keep = set(keep_sidecars)
+ await self.store.reset(self.name)
+ await self.handoff(name="session/start", state={"owner": "human", **(state or {})})
+ for sidecar in self.sidecars:
+ if sidecar.name in keep:
+ continue
+ reset_data = await self._try_reset_sidecar(sidecar, reason="tape.reset")
+ await self.append_event("sidecar.reset", reset_data, context=False)
+
+ def session_tape(self, session_id: str, workspace: Path, context: TapeContext | None = None) -> Tape:
+ return self.scoped(session_tape_name(session_id, workspace), context=context)
+
+ @contextlib.asynccontextmanager
+ async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[Tape, None]:
+ from bub.core.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)
+ forked = replace(self, store=fork_store)
+ try:
+ yield forked
+ finally:
+ if merge_back:
+ await fork_store.merge_back()
diff --git a/src/bub/core/tools.py b/src/bub/core/tools.py
new file mode 100644
index 00000000..76eec3bf
--- /dev/null
+++ b/src/bub/core/tools.py
@@ -0,0 +1,645 @@
+from __future__ import annotations
+
+import asyncio
+import inspect
+import json
+import time
+from collections.abc import Callable, Iterable, Sequence
+from contextlib import AbstractAsyncContextManager, nullcontext
+from dataclasses import dataclass, field, replace
+from typing import Any, Literal, get_args, get_type_hints, overload
+
+import republic
+from loguru import logger
+from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call
+
+from bub.core.events import EventBus, ToolCall, ToolCallFinished, ToolCallResult, ToolCallStarted
+from bub.core.tape import Tape
+from bub.errors import BubError, ErrorKind
+from bub.prompt import UserContent
+from bub.tracing import Span
+
+
+@dataclass(frozen=True)
+class ToolContext:
+ """Runtime context passed to tools that opt into context.
+
+ ``code_mode`` is true when the tool is invoked from model-written code rather than
+ as a direct model tool call. Whether the result is rendered to text is decided by the
+ ``ToolExecutor`` running the call, not by this flag. ``command`` is true when the tool
+ runs as a command prompt rather than for the model. ``events`` is the bus of the agent
+ running the call, for tools that execute further tool calls.
+ """
+
+ tape: Tape
+ run_id: str | None = None
+ state: dict[str, Any] = field(default_factory=dict)
+ code_mode: bool = False
+ command: bool = False
+ events: EventBus | None = field(default=None, repr=False, compare=False)
+
+
+def render_result(result: Any) -> str:
+ """Render a structured tool result as plain text (the default tool renderer)."""
+ if isinstance(result, str):
+ return result
+ if isinstance(result, BaseModel):
+ return result.model_dump_json()
+ try:
+ return json.dumps(result, ensure_ascii=False)
+ except TypeError:
+ return str(result)
+
+
+CONTENT_RESULT_TYPE = "tool_content"
+
+
+def content_result(content: Sequence[UserContent]) -> dict[str, Any]:
+ """Build a model-facing tool result that carries media along with text.
+
+ The result is JSON so it can be recorded on the tape; ``result_content`` reads it back.
+ """
+ blocks = [
+ {"type": "text", "text": item} if isinstance(item, str) else republic.user(item).to_dict()["content"][0]
+ for item in content
+ ]
+ return {"type": CONTENT_RESULT_TYPE, "content": blocks}
+
+
+def is_content_result(result: Any) -> bool:
+ return (
+ isinstance(result, dict)
+ and result.get("type") == CONTENT_RESULT_TYPE
+ and isinstance(result.get("content"), list)
+ )
+
+
+def result_content(result: Any) -> list[UserContent]:
+ """Return a tool result as content for the model: media of ``content_result`` results, otherwise text."""
+ if not is_content_result(result):
+ return [render_result(result)]
+ parts = republic.Message.from_dict({"role": "user", "content": result["content"]}).parts
+ return [
+ part.text if isinstance(part, republic.Text) else part
+ for part in parts
+ if isinstance(part, republic.Text | republic.Image | republic.Audio | republic.Video)
+ ]
+
+
+def _to_snake_case(name: str) -> str:
+ return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
+
+
+def _callable_name(func: Callable[..., Any]) -> str:
+ name = getattr(func, "__name__", None)
+ if isinstance(name, str) and name:
+ return name
+ return func.__class__.__name__
+
+
+def _schema_from_annotation(annotation: Any) -> dict[str, Any]:
+ if annotation is inspect._empty:
+ annotation = Any
+ try:
+ return TypeAdapter(annotation).json_schema()
+ except Exception as exc:
+ raise ValueError(f"Failed to build JSON schema for type: {annotation!r}") from exc
+
+
+def _schema_from_signature(signature: inspect.Signature, *, ignore_params: set[str] | None = None) -> dict[str, Any]:
+ ignore = ignore_params or set()
+ properties: dict[str, Any] = {}
+ required: list[str] = []
+ for param in signature.parameters.values():
+ if param.name in ignore:
+ continue
+ if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
+ continue
+ property_schema = _schema_from_annotation(param.annotation)
+ if param.default is param.empty:
+ required.append(param.name)
+ elif _is_json_value(param.default):
+ property_schema = {**property_schema, "default": param.default}
+ properties[param.name] = property_schema
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
+ if required:
+ schema["required"] = required
+ return schema
+
+
+def _is_json_value(value: Any) -> bool:
+ try:
+ json.dumps(value)
+ except (TypeError, ValueError):
+ return False
+ return True
+
+
+def _output_schema(func: Callable[..., Any]) -> dict[str, Any] | None:
+ """Build a JSON schema for the callable's return annotation, if it can be resolved."""
+ try:
+ annotation = get_type_hints(func).get("return", inspect.Signature.empty)
+ except Exception:
+ return None
+ if annotation is inspect.Signature.empty:
+ return None
+ try:
+ return _schema_from_annotation(annotation)
+ except ValueError:
+ return None
+
+
+def _signature_without_context(signature: inspect.Signature) -> inspect.Signature:
+ parameters = [param for param in signature.parameters.values() if param.name != "context"]
+ return signature.replace(parameters=parameters)
+
+
+def _validate_without_context(func: Callable[..., Any], signature: inspect.Signature) -> Callable[..., Any]:
+ def validate_target(*args: Any, **kwargs: Any) -> tuple[tuple[Any, ...], dict[str, Any]]:
+ return args, kwargs
+
+ validate_target.__name__ = _callable_name(func)
+ validate_target.__qualname__ = getattr(func, "__qualname__", validate_target.__name__)
+ # Only arguments are validated; dropping the return annotation also avoids resolving
+ # forward references to types that live in the tool's module, not this one.
+ validate_target.__annotations__ = dict(getattr(func, "__annotations__", {}))
+ validate_target.__annotations__.pop("context", None)
+ validate_target.__annotations__.pop("return", None)
+ validate_target.__signature__ = _signature_without_context(signature).replace( # type: ignore[attr-defined]
+ return_annotation=inspect.Signature.empty
+ )
+ return validate_call(validate_target)
+
+
+ToolExposure = Literal["auto", "direct", "code", "command"]
+"""Where a tool can be called from.
+
+- ``auto``: by the model directly, or only from ``run_code`` in code mode.
+- ``direct``: by the model directly, also in code mode; never from ``run_code``.
+- ``code``: only from ``run_code`` in code mode; never by the model directly.
+- ``command``: only as a comma command; never by the model or from code.
+"""
+
+
+@dataclass(frozen=True)
+class Tool:
+ """A callable unit the model can invoke."""
+
+ name: str
+ handler: Callable[..., Any]
+ description: str = ""
+ parameters: dict[str, Any] = field(default_factory=dict)
+ context: bool = False
+ exposure: ToolExposure = "auto"
+ """Where the tool can be called from, see ``ToolExposure``."""
+ renderer: Callable[[Any], str] | None = None
+ output_schema: dict[str, Any] | None = None
+ """JSON schema of the structured result, used to describe the tool to model-written code."""
+ deferred: bool = False
+ """Load the tool on demand: only its name is listed until ``tool.describe`` loads its definition."""
+
+ def __post_init__(self) -> None:
+ if self.exposure not in get_args(ToolExposure):
+ raise ValueError(f"Tool '{self.name}' has unknown exposure {self.exposure!r}.")
+
+ async def run(self, *args: Any, **kwargs: Any) -> Any:
+ """Call the handler, awaiting its result when it is awaitable."""
+ result = self.handler(*args, **kwargs)
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+
+ def render(self, result: Any) -> str:
+ """Render a structured result of this tool as plain text for the model."""
+ if self.renderer is None:
+ return render_result(result)
+ return self.renderer(result)
+
+ def to_schema(self) -> dict[str, Any]:
+ """Build a function tool schema for hooks and diagnostics."""
+ return {"name": self.name, "description": self.description, "parameters": self.parameters}
+
+ @classmethod
+ def from_callable(
+ cls,
+ func: Callable[..., Any],
+ *,
+ name: str | None = None,
+ description: str | None = None,
+ context: bool = False,
+ exposure: ToolExposure = "auto",
+ renderer: Callable[[Any], str] | None = None,
+ deferred: bool = False,
+ ) -> Tool:
+ signature = inspect.signature(func)
+ if context and "context" not in signature.parameters:
+ raise TypeError("Tool context is enabled but the callable lacks a 'context' parameter.")
+ tool_name = name or _to_snake_case(_callable_name(func))
+ tool_description = description if description is not None else (inspect.getdoc(func) or "")
+ parameters = _schema_from_signature(signature, ignore_params={"context"} if context else None)
+ if context:
+ validate_args = _validate_without_context(func, signature)
+
+ def validated(*args: Any, **kwargs: Any) -> Any:
+ tool_context = kwargs.pop("context")
+ validated_args, validated_kwargs = validate_args(*args, **kwargs)
+ return func(*validated_args, context=tool_context, **validated_kwargs)
+
+ else:
+ validated = validate_call(config=ConfigDict(arbitrary_types_allowed=True))(func)
+ return cls(
+ name=tool_name,
+ description=tool_description,
+ parameters=parameters,
+ handler=validated,
+ context=context,
+ exposure=exposure,
+ renderer=renderer,
+ output_schema=_output_schema(func),
+ deferred=deferred,
+ )
+
+
+def model_tools(tools: Iterable[Tool]) -> list[Tool]:
+ """Convert tools callable by the model or code into model-safe aliases; comma commands are dropped."""
+ return [
+ replace(tool_item, name=tool_item.name.replace(".", "_"))
+ for tool_item in tools
+ if tool_item.exposure != "command"
+ ]
+
+
+@dataclass(frozen=True)
+class ToolExecution:
+ tool_results: list[Any] = field(default_factory=list)
+ error: BubError | None = None
+
+
+@dataclass(frozen=True)
+class _FailedToolResult:
+ error: BubError
+ result: Any = None
+
+
+class ToolExecutor:
+ """Execute already-resolved Bub tool invocations.
+
+ With ``render`` (the default) successful results are rendered to model-facing text with
+ ``Tool.render``; ``render=False`` returns tools' structured results unchanged, as needed
+ when the caller is code rather than the model. ``ToolCallStarted`` and ``ToolCallFinished``
+ events are emitted on ``events``. ``max_concurrency`` limits how many of this executor's
+ calls run at once; None leaves them unlimited.
+ """
+
+ def __init__(
+ self, events: EventBus | None = None, *, render: bool = True, max_concurrency: int | None = None
+ ) -> None:
+ if max_concurrency is not None and max_concurrency < 1:
+ raise ValueError("max_concurrency must be a positive integer or None")
+ self._events = events
+ self._render = render
+ self._limiter: AbstractAsyncContextManager[Any] = (
+ nullcontext() if max_concurrency is None else asyncio.Semaphore(max_concurrency)
+ )
+
+ async def execute(
+ self,
+ tool: Tool,
+ arguments: dict[str, Any],
+ *,
+ context: ToolContext | None = None,
+ call_id: str | None = None,
+ ) -> Any:
+ """Execute one tool call and return its result; raise its ``BubError`` when it fails."""
+ result = await self._trace_tool_response(tool, arguments, context, call_id)
+ if isinstance(result, _FailedToolResult):
+ raise result.error
+ return result
+
+ async def execute_many(
+ self,
+ invocations: Sequence[tuple[Tool, dict[str, Any]]],
+ *,
+ context: ToolContext | None = None,
+ call_ids: Sequence[str] | None = None,
+ ) -> ToolExecution:
+ if not invocations:
+ return ToolExecution(tool_results=[])
+
+ results: list[Any] = []
+ error: BubError | None = None
+ gathered = await asyncio.gather(
+ *(
+ self._trace_tool_response(tool_obj, tool_args, context, call_ids[index] if call_ids else None)
+ for index, (tool_obj, tool_args) in enumerate(invocations)
+ ),
+ return_exceptions=True,
+ )
+ for result in gathered:
+ if isinstance(result, _FailedToolResult):
+ error = result.error
+ results.append(result.error.as_dict() if result.result is None else result.result)
+ elif isinstance(result, BubError):
+ error = result
+ results.append(result.as_dict())
+ elif isinstance(result, BaseException):
+ raise result
+ else:
+ results.append(result)
+
+ return ToolExecution(tool_results=results, error=error)
+
+ async def _trace_tool_response(
+ self,
+ tool: Tool,
+ arguments: dict[str, Any],
+ context: ToolContext | None,
+ call_id: str | None,
+ ) -> Any:
+ async with self._limiter:
+ span = Span(
+ f"execute_tool {tool.name}",
+ {
+ "gen_ai.operation.name": "execute_tool",
+ "gen_ai.tool.name": tool.name,
+ "gen_ai.tool.type": "function",
+ "gen_ai.tool.call.id": call_id,
+ "bub.run_id": context.run_id if context else None,
+ "gen_ai.conversation.id": context.state.get("session_id") if context else None,
+ },
+ )
+ try:
+ with span.activate():
+ result = await self._handle_tool_response_async(tool, arguments, context, span=span)
+ if isinstance(result, _FailedToolResult):
+ span.fail(result.error)
+ span.set(**{
+ "gen_ai.tool.call.result": result.error.as_dict()
+ if result.result is None
+ else result.result
+ })
+ else:
+ span.set(**{"gen_ai.tool.call.result": result})
+ return result
+ except BaseException as exc:
+ span.fail(exc)
+ raise
+ finally:
+ span.end()
+
+ def _invoke_tool(
+ self,
+ *,
+ tool_name: str,
+ tool_obj: Tool,
+ tool_args: dict[str, Any],
+ context: ToolContext | None,
+ ) -> Any:
+ if tool_obj.context:
+ if context is None:
+ raise BubError(ErrorKind.INVALID_INPUT, f"Tool '{tool_name}' requires context but none was provided.")
+ return tool_obj.run(context=context, **tool_args)
+ return tool_obj.run(**tool_args)
+
+ async def _handle_tool_response_async(
+ self,
+ tool_obj: Tool,
+ tool_args: dict[str, Any],
+ context: ToolContext | None,
+ *,
+ span: Span | None = None,
+ ) -> Any:
+ tool_name = tool_obj.name
+ call = ToolCall(
+ run_id=(context.run_id if context is not None else None) or "",
+ tool=tool_name,
+ arguments=dict(tool_args),
+ context=context,
+ )
+ hook_state = context.state if context is not None else {}
+ if self._events is not None and context is not None:
+ hook_state["_runtime_tape"] = context.tape
+ started = time.monotonic()
+ call, short_circuit = await self._apply_before_tool_call(call, hook_state, started)
+ if span is not None:
+ span.set(**{"gen_ai.tool.call.arguments": call.arguments})
+ if short_circuit is not None:
+ return short_circuit()
+
+ _log_tool_call(call)
+ invoked = time.monotonic()
+ try:
+ result = await self._invoke_normalized(tool_obj, call, context)
+ except BubError as exc:
+ elapsed_ms = (time.monotonic() - invoked) * 1000
+ logger.opt(exception=exc).error("tool.call.error name={} elapsed_time={:.2f}ms", tool_name, elapsed_ms)
+ outcome = await self._fire_after_tool_call(call, hook_state, started, error=exc)
+ return _FailedToolResult(error=exc, result=outcome.result)
+ else:
+ elapsed_ms = (time.monotonic() - invoked) * 1000
+ logger.info("tool.call.success name={} elapsed_time={:.2f}ms", tool_name, elapsed_ms)
+ outcome = await self._fire_after_tool_call(call, hook_state, started, result=result)
+ return outcome.result
+
+ async def _invoke_normalized(self, tool_obj: Tool, call: ToolCall, context: ToolContext | None) -> Any:
+ """Run the tool with errors normalized to BubError; render the result if configured to."""
+
+ tool_name = tool_obj.name
+ try:
+ value = self._invoke_tool(
+ tool_name=tool_name,
+ tool_obj=tool_obj,
+ tool_args=call.arguments,
+ context=context,
+ )
+ if inspect.isawaitable(value):
+ value = await value
+ # Content results already carry model-facing text and media.
+ if self._render and not is_content_result(value):
+ value = tool_obj.render(value)
+ except BubError:
+ raise
+ except ValidationError as exc:
+ raise BubError(
+ ErrorKind.INVALID_INPUT,
+ f"Tool '{tool_name}' argument validation failed.",
+ details={"errors": json.loads(exc.json())},
+ ) from exc
+ except Exception as exc:
+ raise BubError(
+ ErrorKind.TOOL,
+ f"Tool '{tool_name}' execution failed.",
+ details={"error": repr(exc)},
+ ) from exc
+ return value
+
+ async def _apply_before_tool_call(
+ self,
+ call: ToolCall,
+ hook_state: dict[str, Any],
+ started: float,
+ ) -> tuple[ToolCall, Callable[[], Any] | None]:
+ """Emit ToolCallStarted and translate deny/replace into a short-circuit thunk."""
+
+ if self._events is None:
+ return call, None
+ before = await self._events.emit(ToolCallStarted(state=hook_state, call=call))
+ call, decision = before.call, before.decision
+ if decision.action == "deny":
+ error = BubError(
+ ErrorKind.TOOL,
+ decision.message or f"Tool '{call.tool}' call denied by policy hook.",
+ )
+ outcome = await self._fire_after_tool_call(call, hook_state, started, error=error)
+ return call, lambda: _FailedToolResult(error=error, result=outcome.result)
+ if decision.action == "replace":
+ outcome = await self._fire_after_tool_call(call, hook_state, started, result=decision.result)
+ return call, lambda: outcome.result
+ return call, None
+
+ async def _fire_after_tool_call(
+ self,
+ call: ToolCall,
+ state: dict[str, Any],
+ started: float,
+ *,
+ result: Any = None,
+ error: Exception | None = None,
+ ) -> ToolCallResult:
+ duration_ms = int((time.monotonic() - started) * 1000)
+ outcome = ToolCallResult(
+ run_id=call.run_id,
+ tool=call.tool,
+ arguments=call.arguments,
+ result=None if error is not None else result,
+ error=error,
+ duration_ms=duration_ms,
+ )
+ if self._events is not None:
+ await self._events.emit(ToolCallFinished(state=state, call=call, result=outcome))
+ return outcome
+
+
+# Central registry for tools. Tools defined with the @tool decorator are automatically added here.
+REGISTRY: dict[str, Tool] = {}
+
+
+def _shorten_text(text: str, width: int = 30, placeholder: str = "...") -> str:
+ if len(text) <= width:
+ return text
+
+ # Reserve space for placeholder
+ available = width - len(placeholder)
+ if available <= 0:
+ return placeholder
+
+ return text[:available] + placeholder
+
+
+def _render_value(value: Any) -> str:
+ try:
+ rendered = json.dumps(value, ensure_ascii=False)
+ except TypeError:
+ rendered = repr(value)
+ rendered = _shorten_text(rendered, width=100, placeholder="...")
+ if rendered.startswith('"') and not rendered.endswith('"'):
+ rendered = rendered + '"'
+ if rendered.startswith("{") and not rendered.endswith("}"):
+ rendered = rendered + "}"
+ if rendered.startswith("[") and not rendered.endswith("]"):
+ rendered = rendered + "]"
+ return rendered
+
+
+def _log_tool_call(call: ToolCall) -> None:
+ params = [f"{key}={_render_value(value)}" for key, value in call.arguments.items()]
+ params_str = f" {{ {', '.join(params)} }}" if params else ""
+ logger.info("tool.call.start name={}{}", call.tool, params_str)
+
+
+@overload
+def tool(
+ func: Callable,
+ *,
+ name: str | None = ...,
+ model: type[BaseModel] | None = ...,
+ description: str | None = ...,
+ context: bool = ...,
+ exposure: ToolExposure = ...,
+ renderer: Callable[[Any], str] | None = ...,
+ deferred: bool = ...,
+) -> Tool: ...
+
+
+@overload
+def tool(
+ func: None = ...,
+ *,
+ name: str | None = ...,
+ model: type[BaseModel] | None = ...,
+ description: str | None = ...,
+ context: bool = ...,
+ exposure: ToolExposure = ...,
+ renderer: Callable[[Any], str] | None = ...,
+ deferred: bool = ...,
+) -> Callable[[Callable], Tool]: ...
+
+
+def tool(
+ func: Callable | None = None,
+ *,
+ name: str | None = None,
+ model: type[BaseModel] | None = None,
+ description: str | None = None,
+ context: bool = False,
+ exposure: ToolExposure = "auto",
+ renderer: Callable[[Any], str] | None = None,
+ deferred: bool = False,
+) -> Tool | Callable[[Callable], Tool]:
+ """Decorator to convert a function into a Tool instance.
+
+ Tools should return structured results; ``renderer`` turns such a result into the
+ plain text shown to the model outside code mode (defaults to JSON for non-strings).
+ ``exposure`` decides where the tool can be called from (see ``ToolExposure``), and
+ ``deferred`` tools are loaded on demand through ``tool.describe``.
+ """
+
+ def decorator(func: Callable) -> Tool:
+ if model is not None:
+ if context and "context" not in inspect.signature(func).parameters:
+ raise TypeError("Tool context is enabled but the handler lacks a 'context' parameter.")
+
+ def handler(*args: Any, **kwargs: Any) -> Any:
+ tool_context = kwargs.pop("context", None)
+ parsed = model(*args, **kwargs)
+ if context:
+ return func(parsed, context=tool_context)
+ return func(parsed)
+
+ result = Tool(
+ name=name or _to_snake_case(model.__name__),
+ description=description if description is not None else (inspect.getdoc(func) or model.__doc__ or ""),
+ parameters=model.model_json_schema(),
+ handler=handler,
+ context=context,
+ exposure=exposure,
+ renderer=renderer,
+ output_schema=_output_schema(func),
+ deferred=deferred,
+ )
+ else:
+ result = Tool.from_callable(
+ func,
+ name=name,
+ description=description,
+ context=context,
+ exposure=exposure,
+ renderer=renderer,
+ deferred=deferred,
+ )
+ REGISTRY[result.name] = result
+ return result
+
+ if func is None:
+ return decorator
+ return decorator(func)
diff --git a/src/bub/environment.py b/src/bub/environment.py
index b2e67298..242d5b3e 100644
--- a/src/bub/environment.py
+++ b/src/bub/environment.py
@@ -5,15 +5,20 @@
``bash``, ``fs.*`` and ``run_code`` run on top of it. Tool semantics (background shells, timeouts,
rendering, hooks) stay on the host; only execution moves into the environment.
Plugins provide one per session through the ``provide_environment`` hook; Bub's
-builtin hooks provide ``bub.builtin.environment.LocalEnvironment``, which runs
-everything on the host.
+builtin hooks provide :class:`LocalEnvironment`, which runs everything on the host.
"""
from __future__ import annotations
import abc
import asyncio
+import contextlib
+import os
+import shutil
+import signal
+import sys
from collections.abc import Awaitable, Callable, Mapping, Sequence
+from pathlib import Path
from typing import Any
ENVIRONMENT_STATE_KEY = "_runtime_environment"
@@ -104,6 +109,19 @@ async def spawn(
async def read_text(self, path: str) -> str:
"""Read a UTF-8 text file."""
+ async def read_bytes(self, path: str) -> bytes:
+ """Read a file as bytes.
+
+ The default runs ``python`` in the environment to print the file; override it with direct access.
+ """
+ script = "import sys; sys.stdout.buffer.write(open(sys.argv[1], 'rb').read())"
+ process = await self.spawn([self.python, "-c", script, path])
+ process.close_stdin()
+ data, error = await asyncio.gather(process.stdout.read(), process.stderr.read())
+ if await process.wait() != 0:
+ raise OSError(error.decode("utf-8", errors="replace").strip() or f"cannot read {path}")
+ return data
+
@abc.abstractmethod
async def write_text(self, path: str, content: str) -> None:
"""Write a UTF-8 text file, creating missing parent directories."""
@@ -126,15 +144,133 @@ async def run_code(
The code may use top-level ``await``. For each name in ``tools``, ``tools.(**kwargs)``
must be an async function that returns ``await call_tool(name, kwargs)``; errors from
- ``call_tool`` surface in the code as exceptions. Pass everything the code writes to stdout
+ ``call_tool`` surface in the code as exceptions. The code may also call ``call_tool`` with names
+ prefixed ``republic.``; ``bub.core.codemode.runner_child`` shows the ``republic`` module
+ that makes those calls. Pass everything the code writes to stdout
to ``write`` as it happens, so output survives a timeout. Raise :class:`CodeFailed` when the
code raises; any other exception reports that the runtime itself failed. When cancelled
(for example on timeout), stop the code before returning.
How the code runs is up to the environment: a Python process, an embedded interpreter or a
- remote code interpreter. ``bub.builtin.codemode.code_runner.run_code_in_subprocess`` implements it
+ remote code interpreter. ``bub.core.codemode.runner.run_code_in_subprocess`` implements it
on top of :meth:`spawn` for environments that have a Python interpreter.
"""
async def close(self) -> None: # noqa: B027
"""Release the environment's resources. Called when the framework stops."""
+
+
+class LocalProcess(Process):
+ """A host process in its own session, so signals reach the whole process group."""
+
+ def __init__(self, process: asyncio.subprocess.Process) -> None:
+ self.process = process
+
+ @property
+ def pid(self) -> int:
+ return self.process.pid
+
+ @property
+ def stdout(self) -> asyncio.StreamReader:
+ assert self.process.stdout is not None # noqa: S101
+ return self.process.stdout
+
+ @property
+ def stderr(self) -> asyncio.StreamReader:
+ assert self.process.stderr is not None # noqa: S101
+ return self.process.stderr
+
+ @property
+ def returncode(self) -> int | None:
+ return self.process.returncode
+
+ async def wait(self) -> int:
+ return await self.process.wait()
+
+ async def write_stdin(self, data: bytes) -> None:
+ assert self.process.stdin is not None # noqa: S101
+ self.process.stdin.write(data)
+ await self.process.stdin.drain()
+
+ def close_stdin(self) -> None:
+ if self.process.stdin is not None:
+ self.process.stdin.close()
+
+ def signal(self, *, kill: bool) -> None:
+ with contextlib.suppress(ProcessLookupError):
+ if os.name != "nt":
+ os.killpg(self.process.pid, signal.SIGKILL if kill else signal.SIGTERM)
+ elif self.returncode is None:
+ if kill:
+ self.process.kill()
+ else:
+ self.process.terminate()
+
+ def is_running(self) -> bool:
+ if os.name == "nt":
+ return self.returncode is None
+ try:
+ os.killpg(self.process.pid, 0)
+ except ProcessLookupError:
+ return False
+ except PermissionError:
+ # EPERM does not establish that the group is gone (in particular
+ # while its leader is exiting on macOS).
+ return True
+ return True
+
+
+class LocalEnvironment(Environment):
+ """Run processes and access files directly on the host."""
+
+ SHELL = shutil.which("bash") or shutil.which("sh") if os.name != "nt" else None
+
+ def __init__(self, workspace: str | Path | None = None) -> None:
+ self.workspace = str(workspace) if workspace is not None else None
+ self.python = sys.executable
+
+ async def spawn(
+ self, command: str | Sequence[str], *, cwd: str | None = None, env: Mapping[str, str] | None = None
+ ) -> LocalProcess:
+ options: dict[str, Any] = {
+ "cwd": cwd or self.workspace,
+ "env": {**os.environ, **env} if env else None,
+ "stdin": asyncio.subprocess.PIPE,
+ "stdout": asyncio.subprocess.PIPE,
+ "stderr": asyncio.subprocess.PIPE,
+ "start_new_session": os.name != "nt",
+ }
+ if isinstance(command, str):
+ process = await asyncio.create_subprocess_shell(command, executable=self.SHELL, **options)
+ else:
+ process = await asyncio.create_subprocess_exec(*command, **options)
+ return LocalProcess(process)
+
+ async def read_text(self, path: str) -> str:
+ return await asyncio.to_thread(Path(path).read_text, encoding="utf-8")
+
+ async def read_bytes(self, path: str) -> bytes:
+ return await asyncio.to_thread(Path(path).read_bytes)
+
+ async def write_text(self, path: str, content: str) -> None:
+ def write() -> None:
+ target = Path(path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(content, encoding="utf-8")
+
+ await asyncio.to_thread(write)
+
+ async def run_code(
+ self, code: str, *, tools: Sequence[str], call_tool: CallTool, write: Callable[[str], None]
+ ) -> None:
+ from bub.core.codemode.runner import run_code_in_subprocess
+
+ await run_code_in_subprocess(self, code, tools=tools, call_tool=call_tool, write=write)
+
+ def resolve_path(self, path: str) -> str:
+ expanded = Path(path).expanduser()
+ if expanded.is_absolute():
+ return str(expanded)
+ if self.workspace is None:
+ raise ValueError(f"relative path '{path}' is not allowed without a workspace")
+ return str((Path(self.workspace) / expanded).resolve())
diff --git a/src/bub/framework.py b/src/bub/framework.py
index a8761138..3b0067dc 100644
--- a/src/bub/framework.py
+++ b/src/bub/framework.py
@@ -4,38 +4,39 @@
import asyncio
import contextlib
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Iterator
from dataclasses import dataclass
from pathlib import Path
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any
import pluggy
import typer
-from dotenv import find_dotenv, load_dotenv
from loguru import logger
from bub import configure
+from bub.application import CommandChanged, Outcome
from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot
from bub.channels.contracts import ChannelRouter, MessageHandler
+from bub.configure import DEFAULT_HOME
+from bub.core.events import Event, EventBus
+from bub.core.sidecars import TapeSidecar
+from bub.core.store import AsyncTapeStore, TapeStore
+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.errors import BubError, ErrorKind
-from bub.hooks.interception import AgentHooks
+from bub.hooks.interception import PluginAgentHooks
from bub.hooks.runtime import _SKIP_VALUE, HookRuntime
from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs
from bub.model_selection import ModelOptions
-from bub.sidecars import TapeSidecar
-from bub.store import AsyncTapeStore, TapeStore
+from bub.prompt import UserContent, to_content, to_legacy_prompt
from bub.streaming import StreamState
-from bub.tape import Tape, TapeContext
from bub.turn import TurnResult, TurnState
from bub.utils import maybe_context_manager
if TYPE_CHECKING:
from bub.channels.base import Channel
-load_dotenv(find_dotenv(usecwd=True))
-DEFAULT_HOME = Path.home() / ".bub"
DEFAULT_CONFIG_FILE = (DEFAULT_HOME / "config.yml").resolve()
@@ -59,7 +60,8 @@ def __init__(self, config_file: Path = DEFAULT_CONFIG_FILE) -> None:
self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE)
self._plugin_manager.add_hookspecs(BubHookSpecs)
self._hook_runtime = HookRuntime(self._plugin_manager)
- self._agent_hooks = AgentHooks(self._hook_runtime)
+ self._agent_hooks = PluginAgentHooks(self._hook_runtime)
+ self.events = EventBus()
self._plugin_status: dict[str, PluginStatus] = {}
self._channel_router: ChannelRouter | None = None
self._tape_store: TapeStore | AsyncTapeStore | None = None
@@ -117,8 +119,29 @@ def load_hooks(self) -> None:
def create_cli_app(self) -> typer.Typer:
"""Create CLI app by collecting commands from hooks. Can be used for custom CLI entry point."""
+ import sys
+
+ from bub.program_status import ProgramStatus
+
app = typer.Typer(name="bub", help="A tiny agent runtime, composable with plugins", add_completion=False)
+ @contextlib.contextmanager
+ def command_events() -> Iterator[None]:
+ outcome: Outcome = "failed"
+ asyncio.run(self.events.emit(CommandChanged()))
+ try:
+ yield
+ except (asyncio.CancelledError, KeyboardInterrupt, EOFError, typer.Abort):
+ outcome = "cancelled"
+ raise
+ except typer.Exit as exc:
+ outcome = "succeeded" if exc.exit_code == 0 else "failed"
+ raise
+ else:
+ outcome = "succeeded"
+ finally:
+ asyncio.run(self.events.emit(CommandChanged(outcome)))
+
@app.callback(invoke_without_command=True)
def _main(
ctx: typer.Context,
@@ -127,24 +150,27 @@ def _main(
if workspace:
self.workspace = Path(workspace).resolve()
ctx.obj = self
+ reporter = ProgramStatus(sys.stdout)
+ self.events.on(Event, reporter)
+ ctx.call_on_close(lambda: self.events.off(Event, reporter))
+ ctx.with_resource(command_events())
self._hook_runtime.call_many_sync("register_cli_commands", app=app)
return app
- async def build_prompt(
- self, message: Envelope, session_id: str, state: dict[str, Any]
- ) -> str | list[dict[str, Any]]:
- """Build prompt for one message turn."""
+ async def build_prompt(self, message: Envelope, session_id: str, state: dict[str, Any]) -> list[UserContent]:
+ """Build prompt content for one message turn, converting legacy hook results."""
prompt = await self._hook_runtime.call_first(
"build_prompt", message=message, session_id=session_id, state=state
)
if not prompt:
prompt = content_of(message)
- return cast("str | list[dict[str, Any]]", prompt)
+ return to_content(prompt)
- async def continue_prompt(self, prompt: str | list[dict] | None, tape: Tape, state: StreamState) -> str | None:
+ async def continue_prompt(self, prompt: list[UserContent] | None, tape: Tape, state: StreamState) -> str | None:
"""Return an optional user message for the next step of an agent loop."""
- next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=prompt, tape=tape, state=state)
+ legacy = None if prompt is None else to_legacy_prompt(prompt)
+ next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=legacy, tape=tape, state=state)
if next_prompt is None or isinstance(next_prompt, str):
return next_prompt
raise TypeError("hook.continue_prompt must return str or None")
@@ -219,7 +245,7 @@ async def resolve_session(self, message: Envelope) -> str:
async def _run_model(
self,
inbound: Envelope,
- prompt: str | list[dict],
+ prompt: list[UserContent],
session_id: str,
state: dict[str, Any],
stream_output: bool,
@@ -232,7 +258,7 @@ async def _run_model(
error=RuntimeError("no model skill returned output"),
message=inbound,
)
- return prompt if isinstance(prompt, str) else content_of(inbound)
+ return text if isinstance(text := to_legacy_prompt(prompt), str) else content_of(inbound)
return output
stream = await self._hook_runtime.run_model_stream(prompt=prompt, session_id=session_id, state=state)
if stream is None:
@@ -241,7 +267,7 @@ async def _run_model(
error=RuntimeError("no model skill returned output"),
message=inbound,
)
- return prompt if isinstance(prompt, str) else content_of(inbound)
+ return text if isinstance(text := to_legacy_prompt(prompt), str) else content_of(inbound)
else:
parts: list[str] = []
events = self._channel_router.wrap_stream(inbound, stream) if self._channel_router is not None else stream
@@ -471,11 +497,11 @@ def get_steering_inbox(self) -> SteeringInbox | None:
"""Return the inbox acquired by ``running()``, or None when unavailable."""
return self._steering_inbox
- def get_agent_hooks(self) -> AgentHooks:
+ def get_agent_hooks(self) -> PluginAgentHooks:
"""Return the model and tool interception adapter for this framework's hooks."""
return self._agent_hooks
- def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) -> str:
+ def get_system_prompt(self, prompt: list[UserContent], state: dict[str, Any]) -> str:
"""Join nonempty system-prompt hook results from low to high priority.
Hooks contribute additional blocks; a higher-priority hook does not replace
@@ -483,7 +509,9 @@ def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) ->
"""
return "\n\n".join(
result
- for result in reversed(self._hook_runtime.call_many_sync("system_prompt", prompt=prompt, state=state))
+ for result in reversed(
+ self._hook_runtime.call_many_sync("system_prompt", prompt=to_legacy_prompt(prompt), state=state)
+ )
if result
)
diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py
index 931b321a..57588a36 100644
--- a/src/bub/hooks/interception.py
+++ b/src/bub/hooks/interception.py
@@ -1,122 +1,49 @@
-"""Contracts and execution semantics for agent-loop interception hooks."""
+"""Pluggy adapter turning agent-loop events into interception hook calls."""
from __future__ import annotations
-from dataclasses import dataclass, field, replace
-from typing import TYPE_CHECKING, Any, Literal
+from dataclasses import replace
+from typing import Any
from loguru import logger
+from bub.core.events import (
+ EventBus,
+ LLMCallDecision,
+ LLMCallFinished,
+ LLMCallRequest,
+ LLMCallResult,
+ LLMCallStarted,
+ ToolCall,
+ ToolCallDecision,
+ ToolCallFinished,
+ ToolCallResult,
+ ToolCallStarted,
+)
from bub.hooks.runtime import _SKIP_VALUE, HookRuntime
from bub.turn import TurnState
-if TYPE_CHECKING:
- from bub.tools import ToolContext
+__all__ = [
+ "LLMCallDecision",
+ "LLMCallRequest",
+ "LLMCallResult",
+ "LlmCallDecision",
+ "LlmCallRequest",
+ "LlmCallResult",
+ "PluginAgentHooks",
+ "ToolCall",
+ "ToolCallDecision",
+ "ToolCallResult",
+]
+# Compatibility aliases for the names used before 0.6.0a2.
+LlmCallDecision = LLMCallDecision
+LlmCallRequest = LLMCallRequest
+LlmCallResult = LLMCallResult
-@dataclass(frozen=True)
-class LlmCallRequest:
- """Outgoing agent-loop LLM request exposed to interception hooks.
-
- Hooks may return a modified copy (``dataclasses.replace``) to change the
- model or messages for this call. Tool objects are not exposed;
- ``tool_names`` is observational and altering the toolset is out of scope.
- """
-
- run_id: str
- model: str
- messages: list[dict[str, Any]]
- tool_names: tuple[str, ...] = ()
- max_tokens: int | None = None
-
-
-@dataclass(frozen=True)
-class LlmCallResult:
- """Terminal outcome of one LLM call exposed to interception hooks.
-
- For streaming completions this is the fully accumulated final state, not
- a per-chunk view. ``error`` is the original raised exception (and other
- fields are best effort) when the call failed. Cancellation and consumer
- close are not observed.
- """
-
- run_id: str
- text: str | None = None
- tool_calls: list[dict[str, Any]] = field(default_factory=list)
- usage: dict[str, Any] | None = None
- error: Exception | None = None
- duration_ms: int = 0
-
-
-@dataclass(frozen=True)
-class LlmCallDecision:
- """Short-circuit verdict returned by a ``before_llm_call`` hook."""
-
- action: Literal["finish"] = "finish"
- text: str = ""
-
- @classmethod
- def finish(cls, text: str) -> LlmCallDecision:
- """Skip the provider call and emit ``text`` as its final output."""
-
- return cls(action="finish", text=text)
-
-
-@dataclass(frozen=True)
-class ToolCall:
- """One tool invocation exposed to interception hooks.
-
- ``context`` is the ``ToolContext`` the call runs with, when one is available; for
- example, ``context.code_mode`` marks calls made from model-written code (``run_code``).
- """
-
- run_id: str
- tool: str
- arguments: dict[str, Any]
- context: ToolContext | None = field(default=None, repr=False, compare=False)
-
-@dataclass(frozen=True)
-class ToolCallDecision:
- """Verdict returned by a ``before_tool_call`` hook.
-
- ``proceed`` continues with optional argument changes. ``replace`` skips
- the handler and uses the supplied result. ``deny`` skips the handler and
- surfaces the supplied message as a tool error.
- """
-
- action: Literal["proceed", "replace", "deny"] = "proceed"
- arguments: dict[str, Any] | None = None
- result: Any = None
- message: str | None = None
-
- @classmethod
- def proceed(cls, arguments: dict[str, Any] | None = None) -> ToolCallDecision:
- return cls(action="proceed", arguments=arguments)
-
- @classmethod
- def replace(cls, result: Any) -> ToolCallDecision:
- return cls(action="replace", result=result)
-
- @classmethod
- def deny(cls, message: str) -> ToolCallDecision:
- return cls(action="deny", message=message)
-
-
-@dataclass
-class ToolCallResult:
- """Terminal outcome exposed to hooks; model-facing results may be replaced in place."""
-
- run_id: str
- tool: str
- arguments: dict[str, Any]
- result: Any = None
- error: Exception | None = None
- duration_ms: int = 0
-
-
-class AgentHooks:
- """Fault-isolated executor for agent-loop interception hooks.
+class PluginAgentHooks:
+ """Fault-isolated adapter running the framework's pluggy interception hooks for agent events.
Blocking a tool call is only possible through a returned
:class:`ToolCallDecision`; an exception inside ``before_tool_call`` is
@@ -126,29 +53,50 @@ class AgentHooks:
def __init__(self, runtime: HookRuntime) -> None:
self._runtime = runtime
+ def subscribe(self, events: EventBus | None = None) -> EventBus:
+ """Handle an agent's ``Before/After*Call`` events with the plugin hooks; return the bus."""
+ events = EventBus() if events is None else events
+ events.on(LLMCallStarted, self._on_llm_call_started)
+ events.on(LLMCallFinished, self._on_llm_call_finished)
+ events.on(ToolCallStarted, self._on_tool_call_started)
+ events.on(ToolCallFinished, self._on_tool_call_finished)
+ return events
+
+ async def _on_llm_call_started(self, event: LLMCallStarted) -> None:
+ event.request, event.decision = await self.before_llm_call(event.request, event.state)
+
+ async def _on_llm_call_finished(self, event: LLMCallFinished) -> None:
+ await self.after_llm_call(event.request, event.result, event.state)
+
+ async def _on_tool_call_started(self, event: ToolCallStarted) -> None:
+ event.call, event.decision = await self.before_tool_call(event.call, event.state)
+
+ async def _on_tool_call_finished(self, event: ToolCallFinished) -> None:
+ await self.after_tool_call(event.call, event.result, event.state)
+
async def before_llm_call(
- self, request: LlmCallRequest, state: TurnState
- ) -> tuple[LlmCallRequest, LlmCallDecision | None]:
+ self, request: LLMCallRequest, state: TurnState
+ ) -> tuple[LLMCallRequest, LLMCallDecision | None]:
"""Chain hooks so each implementation sees the previous request."""
for impl in self._runtime._iter_hookimpls("before_llm_call"):
value = await self._safe_call_one("before_llm_call", impl, {"request": request, "state": state})
if value is None or value is _SKIP_VALUE:
continue
- if isinstance(value, LlmCallRequest):
+ if isinstance(value, LLMCallRequest):
request = value
- elif isinstance(value, LlmCallDecision):
+ elif isinstance(value, LLMCallDecision):
return request, value
else:
self._warn_bad_return(
- "before_llm_call", impl, value, expected="LlmCallRequest | LlmCallDecision | None"
+ "before_llm_call", impl, value, expected="LLMCallRequest | LLMCallDecision | None"
)
return request, None
async def after_llm_call(
self,
- request: LlmCallRequest,
- result: LlmCallResult,
+ request: LLMCallRequest,
+ result: LLMCallResult,
state: TurnState,
) -> None:
"""Notify every observer; return values are ignored."""
diff --git a/src/bub/hooks/runtime.py b/src/bub/hooks/runtime.py
index 2b0bf112..9a353d74 100644
--- a/src/bub/hooks/runtime.py
+++ b/src/bub/hooks/runtime.py
@@ -11,6 +11,7 @@
from loguru import logger
from bub.envelope import Envelope
+from bub.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt
from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
from bub.turn import TurnState
@@ -162,8 +163,11 @@ def _iter_hookimpls(self, hook_name: str) -> list[Any]:
def _kwargs_for_impl(impl: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
return {name: kwargs[name] for name in impl.argnames if name in kwargs}
- async def run_model(self, prompt: str | list[dict], session_id: str, state: TurnState) -> str | None:
- """Run the first model hook found and return its text."""
+ async def run_model(
+ self, prompt: list[UserContent] | LegacyPrompt, session_id: str, state: TurnState
+ ) -> str | None:
+ """Run the first model hook found and return its text; hooks receive the legacy prompt form."""
+ prompt = to_legacy_prompt(to_content(prompt))
for _, plugin in reversed(self._plugin_manager.list_name_plugin()):
if hasattr(plugin, "run_model"):
@@ -182,9 +186,10 @@ async def run_model(self, prompt: str | list[dict], session_id: str, state: Turn
return None
async def run_model_stream(
- self, prompt: str | list[dict], session_id: str, state: TurnState
+ self, prompt: list[UserContent] | LegacyPrompt, session_id: str, state: TurnState
) -> AsyncStreamEvents | None:
- """Run the first streaming model hook, falling back to a text hook."""
+ """Run the first streaming model hook, falling back to a text hook; hooks receive the legacy prompt form."""
+ prompt = to_legacy_prompt(to_content(prompt))
for _, plugin in reversed(self._plugin_manager.list_name_plugin()):
if hasattr(plugin, "run_model_stream"):
diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py
index 73a0bab6..7bcd7c06 100644
--- a/src/bub/hooks/specs.py
+++ b/src/bub/hooks/specs.py
@@ -10,21 +10,22 @@
from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot
from bub.channels.contracts import MessageHandler
-from bub.envelope import Envelope
-from bub.environment import Environment
-from bub.hooks.interception import (
- LlmCallDecision,
- LlmCallRequest,
- LlmCallResult,
+from bub.core.events import (
+ LLMCallDecision,
+ LLMCallRequest,
+ LLMCallResult,
ToolCall,
ToolCallDecision,
ToolCallResult,
)
+from bub.core.sidecars import TapeSidecar
+from bub.core.store import AsyncTapeStore, TapeStore
+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.sidecars import TapeSidecar
-from bub.store import AsyncTapeStore, TapeStore
+from bub.prompt import LegacyPrompt, UserContent
from bub.streaming import AsyncStreamEvents, StreamState
-from bub.tape import Tape, TapeContext
from bub.turn import TurnState
if TYPE_CHECKING:
@@ -36,7 +37,14 @@
class BubHookSpecs:
- """Hook contract for Bub framework extensions."""
+ """Hook contract for Bub framework extensions.
+
+ ``build_prompt`` returns user content: text, ``republic.Image``,
+ ``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.prompt.to_legacy_prompt``).
+ """
@hookspec(firstresult=True)
def resolve_session(self, message: Envelope) -> str:
@@ -44,11 +52,11 @@ def resolve_session(self, message: Envelope) -> str:
raise NotImplementedError
@hookspec(firstresult=True)
- def build_prompt(self, message: Envelope, session_id: str, state: TurnState) -> str | list[dict]:
- """Build model prompt for this turn.
+ def build_prompt(self, message: Envelope, session_id: str, state: TurnState) -> list[UserContent] | LegacyPrompt:
+ """Build model prompt content for this turn.
- Returns either a plain text string or a list of content parts
- (OpenAI multimodal format) when media attachments are present.
+ Return a list of text and Republic media such as ``republic.image(...)``.
+ Legacy text and content-block lists are still accepted and converted.
"""
raise NotImplementedError
@@ -131,7 +139,7 @@ def provide_environment(self, session_id: str, workspace: Path) -> Environment |
Called once per session and cached until ``framework.running()`` exits,
which closes it with ``Environment.close()``. ``workspace`` is the host
- workspace. Bub's builtin hooks provide a host environment (``bub.builtin.environment.LocalEnvironment``);
+ workspace. Bub's builtin hooks provide a host environment (``bub.environment.LocalEnvironment``);
a higher-priority plugin replaces it.
"""
@@ -140,20 +148,20 @@ def on_error(self, stage: str, error: Exception, message: Envelope | None) -> No
"""Observe framework errors from any stage."""
@hookspec
- def before_llm_call(self, request: LlmCallRequest, state: TurnState) -> LlmCallRequest | LlmCallDecision | None:
+ def before_llm_call(self, request: LLMCallRequest, state: TurnState) -> LLMCallRequest | LLMCallDecision | None:
"""Observe, modify or short-circuit an outgoing agent-loop LLM request.
Implementations are chained in pluggy's LIFO order (last registered
runs first): each receives the
request as modified by earlier implementations, and may return a
modified copy (``dataclasses.replace``), ``None`` to leave it
- unchanged, or ``LlmCallDecision.finish(text)`` to skip the provider
+ unchanged, or ``LLMCallDecision.finish(text)`` to skip the provider
call and emit ``text`` as the final output (cost guards / call
limits). Exceptions are logged and skipped, never fatal.
"""
@hookspec
- def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None:
+ def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: TurnState) -> None:
"""Observe the terminal outcome of one agent-loop LLM call.
Fires exactly once per completed call — success (for streaming
diff --git a/src/bub/inquirer.py b/src/bub/inquirer.py
index 63a96c5e..53c481ce 100644
--- a/src/bub/inquirer.py
+++ b/src/bub/inquirer.py
@@ -1,50 +1,67 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable
from typing import Any, cast
import typer
+from click import get_current_context
from inquirer_textual import prompts
from inquirer_textual.common.Choice import Choice
from inquirer_textual.common.InquirerResult import InquirerResult
from inquirer_textual.common.PromptSettings import PromptSettings
from inquirer_textual.common.Shortcut import Shortcut
+from bub.application import PromptChanged, PromptKind
+
CheckboxValidator = Callable[[list[str]], bool | str]
CHECKBOX_HINT_SETTINGS = PromptSettings(shortcuts=[Shortcut("space", "toggle", "Space check/uncheck")])
-def ask_prompt(question: InquirerResult[Any]) -> Any:
- if question.command in {"ctrl+c", "quit"}:
- raise typer.Abort()
- answer = question.value
- if answer is None:
- raise typer.Abort()
- return answer
+def ask_prompt(
+ prompt: Callable[..., InquirerResult[Any]], message: str, *, kind: PromptKind = "question", **kwargs: Any
+) -> Any:
+ from bub.framework import BubFramework
+
+ ctx = get_current_context(silent=True)
+ framework = ctx.find_root().obj if ctx else None
+ events = framework.events if isinstance(framework, BubFramework) else None
+ if events is not None:
+ asyncio.run(events.emit(PromptChanged(kind)))
+ try:
+ question = prompt(message, **kwargs)
+ if question.command in {"ctrl+c", "quit"}:
+ raise typer.Abort()
+ answer = question.value
+ if answer is None:
+ raise typer.Abort()
+ return answer
+ finally:
+ if events is not None:
+ asyncio.run(events.emit(PromptChanged(None)))
def ask_text(message: str, default: str = "") -> str:
- return cast("str", ask_prompt(prompts.text(message, default=default)))
+ return cast("str", ask_prompt(prompts.text, message, default=default))
def ask_secret(message: str) -> str:
- return cast("str", ask_prompt(prompts.secret(message)))
+ return cast("str", ask_prompt(prompts.secret, message, kind="auth"))
-def ask_confirm(message: str, default: bool = False) -> bool:
- return cast("bool", ask_prompt(prompts.confirm(message, default=default)))
+def ask_confirm(message: str, default: bool = False, *, kind: PromptKind = "question") -> bool:
+ return cast("bool", ask_prompt(prompts.confirm, message, default=default, kind=kind))
def ask_select(message: str, choices: list[str], default: str = "") -> str:
return cast(
"str",
ask_prompt(
- prompts.select(
- message,
- choices=cast("list[str | Choice]", choices),
- default=default,
- )
+ prompts.select,
+ message,
+ choices=cast("list[str | Choice]", choices),
+ default=default,
),
)
@@ -53,11 +70,10 @@ def ask_fuzzy(message: str, choices: list[str], default: str | None = None) -> s
return cast(
"str",
ask_prompt(
- prompts.fuzzy(
- message,
- choices=cast("list[str | Choice]", choices),
- default=default,
- )
+ prompts.fuzzy,
+ message,
+ choices=cast("list[str | Choice]", choices),
+ default=default,
),
)
@@ -70,12 +86,11 @@ def ask_checkbox(
) -> list[str]:
while True:
answer: list[str | Choice] = ask_prompt(
- prompts.checkbox(
- message,
- choices=cast("list[str | Choice]", choices),
- enabled=cast("list[str | Choice] | None", enabled),
- settings=CHECKBOX_HINT_SETTINGS,
- )
+ prompts.checkbox,
+ message,
+ choices=cast("list[str | Choice]", choices),
+ enabled=cast("list[str | Choice] | None", enabled),
+ settings=CHECKBOX_HINT_SETTINGS,
)
values = list(cast("list[str]", answer or []))
if validate is None:
diff --git a/src/bub/program_status.py b/src/bub/program_status.py
new file mode 100644
index 00000000..7a4804bc
--- /dev/null
+++ b/src/bub/program_status.py
@@ -0,0 +1,36 @@
+"""Map application events to a single OSC 7501 root record."""
+
+from contextlib import suppress
+from typing import TextIO
+
+from bub.application import CommandChanged, PromptChanged, PromptKind, WorkChanged
+from bub.core.events import Event
+
+_RESULTS = {None: "working", "succeeded": "done", "failed": "error", "cancelled": "idle"}
+
+
+class ProgramStatus:
+ def __init__(self, output: TextIO | None) -> None:
+ self.output = output
+ self.listener = False
+
+ def __call__(self, event: Event) -> None:
+ kind: PromptKind | None = None
+ match event:
+ case CommandChanged(outcome):
+ state = "idle" if outcome == "succeeded" and self.listener else _RESULTS[outcome]
+ case WorkChanged(busy, completed):
+ self.listener = True
+ state = "working" if busy else _RESULTS[completed] if completed else "idle"
+ case PromptChanged(kind):
+ state = "blocked" if kind else "working"
+ case _:
+ return
+ with suppress(OSError):
+ if self.output is None or not self.output.isatty():
+ return
+ fields = f"state={state}:app=bub"
+ if kind is not None:
+ fields += f":kind={kind}"
+ self.output.write(f"\x1b]7501;{fields}\x1b\\")
+ self.output.flush()
diff --git a/src/bub/prompt.py b/src/bub/prompt.py
new file mode 100644
index 00000000..ee018291
--- /dev/null
+++ b/src/bub/prompt.py
@@ -0,0 +1,58 @@
+"""User prompt content and the legacy prompt forms accepted for compatibility."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import republic
+
+from bub.errors import BubError, ErrorKind
+
+type UserContent = str | republic.Image | republic.Audio | republic.Video
+type LegacyPrompt = str | list[dict[str, Any]]
+"""Prompt forms used before ``list[UserContent]``: text, or text and media content blocks."""
+
+_MEDIA_TYPES = (republic.Image, republic.Audio, republic.Video)
+
+
+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
+
+ if isinstance(prompt, str):
+ return [prompt]
+ if not isinstance(prompt, list):
+ raise BubError(ErrorKind.INVALID_INPUT, "Expected user content, text, or a list of content blocks.")
+ content: list[UserContent] = []
+ for item in prompt:
+ if isinstance(item, str | republic.Image | republic.Audio | republic.Video):
+ content.append(item)
+ elif isinstance(item, dict):
+ for part in to_message({"role": "user", "content": [item]}).parts:
+ if isinstance(part, republic.Text):
+ content.append(part.text)
+ elif isinstance(part, _MEDIA_TYPES):
+ content.append(part)
+ else:
+ raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content block: {item.get('type')}")
+ else:
+ raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content: {type(item).__name__}")
+ return content
+
+
+def to_legacy_prompt(content: list[UserContent]) -> LegacyPrompt:
+ """Return the legacy form: text without media, otherwise content blocks with media URLs."""
+ if all(isinstance(item, str) for item in content):
+ return prompt_text(content)
+ blocks: list[dict[str, Any]] = []
+ for item in content:
+ if isinstance(item, str):
+ blocks.append({"type": "text", "text": item})
+ else:
+ blocks.append({"type": item.kind, "media_type": item.media_type, "url": item.data_url})
+ return blocks
+
+
+def prompt_text(content: list[UserContent]) -> str:
+ """Join the text items of a prompt."""
+ return "\n".join(item for item in content if isinstance(item, str))
diff --git a/src/bub/sidecars.py b/src/bub/sidecars.py
index 2ca767ee..88e59846 100644
--- a/src/bub/sidecars.py
+++ b/src/bub/sidecars.py
@@ -1,20 +1,8 @@
-"""Contracts for tapes mounted beside a session tape."""
+"""Compatibility alias for :mod:`bub.core.sidecars`."""
-from __future__ import annotations
+import sys
-from typing import Protocol
+from bub.core import sidecars as _module
+from bub.core.sidecars import * # noqa: F403
-SIDECAR_TAPE_MARKER = "__sidecar__"
-
-
-class TapeSidecar(Protocol):
- """A named capability backed by a sibling tape."""
-
- @property
- def name(self) -> str: ...
-
-
-def sidecar_tape_name(owner: str, sidecar: str) -> str:
- """Return the physical tape name for a mounted sidecar."""
-
- return f"{owner}{SIDECAR_TAPE_MARKER}{sidecar}"
+sys.modules[__name__] = _module
diff --git a/src/bub/store.py b/src/bub/store.py
index 06184baf..3fd8aeca 100644
--- a/src/bub/store.py
+++ b/src/bub/store.py
@@ -1,560 +1,8 @@
-from __future__ import annotations
+"""Compatibility alias for :mod:`bub.core.store`."""
-import asyncio
-import inspect
-import itertools
-import json
-import re
-import threading
-from collections.abc import Coroutine, Iterable, Sequence
-from dataclasses import asdict, dataclass, field, replace
-from datetime import UTC, datetime, time
-from datetime import date as date_type
-from pathlib import Path
-from typing import Any, NoReturn, Protocol, Self, overload
+import sys
-from loguru import logger
-from typing_extensions import TypeIs
+from bub.core import store as _module
+from bub.core.store import * # noqa: F403
-from bub.errors import BubError, ErrorKind
-from bub.tape import TapeEntry
-from bub.utils import get_entry_text
-
-WORD_PATTERN = re.compile(r"[a-z0-9_/-]+")
-MIN_FUZZY_QUERY_LENGTH = 3
-MIN_FUZZY_SCORE = 80
-MAX_FUZZY_CANDIDATES = 128
-
-
-class TapeStore(Protocol):
- """Append-only tape storage interface."""
-
- def list_tapes(self) -> list[str]: ...
-
- def reset(self, tape: str) -> None: ...
-
- def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: ...
-
- def append(self, tape: str, entry: TapeEntry) -> None: ...
-
-
-class AsyncTapeStore(Protocol):
- """Async append-only tape storage interface."""
-
- async def list_tapes(self) -> list[str]: ...
-
- async def reset(self, tape: str) -> None: ...
-
- async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: ...
-
- async def append(self, tape: str, entry: TapeEntry) -> None: ...
-
-
-def is_async_tape_store(store: TapeStore | AsyncTapeStore) -> TypeIs[AsyncTapeStore]:
- return hasattr(store, "append") and inspect.iscoroutinefunction(store.append)
-
-
-@dataclass(frozen=True)
-class TapeQuery[T: TapeStore | AsyncTapeStore]:
- tape: str
- store: T
- _query: str | None = None
- _after_anchor: str | None = None
- _after_last: bool = False
- _between_anchors: tuple[str, str] | None = None
- _between_dates: tuple[str, str] | None = None
- _kinds: tuple[str, ...] = field(default_factory=tuple)
- _limit: int | None = None
-
- def query(self, value: str) -> Self:
- return replace(self, _query=value)
-
- def after_anchor(self, name: str) -> Self:
- if not name:
- return replace(self, _after_anchor=None, _after_last=False)
- return replace(self, _after_anchor=name, _after_last=False)
-
- def last_anchor(self) -> Self:
- return replace(self, _after_anchor=None, _after_last=True)
-
- def between_anchors(self, start: str, end: str) -> Self:
- return replace(self, _between_anchors=(start, end))
-
- def between_dates(self, start: str | date_type, end: str | date_type) -> Self:
- start_value = start.isoformat() if isinstance(start, date_type) else start
- end_value = end.isoformat() if isinstance(end, date_type) else end
- return replace(self, _between_dates=(start_value, end_value))
-
- def kinds(self, *kinds: str) -> Self:
- return replace(self, _kinds=kinds)
-
- def limit(self, value: int) -> Self:
- return replace(self, _limit=value)
-
- @overload
- def all(self: TapeQuery[TapeStore]) -> Iterable[TapeEntry]: ...
-
- @overload
- async def all(self: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: ...
-
- def all(self) -> Iterable[TapeEntry] | Coroutine[None, None, Iterable[TapeEntry]]:
- return self.store.fetch_all(self)
-
-
-def _anchor_index(
- entries: Sequence[TapeEntry],
- name: str | None,
- *,
- default: int,
- forward: bool,
- start: int = 0,
-) -> int:
- rng = range(start, len(entries)) if forward else range(len(entries) - 1, start - 1, -1)
- for idx in rng:
- entry = entries[idx]
- if entry.kind != "anchor":
- continue
- if name is not None and entry.payload.get("name") != name:
- continue
- return idx
- return default
-
-
-def _parse_datetime_boundary(value: str, *, is_end: bool) -> datetime:
- if "T" not in value and " " not in value:
- try:
- parsed_date = date_type.fromisoformat(value)
- except ValueError:
- pass
- else:
- boundary_time = time.max if is_end else time.min
- return datetime.combine(parsed_date, boundary_time, tzinfo=UTC)
- try:
- parsed = datetime.fromisoformat(value)
- except ValueError:
- try:
- parsed_date = date_type.fromisoformat(value)
- except ValueError as exc:
- raise BubError(ErrorKind.INVALID_INPUT, f"Invalid ISO date or datetime: '{value}'.") from exc
- boundary_time = time.max if is_end else time.min
- parsed = datetime.combine(parsed_date, boundary_time, tzinfo=UTC)
- if parsed.tzinfo is None:
- return parsed.replace(tzinfo=UTC)
- return parsed.astimezone(UTC)
-
-
-def _entry_in_datetime_range(entry: TapeEntry, start_dt: datetime, end_dt: datetime) -> bool:
- entry_dt = _parse_datetime_boundary(entry.date, is_end=False)
- return start_dt <= entry_dt <= end_dt
-
-
-def _entry_matches_query(entry: TapeEntry, query: str) -> bool:
- needle = query.casefold()
- haystack = json.dumps(
- {
- "kind": entry.kind,
- "date": entry.date,
- "payload": entry.payload,
- "meta": entry.meta,
- },
- sort_keys=True,
- default=str,
- ).casefold()
- return needle in haystack
-
-
-class InMemoryQueryMixin:
- """Mixin to implement in-memory query support for simple stores."""
-
- def read(self, tape: str) -> list[TapeEntry] | None:
- raise NotImplementedError("InMemoryQueryMixin requires a read() method to be implemented.")
-
- def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: # noqa: C901
- entries = self.read(query.tape) or []
- start_index = 0
- end_index: int | None = None
-
- if query._between_anchors is not None:
- start_name, end_name = query._between_anchors
- start_idx = _anchor_index(entries, start_name, default=-1, forward=False)
- if start_idx < 0:
- raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{start_name}' was not found.")
- end_idx = _anchor_index(entries, end_name, default=-1, forward=True, start=start_idx + 1)
- if end_idx < 0:
- raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{end_name}' was not found.")
- start_index = min(start_idx + 1, len(entries))
- end_index = min(max(start_index, end_idx), len(entries))
- elif query._after_last:
- anchor_index = _anchor_index(entries, None, default=-1, forward=False)
- if anchor_index < 0:
- raise BubError(ErrorKind.NOT_FOUND, "No anchors found in tape.")
- start_index = min(anchor_index + 1, len(entries))
- elif query._after_anchor is not None:
- anchor_index = _anchor_index(entries, query._after_anchor, default=-1, forward=False)
- if anchor_index < 0:
- raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found.")
- start_index = min(anchor_index + 1, len(entries))
-
- sliced = entries[start_index:end_index]
- if query._between_dates is not None:
- start_date, end_date = query._between_dates
- start_dt = _parse_datetime_boundary(start_date, is_end=False)
- end_dt = _parse_datetime_boundary(end_date, is_end=True)
- if start_dt > end_dt:
- raise BubError(ErrorKind.INVALID_INPUT, "Start date must be earlier than or equal to end date.")
- sliced = [entry for entry in sliced if _entry_in_datetime_range(entry, start_dt, end_dt)]
- if query._query:
- sliced = [entry for entry in sliced if _entry_matches_query(entry, query._query)]
- if query._kinds:
- sliced = [entry for entry in sliced if entry.kind in query._kinds]
- if query._limit is not None:
- sliced = sliced[: query._limit]
- return sliced
-
-
-class InMemoryTapeStore(InMemoryQueryMixin):
- """In-memory tape storage."""
-
- def __init__(self) -> None:
- self._tapes: dict[str, list[TapeEntry]] = {}
- self._next_id: dict[str, int] = {}
-
- def list_tapes(self) -> list[str]:
- return sorted(self._tapes.keys())
-
- def reset(self, tape: str) -> None:
- self._tapes.pop(tape, None)
- self._next_id.pop(tape, None)
-
- def read(self, tape: str) -> list[TapeEntry] | None:
- entries = self._tapes.get(tape)
- if entries is None:
- return None
- return [entry.copy() for entry in entries]
-
- def append(self, tape: str, entry: TapeEntry) -> None:
- next_id = self._next_id.get(tape, 1)
- self._next_id[tape] = next_id + 1
- stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta), entry.date)
- self._tapes.setdefault(tape, []).append(stored)
-
-
-class AsyncTapeStoreAdapter:
- """Adapt a sync TapeStore to AsyncTapeStore."""
-
- def __init__(self, store: TapeStore) -> None:
- self._store = store
-
- async def list_tapes(self) -> list[str]:
- return await asyncio.to_thread(self._store.list_tapes)
-
- async def reset(self, tape: str) -> None:
- await asyncio.to_thread(self._store.reset, tape)
-
- async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]:
- return await asyncio.to_thread(self._store.fetch_all, query)
-
- async def append(self, tape: str, entry: TapeEntry) -> None:
- await asyncio.to_thread(self._store.append, tape, entry)
-
-
-class UnavailableTapeStore:
- """Sync TapeStore sentinel that always fails with a clear message."""
-
- def __init__(self, message: str) -> None:
- self._message = message
-
- def _raise(self) -> NoReturn:
- raise BubError(ErrorKind.INVALID_INPUT, self._message)
-
- def list_tapes(self) -> list[str]:
- self._raise()
-
- def reset(self, tape: str) -> None:
- self._raise()
-
- def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]:
- self._raise()
-
- def append(self, tape: str, entry: TapeEntry) -> None:
- self._raise()
-
-
-class ForkTapeStore:
- def __init__(self, parent: AsyncTapeStore, tape: str, *, sidecars: Iterable[str] = ()) -> None:
- self._parent = parent
- self._store = InMemoryTapeStore()
- self._tape = tape
- self._sidecars = tuple(dict.fromkeys(sidecars))
- self._managed_tapes = {tape, *self._sidecars}
- self._reset_tapes: set[str] = set()
-
- async def list_tapes(self) -> list[str]:
- return await self._parent.list_tapes()
-
- async def reset(self, tape: str) -> None:
- if tape not in self._managed_tapes:
- await self._parent.reset(tape)
- return
- self._store.reset(tape)
- self._reset_tapes.add(tape)
-
- async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]:
- if query.tape not in self._managed_tapes:
- return await self._parent.fetch_all(query)
-
- parent_entries: Iterable[TapeEntry] = []
- if query.tape not in self._reset_tapes:
- try:
- parent_entries = await self._parent.fetch_all(query)
- except Exception:
- parent_entries = []
- this_entries: list[TapeEntry] = []
- for entry in self._store.read(query.tape) or []:
- if entry.kind == "anchor": # noqa: SIM102
- if query._after_last or (query._after_anchor and entry.payload.get("name") == query._after_anchor):
- this_entries.clear()
- parent_entries = []
- continue
- if query._kinds and entry.kind not in query._kinds:
- continue
- this_entries.append(entry)
- entries = itertools.chain(parent_entries, this_entries)
- return itertools.islice(entries, query._limit) if query._limit is not None else entries
-
- @staticmethod
- def _redact_prompt(prompt: list[dict]) -> Any:
- if not isinstance(prompt, list):
- return prompt
- new_prompt = []
- for part in prompt:
- if part.get("type") == "text":
- new_prompt.append(part)
- return new_prompt
-
- @staticmethod
- def _redact_payload(payload: dict) -> None:
- if "content" in payload:
- payload["content"] = ForkTapeStore._redact_prompt(payload["content"])
- elif "prompt" in payload:
- payload["prompt"] = ForkTapeStore._redact_prompt(payload["prompt"])
-
- async def append(self, tape: str, entry: TapeEntry) -> None:
- self._redact_payload(entry.payload)
- if tape not in self._managed_tapes:
- await self._parent.append(tape, entry)
- return
- self._store.append(tape, entry)
-
- async def merge_back(self) -> None:
- total = 0
- for sidecar in self._sidecars:
- entries = self._store.read(sidecar) or []
- try:
- if sidecar in self._reset_tapes:
- await self._parent.reset(sidecar)
- for entry in entries:
- await self._parent.append(sidecar, entry)
- except Exception as exc:
- logger.warning('Failed to merge sidecar "{}" into tape "{}": {}', sidecar, self._tape, exc)
- self._store.append(
- self._tape,
- TapeEntry.event(
- "sidecar.merge",
- {"tape": sidecar, "status": "error", "error": str(exc)},
- context=False,
- ),
- )
- else:
- total += len(entries)
-
- if self._tape in self._reset_tapes:
- await self._parent.reset(self._tape)
- entries = self._store.read(self._tape) or []
- for entry in entries:
- await self._parent.append(self._tape, entry)
- total += len(entries)
- if total:
- logger.info('Merged {} entries into tape fork "{}"', total, self._tape)
-
-
-class FileTapeStore(InMemoryQueryMixin):
- """TapeStore implementation that persists tapes as JSONL files under a directory."""
-
- def __init__(self, directory: Path) -> None:
- self._directory = directory
- self._directory.mkdir(parents=True, exist_ok=True)
- self._tape_files: dict[str, TapeFile] = {}
-
- def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]:
- if not query._query:
- result: Iterable[TapeEntry] = super().fetch_all(query)
- return result
- unlimited_query = replace(query, _limit=None)
- entries: Iterable[TapeEntry] = super().fetch_all(unlimited_query)
- return self._filter_entries(list(entries), query._query, query._limit or 20)
-
- def _filter_entries(self, entries: list[TapeEntry], query: str, limit: int) -> list[TapeEntry]:
- normalized_query = query.strip().lower()
- if not normalized_query:
- return []
- results: list[TapeEntry] = []
- seen: set[str] = set()
-
- count = 0
- for entry in reversed(entries):
- payload_text = get_entry_text(entry).lower()
- if payload_text in seen:
- continue
- seen.add(payload_text)
-
- if normalized_query in payload_text or self._is_fuzzy_match(normalized_query, payload_text):
- results.append(entry)
- count += 1
- if count >= limit:
- break
- return results
-
- @staticmethod
- def _is_fuzzy_match(normalized_query: str, payload_text: str) -> bool:
- from rapidfuzz import fuzz, process
-
- if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH:
- return False
-
- query_tokens = WORD_PATTERN.findall(normalized_query)
- if not query_tokens:
- return False
- query_phrase = " ".join(query_tokens)
- window_size = len(query_tokens)
-
- source_tokens = WORD_PATTERN.findall(payload_text)
- if not source_tokens:
- return False
-
- candidates: list[str] = []
- for token in source_tokens:
- candidates.append(token)
- if len(candidates) >= MAX_FUZZY_CANDIDATES:
- break
-
- if window_size > 1:
- max_window_start = len(source_tokens) - window_size + 1
- for idx in range(max(0, max_window_start)):
- candidates.append(" ".join(source_tokens[idx : idx + window_size]))
- if len(candidates) >= MAX_FUZZY_CANDIDATES:
- break
-
- best_match = process.extractOne(
- query_phrase,
- candidates,
- scorer=fuzz.WRatio,
- score_cutoff=MIN_FUZZY_SCORE,
- )
- return best_match is not None
-
- def _tape_file(self, tape: str) -> TapeFile:
- if tape not in self._tape_files:
- self._tape_files[tape] = TapeFile(self._directory / f"{tape}.jsonl")
- return self._tape_files[tape]
-
- def list_tapes(self) -> list[str]:
- return sorted(file.stem for file in self._directory.glob("*.jsonl") if file.stem.count("__") == 1)
-
- def reset(self, tape: str) -> None:
- self._tape_file(tape).reset()
-
- def append(self, tape: str, entry: TapeEntry) -> None:
- self._tape_file(tape).append(entry)
-
- def read(self, tape: str) -> list[TapeEntry] | None:
- return self._tape_file(tape).read()
-
-
-class TapeFile:
- """Helper for one tape file."""
-
- def __init__(self, path: Path) -> None:
- self.path = path
- self._lock = threading.Lock()
- self._read_entries: list[TapeEntry] = []
- self._read_offset = 0
-
- def _next_id(self) -> int:
- if self._read_entries:
- return self._read_entries[-1].id + 1
- return 1
-
- def _reset(self) -> None:
- self._read_entries = []
- self._read_offset = 0
-
- def reset(self) -> None:
- with self._lock:
- if self.path.exists():
- self.path.unlink()
- self._reset()
-
- def read(self) -> list[TapeEntry]:
- with self._lock:
- return self._read_locked()
-
- def _read_locked(self) -> list[TapeEntry]:
- if not self.path.exists():
- self._reset()
- return []
-
- file_size = self.path.stat().st_size
- if file_size < self._read_offset:
- # The file was truncated or replaced, so cached entries are stale.
- self._reset()
-
- with self.path.open("r", encoding="utf-8") as handle:
- handle.seek(self._read_offset)
- for raw_line in handle:
- line = raw_line.strip()
- if not line:
- continue
- try:
- payload = json.loads(line)
- except json.JSONDecodeError:
- continue
- entry = self.entry_from_payload(payload)
- if entry is not None:
- self._read_entries.append(entry)
- self._read_offset = handle.tell()
-
- return list(self._read_entries)
-
- @staticmethod
- def entry_from_payload(payload: object) -> TapeEntry | None:
- if not isinstance(payload, dict):
- return None
- entry_id = payload.get("id")
- kind = payload.get("kind")
- entry_payload = payload.get("payload")
- meta = payload.get("meta")
- if not isinstance(entry_id, int):
- return None
- if not isinstance(kind, str):
- return None
- if not isinstance(entry_payload, dict):
- return None
- if not isinstance(meta, dict):
- meta = {}
- if "date" in payload:
- date = payload["date"]
- else:
- date = datetime.fromtimestamp(payload.get("timestamp", 0.0), tz=UTC).isoformat()
- return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), date)
-
- def append(self, entry: TapeEntry) -> None:
- with self._lock:
- # Keep cache and offset in sync before allocating new IDs.
- self._read_locked()
- with self.path.open("a", encoding="utf-8") as handle:
- next_id = self._next_id()
- stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta), entry.date)
- handle.write(json.dumps(asdict(stored), ensure_ascii=False) + "\n")
- self._read_entries.append(stored)
- self._read_offset = handle.tell()
+sys.modules[__name__] = _module
diff --git a/src/bub/tape.py b/src/bub/tape.py
index 627a074a..965d1d18 100644
--- a/src/bub/tape.py
+++ b/src/bub/tape.py
@@ -1,542 +1,8 @@
-"""Append-only tape primitives owned by Bub."""
+"""Compatibility alias for :mod:`bub.core.tape`."""
-from __future__ import annotations
+import sys
-import contextlib
-import hashlib
-import inspect
-import json
-from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping
-from dataclasses import asdict, dataclass, field, replace
-from datetime import UTC, datetime
-from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from bub.core import tape as _module
+from bub.core.tape import * # noqa: F403
-from pydantic import BaseModel
-
-from bub import tracing
-from bub.errors import BubError
-from bub.sidecars import TapeSidecar, sidecar_tape_name
-
-__all__ = [
- "LAST_ANCHOR",
- "AnchorSelector",
- "AnchorSummary",
- "AsyncTapeStore",
- "AsyncTapeStoreAdapter",
- "ContextSelector",
- "InMemoryQueryMixin",
- "InMemoryTapeStore",
- "SelectedMessages",
- "Tape",
- "TapeContext",
- "TapeEntry",
- "TapeInfo",
- "TapeQuery",
- "TapeStore",
- "UnavailableTapeStore",
- "build_messages",
- "is_async_tape_store",
- "utc_now",
-]
-
-if TYPE_CHECKING:
- from bub.store import (
- AsyncTapeStore,
- AsyncTapeStoreAdapter,
- InMemoryQueryMixin,
- InMemoryTapeStore,
- TapeQuery,
- TapeStore,
- UnavailableTapeStore,
- is_async_tape_store,
- )
-
-
-_STORE_EXPORTS = frozenset({
- "AsyncTapeStore",
- "AsyncTapeStoreAdapter",
- "InMemoryQueryMixin",
- "InMemoryTapeStore",
- "TapeQuery",
- "TapeStore",
- "UnavailableTapeStore",
- "is_async_tape_store",
-})
-
-
-def __getattr__(name: str) -> Any:
- if name not in _STORE_EXPORTS:
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
-
- from bub import store
-
- return getattr(store, name)
-
-
-def __dir__() -> list[str]:
- return sorted({*globals(), *_STORE_EXPORTS})
-
-
-def utc_now() -> str:
- return datetime.now(UTC).isoformat()
-
-
-@dataclass(frozen=True)
-class TapeEntry:
- """A single append-only entry in a tape."""
-
- id: int
- kind: str
- payload: dict[str, Any]
- meta: dict[str, Any] = field(default_factory=dict)
- date: str = field(default_factory=utc_now)
-
- def copy(self) -> TapeEntry:
- return TapeEntry(self.id, self.kind, dict(self.payload), dict(self.meta), self.date)
-
- @classmethod
- def message(cls, message: dict[str, Any], **meta: Any) -> TapeEntry:
- return cls(id=0, kind="message", payload=dict(message), meta=dict(meta))
-
- @classmethod
- def system(cls, content: str, **meta: Any) -> TapeEntry:
- return cls(id=0, kind="system", payload={"content": content}, meta=dict(meta))
-
- @classmethod
- def anchor(cls, name: str, state: dict[str, Any] | None = None, **meta: Any) -> TapeEntry:
- payload: dict[str, Any] = {"name": name}
- if state is not None:
- payload["state"] = dict(state)
- return cls(id=0, kind="anchor", payload=payload, meta=dict(meta))
-
- @classmethod
- def tool_call(cls, calls: list[dict[str, Any]], *, content: str | None = None, **meta: Any) -> TapeEntry:
- payload: dict[str, Any] = {"calls": calls}
- if content is not None:
- payload["content"] = content
- return cls(id=0, kind="tool_call", payload=payload, meta=dict(meta))
-
- @classmethod
- def tool_result(cls, results: list[Any], **meta: Any) -> TapeEntry:
- return cls(id=0, kind="tool_result", payload={"results": results}, meta=dict(meta))
-
- @classmethod
- def error(cls, error: BubError, **meta: Any) -> TapeEntry:
- return cls(id=0, kind="error", payload=error.as_dict(), meta=dict(meta))
-
- @classmethod
- def event(cls, name: str, data: dict[str, Any] | None = None, **meta: Any) -> TapeEntry:
- payload: dict[str, Any] = {"name": name}
- if data is not None:
- payload["data"] = dict(data)
- return cls(id=0, kind="event", payload=payload, meta=dict(meta))
-
-
-class _LastAnchor:
- def __repr__(self) -> str:
- return "LAST_ANCHOR"
-
-
-LAST_ANCHOR = _LastAnchor()
-type AnchorSelector = str | None | _LastAnchor
-type SelectedMessages = list[dict[str, Any]] | Coroutine[Any, Any, list[dict[str, Any]]]
-type ContextSelector = Callable[[Iterable[TapeEntry], "TapeContext"], SelectedMessages]
-
-
-@dataclass(frozen=True)
-class TapeContext:
- """Rules for selecting tape entries into a prompt context."""
-
- anchor: AnchorSelector = LAST_ANCHOR
- select: ContextSelector | None = None
- state: dict[str, Any] = field(default_factory=dict)
-
- def build_query(self, query: TapeQuery) -> TapeQuery:
- if self.anchor is None:
- return query
- if isinstance(self.anchor, _LastAnchor):
- return query.last_anchor()
- return query.after_anchor(self.anchor)
-
-
-def build_messages(entries: Iterable[TapeEntry], context: TapeContext) -> SelectedMessages:
- if context.select is not None:
- return context.select(entries, context)
- return _default_messages(entries)
-
-
-def _default_messages(entries: Iterable[TapeEntry]) -> list[dict[str, Any]]:
- messages: list[dict[str, Any]] = []
- for entry in entries:
- if entry.kind != "message":
- continue
- payload = entry.payload
- if isinstance(payload, dict):
- messages.append(dict(payload))
- return messages
-
-
-@dataclass(frozen=True)
-class TapeInfo:
- """Runtime tape info summary."""
-
- name: str
- entries: int
- anchors: int
- last_anchor: str | None
- entries_since_last_anchor: int
- last_token_usage: int | None
- last_token_cache_hit_rate: float | None
-
-
-@dataclass(frozen=True)
-class AnchorSummary:
- """Rendered anchor summary."""
-
- name: str
- state: dict[str, object]
-
-
-@dataclass(frozen=True)
-class Tape:
- """Tape abstraction for recording agent interactions."""
-
- archive_path: Path
- store: AsyncTapeStore
- context: TapeContext
- sidecars: tuple[TapeSidecar, ...] = field(default=(), repr=False)
- _name: str | None = field(default=None, repr=False)
-
- @property
- def name(self) -> str:
- if self._name is None:
- raise ValueError("tape is not scoped")
- return self._name
-
- def with_context(self, context: TapeContext) -> Tape:
- return replace(self, context=context)
-
- 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.store import TapeQuery
-
- return TapeQuery(tape=self.name, store=self.store)
-
- def get_sidecar(self, name: str) -> TapeSidecar | None:
- """Return a mounted sidecar by its public name."""
-
- return next((sidecar for sidecar in self.sidecars if sidecar.name == name), None)
-
- def sidecar_tape_name(self, name: str) -> str:
- """Return the sibling tape name for a mounted sidecar."""
-
- if self.get_sidecar(name) is None:
- raise KeyError(f"tape sidecar {name!r} is not mounted")
- return sidecar_tape_name(self.name, name)
-
- async def info(self) -> TapeInfo:
- entries = list(await self.store.fetch_all(self.query()))
- anchors = [(i, entry) for i, entry in enumerate(entries) if entry.kind == "anchor"]
- if anchors:
- last_anchor = anchors[-1][1].payload.get("name")
- entries_since_last_anchor = len(entries) - anchors[-1][0] - 1
- else:
- last_anchor = None
- entries_since_last_anchor = len(entries)
- last_token_usage: int | None = None
- last_token_cache_hit_rate: float | None = None
- for entry in reversed(entries):
- if entry.kind == "event" and entry.payload.get("name") == "run":
- data = entry.payload.get("data")
- usage = data.get("usage") if isinstance(data, Mapping) else None
- if not isinstance(usage, Mapping):
- continue
- token_usage = usage.get("total_tokens")
- if not isinstance(token_usage, int) or isinstance(token_usage, bool):
- continue
- last_token_usage = token_usage
- prompt_tokens = usage.get("prompt_tokens")
- prompt_details = usage.get("prompt_tokens_details")
- cached_tokens = prompt_details.get("cached_tokens") if isinstance(prompt_details, Mapping) else None
- if (
- isinstance(prompt_tokens, int)
- and not isinstance(prompt_tokens, bool)
- and prompt_tokens > 0
- and isinstance(cached_tokens, int)
- and not isinstance(cached_tokens, bool)
- ):
- last_token_cache_hit_rate = cached_tokens / prompt_tokens
- break
- return TapeInfo(
- name=self.name,
- entries=len(entries),
- anchors=len(anchors),
- last_anchor=str(last_anchor) if last_anchor else None,
- entries_since_last_anchor=entries_since_last_anchor,
- last_token_usage=last_token_usage,
- last_token_cache_hit_rate=last_token_cache_hit_rate,
- )
-
- async def ensure_bootstrap_anchor(self) -> None:
- anchors = list(await self.store.fetch_all(self.query().kinds("anchor")))
- if not anchors:
- await self.handoff(name="session/start", state={"owner": "human"})
-
- async def anchors(self, limit: int = 20) -> list[AnchorSummary]:
- entries = list(await self.store.fetch_all(self.query().kinds("anchor")))
- results: list[AnchorSummary] = []
- for entry in entries[-limit:]:
- name = str(entry.payload.get("name", "-"))
- state = entry.payload.get("state")
- state_dict: dict[str, object] = dict(state) if isinstance(state, dict) else {}
- results.append(AnchorSummary(name=name, state=state_dict))
- return results
-
- async def search(self, query: TapeQuery[AsyncTapeStore]) -> list[TapeEntry]:
- return list(await self.store.fetch_all(query))
-
- async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None:
- tracing.event(f"bub.{name}", **payload)
- await self.store.append(self.name, TapeEntry.event(name, payload, **(tracing.correlation() | meta)))
-
- async def read_messages(self) -> list[dict[str, Any]]:
- query = self.context.build_query(self.query())
- entries = await self.store.fetch_all(query)
- context_entries = (entry for entry in entries if entry.meta.get("context") is not False)
- messages = build_messages(context_entries, self.context)
- if inspect.isawaitable(messages):
- messages = await messages
- return messages
-
- async def handoff(
- self,
- *,
- name: str,
- state: dict[str, Any] | None = None,
- **meta: Any,
- ) -> list[TapeEntry]:
- tape_name = self.name
- meta = tracing.correlation() | meta
- tracing.event("bub.handoff", anchor=name, state=state)
- entry = TapeEntry.anchor(name, state=state, **meta)
- event = TapeEntry.event("handoff", {"name": name, "state": state or {}}, **meta)
- await self.store.append(tape_name, entry)
- await self.store.append(tape_name, event)
- return [entry, event]
-
- async def record_chat( # noqa: C901
- self,
- *,
- run_id: str,
- system_prompt: str | None,
- new_messages: list[dict[str, Any]],
- response_text: str | None,
- context_error: BubError | None = None,
- tool_calls: list[dict[str, Any]] | None = None,
- tool_results: list[Any] | None = None,
- error: BubError | None = None,
- response: Any | None = None,
- provider: str | None = None,
- model: str | None = None,
- usage: dict[str, Any] | None = None,
- ) -> None:
- tape_name = self.name
- meta = {"run_id": run_id, **tracing.correlation()}
- if system_prompt:
- await self.store.append(tape_name, TapeEntry.system(system_prompt, **meta))
- if context_error is not None:
- await self.store.append(tape_name, TapeEntry.error(context_error, **meta))
- for message in new_messages:
- await self.store.append(tape_name, TapeEntry.message(message, **meta))
- if tool_calls:
- await self.store.append(tape_name, TapeEntry.tool_call(tool_calls, content=response_text, **meta))
- if tool_results is not None:
- await self.store.append(tape_name, TapeEntry.tool_result(tool_results, **meta))
- if error is not None and error is not context_error:
- await self.store.append(tape_name, TapeEntry.error(error, **meta))
- if response_text is not None and not tool_calls:
- await self.store.append(
- tape_name, TapeEntry.message({"role": "assistant", "content": response_text}, **meta)
- )
-
- data: dict[str, Any] = {"status": "error" if error is not None else "ok"}
- resolved_usage = usage or self._extract_usage(response)
- if resolved_usage is not None:
- data["usage"] = resolved_usage
- if provider:
- data["provider"] = provider
- if model:
- data["model"] = model
- await self.store.append(tape_name, TapeEntry.event("run", data, **meta))
-
- @staticmethod
- def _extract_usage(response: object) -> dict[str, Any] | None:
- usage = getattr(response, "usage", None)
- if usage is None:
- return None
- if isinstance(usage, dict):
- return usage
- if isinstance(usage, BaseModel):
- payload = usage.model_dump(exclude_none=True)
- return payload if isinstance(payload, dict) else None
- return None
-
- async def _archive_tape(self, tape_name: str, stamp: str) -> Path:
- from bub.store import TapeQuery
-
- self.archive_path.mkdir(parents=True, exist_ok=True)
- archive_path = self.archive_path / f"{tape_name}.jsonl.{stamp}.bak"
- with archive_path.open("w", encoding="utf-8") as f:
- query = TapeQuery(tape=tape_name, store=self.store)
- for entry in await self.store.fetch_all(query):
- f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n")
- return archive_path
-
- @staticmethod
- def _sidecar_lifecycle_data(
- *,
- sidecar: str,
- status: str,
- reason: str,
- archive_path: Path | None = None,
- error: Exception | None = None,
- cause: str | None = None,
- ) -> dict[str, Any]:
- data: dict[str, Any] = {"sidecar": sidecar, "status": status, "reason": reason}
- if archive_path is not None:
- data["archive"] = str(archive_path)
- if error is not None:
- data["error"] = str(error)
- if cause is not None:
- data["cause"] = cause
- return data
-
- async def _try_archive_sidecar(
- self,
- sidecar: TapeSidecar,
- *,
- reason: str,
- stamp: str | None = None,
- ) -> tuple[Path | None, dict[str, Any]]:
- archive_stamp = stamp or datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
- try:
- archive_path = await self._archive_tape(sidecar_tape_name(self.name, sidecar.name), archive_stamp)
- except Exception as exc:
- return None, self._sidecar_lifecycle_data(
- sidecar=sidecar.name,
- status="error",
- reason=reason,
- error=exc,
- )
- return archive_path, self._sidecar_lifecycle_data(
- sidecar=sidecar.name,
- status="ok",
- reason=reason,
- archive_path=archive_path,
- )
-
- async def _try_reset_sidecar(self, sidecar: TapeSidecar, *, reason: str) -> dict[str, Any]:
- try:
- await self.store.reset(sidecar_tape_name(self.name, sidecar.name))
- except Exception as exc:
- return self._sidecar_lifecycle_data(sidecar=sidecar.name, status="error", reason=reason, error=exc)
- return self._sidecar_lifecycle_data(sidecar=sidecar.name, status="ok", reason=reason)
-
- def _require_sidecar(self, name: str) -> TapeSidecar:
- sidecar = self.get_sidecar(name)
- if sidecar is None:
- raise KeyError(f"tape sidecar {name!r} is not mounted")
- return sidecar
-
- async def archive_sidecar(self, name: str, *, reason: str = "manual") -> str:
- """Archive one mounted sidecar without changing the main tape."""
-
- sidecar = self._require_sidecar(name)
- archive_path, event_data = await self._try_archive_sidecar(sidecar, reason=reason)
- await self.append_event("sidecar.archive", event_data, context=False)
- return (
- f"Archived {name}: {archive_path}"
- if archive_path is not None
- else f"{name} archive failed: {event_data['error']}"
- )
-
- async def reset_sidecar(self, name: str, *, archive: bool = False, reason: str = "gc") -> str:
- """Reset one mounted sidecar and record the outcome on the main tape."""
-
- sidecar = self._require_sidecar(name)
- archive_path: Path | None = None
- archive_data: dict[str, Any] | None = None
- if archive:
- archive_path, archive_data = await self._try_archive_sidecar(sidecar, reason=reason)
-
- if archive_data is not None and archive_data["status"] == "error":
- reset_data = self._sidecar_lifecycle_data(
- sidecar=name,
- status="skipped",
- reason=reason,
- cause="archive_failed",
- )
- else:
- reset_data = await self._try_reset_sidecar(sidecar, reason=reason)
- if archive_data is not None:
- await self.append_event("sidecar.archive", archive_data, context=False)
- await self.append_event("sidecar.reset", reset_data, context=False)
-
- if reset_data["status"] == "error":
- return f"{name} reset failed: {reset_data['error']}"
- if reset_data["status"] == "skipped" and archive_data is not None:
- return f"{name} archive failed: {archive_data['error']}; {name} reset skipped"
- return f"Archived {name}: {archive_path}" if archive_path is not None else "ok"
-
- async def reset(self, *, archive: bool = False) -> str:
- archive_path: Path | None = None
- sidecar_archives: dict[str, dict[str, Any]] = {}
- if archive:
- stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
- archive_path = await self._archive_tape(self.name, stamp)
- for sidecar in self.sidecars:
- _, sidecar_archive = await self._try_archive_sidecar(sidecar, reason="tape.reset", stamp=stamp)
- sidecar_archives[sidecar.name] = sidecar_archive
- await self.store.reset(self.name)
- state = {"owner": "human"}
- if archive_path is not None:
- state["archived"] = str(archive_path)
- await self.handoff(name="session/start", state=state)
- for sidecar in self.sidecars:
- archive_data = sidecar_archives.get(sidecar.name)
- if archive_data is not None and archive_data["status"] == "error":
- reset_data = self._sidecar_lifecycle_data(
- sidecar=sidecar.name,
- status="skipped",
- reason="tape.reset",
- cause="archive_failed",
- )
- else:
- reset_data = await self._try_reset_sidecar(sidecar, reason="tape.reset")
- if archive_data is not None:
- await self.append_event("sidecar.archive", archive_data, context=False)
- await self.append_event("sidecar.reset", reset_data, context=False)
- return f"Archived: {archive_path}" if archive_path else "ok"
-
- def session_tape(self, session_id: str, workspace: Path, context: TapeContext | None = None) -> Tape:
- workspace_hash = hashlib.md5(str(workspace.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
- tape_name = (
- workspace_hash + "__" + hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
- )
- return self.scoped(tape_name, context=context)
-
- @contextlib.asynccontextmanager
- async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[Tape, None]:
- from bub.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)
- forked = replace(self, store=fork_store)
- try:
- yield forked
- finally:
- if merge_back:
- await fork_store.merge_back()
+sys.modules[__name__] = _module
diff --git a/src/bub/tools.py b/src/bub/tools.py
index 0d60a399..76d4ee66 100644
--- a/src/bub/tools.py
+++ b/src/bub/tools.py
@@ -1,647 +1,8 @@
-from __future__ import annotations
+"""Compatibility alias for :mod:`bub.core.tools`."""
-import asyncio
-import contextlib
-import contextvars
-import inspect
-import json
-import time
-from collections.abc import Awaitable, Callable, Iterable, Sequence
-from dataclasses import dataclass, field, replace
-from typing import TYPE_CHECKING, Any, Literal, Protocol, get_args, get_type_hints, overload
+import sys
-from loguru import logger
-from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call
+from bub.core import tools as _module
+from bub.core.tools import * # noqa: F403
-from bub.errors import BubError, ErrorKind
-from bub.hooks.interception import ToolCall, ToolCallResult
-from bub.tape import Tape
-from bub.tracing import Span
-
-if TYPE_CHECKING:
- from bub.hooks.interception import AgentHooks
-
-
-@dataclass(frozen=True)
-class ToolContext:
- """Runtime context passed to tools that opt into context.
-
- ``code_mode`` is true when the tool is invoked from model-written code rather than
- as a direct model tool call. Whether the result is rendered to text is decided by the
- ``ToolExecutor`` running the call, not by this flag.
- """
-
- tape: Tape
- run_id: str | None = None
- state: dict[str, Any] = field(default_factory=dict)
- code_mode: bool = False
-
-
-def render_result(result: Any) -> str:
- """Render a structured tool result as plain text (the default tool renderer)."""
- if isinstance(result, str):
- return result
- if isinstance(result, BaseModel):
- return result.model_dump_json()
- try:
- return json.dumps(result, ensure_ascii=False)
- except TypeError:
- return str(result)
-
-
-def _to_snake_case(name: str) -> str:
- return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
-
-
-def _callable_name(func: Callable[..., Any]) -> str:
- name = getattr(func, "__name__", None)
- if isinstance(name, str) and name:
- return name
- return func.__class__.__name__
-
-
-def _schema_from_annotation(annotation: Any) -> dict[str, Any]:
- if annotation is inspect._empty:
- annotation = Any
- try:
- return TypeAdapter(annotation).json_schema()
- except Exception as exc:
- raise ValueError(f"Failed to build JSON schema for type: {annotation!r}") from exc
-
-
-def _schema_from_signature(signature: inspect.Signature, *, ignore_params: set[str] | None = None) -> dict[str, Any]:
- ignore = ignore_params or set()
- properties: dict[str, Any] = {}
- required: list[str] = []
- for param in signature.parameters.values():
- if param.name in ignore:
- continue
- if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
- continue
- property_schema = _schema_from_annotation(param.annotation)
- if param.default is param.empty:
- required.append(param.name)
- elif _is_json_value(param.default):
- property_schema = {**property_schema, "default": param.default}
- properties[param.name] = property_schema
- schema: dict[str, Any] = {"type": "object", "properties": properties}
- if required:
- schema["required"] = required
- return schema
-
-
-def _is_json_value(value: Any) -> bool:
- try:
- json.dumps(value)
- except (TypeError, ValueError):
- return False
- return True
-
-
-def _output_schema(func: Callable[..., Any]) -> dict[str, Any] | None:
- """Build a JSON schema for the callable's return annotation, if it can be resolved."""
- try:
- annotation = get_type_hints(func).get("return", inspect.Signature.empty)
- except Exception:
- return None
- if annotation is inspect.Signature.empty:
- return None
- try:
- return _schema_from_annotation(annotation)
- except ValueError:
- return None
-
-
-def _signature_without_context(signature: inspect.Signature) -> inspect.Signature:
- parameters = [param for param in signature.parameters.values() if param.name != "context"]
- return signature.replace(parameters=parameters)
-
-
-def _validate_without_context(func: Callable[..., Any], signature: inspect.Signature) -> Callable[..., Any]:
- def validate_target(*args: Any, **kwargs: Any) -> tuple[tuple[Any, ...], dict[str, Any]]:
- return args, kwargs
-
- validate_target.__name__ = _callable_name(func)
- validate_target.__qualname__ = getattr(func, "__qualname__", validate_target.__name__)
- # Only arguments are validated; dropping the return annotation also avoids resolving
- # forward references to types that live in the tool's module, not this one.
- validate_target.__annotations__ = dict(getattr(func, "__annotations__", {}))
- validate_target.__annotations__.pop("context", None)
- validate_target.__annotations__.pop("return", None)
- validate_target.__signature__ = _signature_without_context(signature).replace( # type: ignore[attr-defined]
- return_annotation=inspect.Signature.empty
- )
- return validate_call(validate_target)
-
-
-ToolExposure = Literal["auto", "direct", "code", "command"]
-"""Where a tool can be called from.
-
-- ``auto``: by the model directly, or only from ``run_code`` in code mode.
-- ``direct``: by the model directly, also in code mode; never from ``run_code``.
-- ``code``: only from ``run_code`` in code mode; never by the model directly.
-- ``command``: only as a comma command; never by the model or from code.
-"""
-
-
-@dataclass(frozen=True)
-class Tool:
- """A callable unit the model can invoke."""
-
- name: str
- handler: Callable[..., Any]
- description: str = ""
- parameters: dict[str, Any] = field(default_factory=dict)
- context: bool = False
- exposure: ToolExposure = "auto"
- """Where the tool can be called from, see ``ToolExposure``."""
- renderer: Callable[[Any], str] | None = None
- output_schema: dict[str, Any] | None = None
- """JSON schema of the structured result, used to describe the tool to model-written code."""
- deferred: bool = False
- """Load the tool on demand: only its name is listed until ``tool.describe`` loads its definition."""
-
- def __post_init__(self) -> None:
- if self.exposure not in get_args(ToolExposure):
- raise ValueError(f"Tool '{self.name}' has unknown exposure {self.exposure!r}.")
-
- def run(self, *args: Any, **kwargs: Any) -> Any:
- return self.handler(*args, **kwargs)
-
- def render(self, result: Any) -> str:
- """Render a structured result of this tool as plain text for the model."""
- if self.renderer is None:
- return render_result(result)
- return self.renderer(result)
-
- def to_schema(self) -> dict[str, Any]:
- """Build an any-llm completion tool payload."""
- return {
- "type": "function",
- "function": {
- "name": self.name,
- "description": self.description,
- "parameters": self.parameters,
- },
- }
-
- @classmethod
- def from_callable(
- cls,
- func: Callable[..., Any],
- *,
- name: str | None = None,
- description: str | None = None,
- context: bool = False,
- exposure: ToolExposure = "auto",
- renderer: Callable[[Any], str] | None = None,
- deferred: bool = False,
- ) -> Tool:
- signature = inspect.signature(func)
- if context and "context" not in signature.parameters:
- raise TypeError("Tool context is enabled but the callable lacks a 'context' parameter.")
- tool_name = name or _to_snake_case(_callable_name(func))
- tool_description = description if description is not None else (inspect.getdoc(func) or "")
- parameters = _schema_from_signature(signature, ignore_params={"context"} if context else None)
- if context:
- validate_args = _validate_without_context(func, signature)
-
- def validated(*args: Any, **kwargs: Any) -> Any:
- tool_context = kwargs.pop("context")
- validated_args, validated_kwargs = validate_args(*args, **kwargs)
- return func(*validated_args, context=tool_context, **validated_kwargs)
-
- else:
- validated = validate_call(config=ConfigDict(arbitrary_types_allowed=True))(func)
- return cls(
- name=tool_name,
- description=tool_description,
- parameters=parameters,
- handler=validated,
- context=context,
- exposure=exposure,
- renderer=renderer,
- output_schema=_output_schema(func),
- deferred=deferred,
- )
-
-
-def model_tools(tools: Iterable[Tool]) -> list[Tool]:
- """Convert tools callable by the model or code into model-safe aliases; comma commands are dropped."""
- return [
- replace(tool_item, name=tool_item.name.replace(".", "_"))
- for tool_item in tools
- if tool_item.exposure != "command"
- ]
-
-
-@dataclass(frozen=True)
-class ToolExecution:
- tool_results: list[Any] = field(default_factory=list)
- error: BubError | None = None
-
-
-@dataclass(frozen=True)
-class _FailedToolResult:
- error: BubError
- result: Any = None
-
-
-class ToolCallReporter(Protocol):
- def start(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Awaitable[None] | None: ...
-
- def success(self, name: str, result: Any, elapsed_ms: float) -> Awaitable[None] | None: ...
-
- def error(self, name: str, error: BaseException, elapsed_ms: float) -> Awaitable[None] | None: ...
-
-
-_TOOL_CALL_REPORTER: contextvars.ContextVar[ToolCallReporter | None] = contextvars.ContextVar(
- "bub_tool_call_reporter", default=None
-)
-
-
-@contextlib.contextmanager
-def tool_call_reporter(reporter: ToolCallReporter):
- token = _TOOL_CALL_REPORTER.set(reporter)
- try:
- yield
- finally:
- _TOOL_CALL_REPORTER.reset(token)
-
-
-async def _await_report(report: Awaitable[None] | None) -> None:
- if report is not None:
- await report
-
-
-class ToolExecutor:
- """Execute already-resolved Bub tool invocations.
-
- With ``render`` (the default) successful results are rendered to model-facing text with
- ``Tool.render``; ``render=False`` returns tools' structured results unchanged, as needed
- when the caller is code rather than the model.
- """
-
- def __init__(self, hooks: AgentHooks | None = None, *, render: bool = True) -> None:
- self._hooks = hooks
- self._render = render
-
- async def execute_async(
- self,
- invocations: Sequence[tuple[Tool, dict[str, Any]]],
- *,
- context: ToolContext | None = None,
- call_ids: Sequence[str] | None = None,
- ) -> ToolExecution:
- if not invocations:
- return ToolExecution(tool_results=[])
-
- results: list[Any] = []
- error: BubError | None = None
- gathered = await asyncio.gather(
- *(
- self._trace_tool_response(tool_obj, tool_args, context, call_ids[index] if call_ids else None)
- for index, (tool_obj, tool_args) in enumerate(invocations)
- ),
- return_exceptions=True,
- )
- for result in gathered:
- if isinstance(result, _FailedToolResult):
- error = result.error
- results.append(result.error.as_dict() if result.result is None else result.result)
- elif isinstance(result, BubError):
- error = result
- results.append(result.as_dict())
- elif isinstance(result, BaseException):
- raise result
- else:
- results.append(result)
-
- return ToolExecution(tool_results=results, error=error)
-
- async def _trace_tool_response(
- self,
- tool: Tool,
- arguments: dict[str, Any],
- context: ToolContext | None,
- call_id: str | None,
- ) -> Any:
- span = Span(
- f"execute_tool {tool.name}",
- {
- "gen_ai.operation.name": "execute_tool",
- "gen_ai.tool.name": tool.name,
- "gen_ai.tool.type": "function",
- "gen_ai.tool.call.id": call_id,
- "bub.run_id": context.run_id if context else None,
- "gen_ai.conversation.id": context.state.get("session_id") if context else None,
- },
- )
- try:
- with span.activate():
- result = await self._handle_tool_response_async(tool, arguments, context, span=span)
- if isinstance(result, _FailedToolResult):
- span.fail(result.error)
- span.set(**{
- "gen_ai.tool.call.result": result.error.as_dict() if result.result is None else result.result
- })
- else:
- span.set(**{"gen_ai.tool.call.result": result})
- return result
- except BaseException as exc:
- span.fail(exc)
- raise
- finally:
- span.end()
-
- def _invoke_tool(
- self,
- *,
- tool_name: str,
- tool_obj: Tool,
- tool_args: dict[str, Any],
- context: ToolContext | None,
- ) -> Any:
- if tool_obj.context:
- if context is None:
- raise BubError(ErrorKind.INVALID_INPUT, f"Tool '{tool_name}' requires context but none was provided.")
- return tool_obj.run(context=context, **tool_args)
- return tool_obj.run(**tool_args)
-
- async def _handle_tool_response_async(
- self,
- tool_obj: Tool,
- tool_args: dict[str, Any],
- context: ToolContext | None,
- *,
- span: Span | None = None,
- ) -> Any:
- tool_name = tool_obj.name
- call = ToolCall(
- run_id=(context.run_id if context is not None else None) or "",
- tool=tool_name,
- arguments=dict(tool_args),
- context=context,
- )
- hook_state = context.state if context is not None else {}
- if self._hooks is not None and context is not None:
- hook_state["_runtime_tape"] = context.tape
- started = time.monotonic()
- call, short_circuit = await self._apply_before_tool_call(call, hook_state, started)
- if span is not None:
- span.set(**{"gen_ai.tool.call.arguments": call.arguments})
- if short_circuit is not None:
- return short_circuit()
-
- try:
- result = await self._invoke_normalized(tool_obj, call, context)
- except BubError as exc:
- outcome = await self._fire_after_tool_call(call, hook_state, started, error=exc)
- return _FailedToolResult(error=exc, result=outcome.result)
- else:
- outcome = await self._fire_after_tool_call(call, hook_state, started, result=result)
- return outcome.result
-
- async def _invoke_normalized(self, tool_obj: Tool, call: ToolCall, context: ToolContext | None) -> Any:
- """Run the tool with errors normalized to BubError; render the result if configured to."""
-
- tool_name = tool_obj.name
- try:
- value = self._invoke_tool(
- tool_name=tool_name,
- tool_obj=tool_obj,
- tool_args=call.arguments,
- context=context,
- )
- if inspect.isawaitable(value):
- value = await value
- if self._render:
- value = tool_obj.render(value)
- except BubError:
- raise
- except ValidationError as exc:
- raise BubError(
- ErrorKind.INVALID_INPUT,
- f"Tool '{tool_name}' argument validation failed.",
- details={"errors": json.loads(exc.json())},
- ) from exc
- except Exception as exc:
- raise BubError(
- ErrorKind.TOOL,
- f"Tool '{tool_name}' execution failed.",
- details={"error": repr(exc)},
- ) from exc
- return value
-
- async def _apply_before_tool_call(
- self,
- call: ToolCall,
- hook_state: dict[str, Any],
- started: float,
- ) -> tuple[ToolCall, Callable[[], Any] | None]:
- """Run before_tool_call and translate deny/replace into a short-circuit thunk."""
-
- if self._hooks is None:
- return call, None
- call, decision = await self._hooks.before_tool_call(call, state=hook_state)
- if decision.action == "deny":
- error = BubError(
- ErrorKind.TOOL,
- decision.message or f"Tool '{call.tool}' call denied by policy hook.",
- )
- outcome = await self._fire_after_tool_call(call, hook_state, started, error=error)
- return call, lambda: _FailedToolResult(error=error, result=outcome.result)
- if decision.action == "replace":
- outcome = await self._fire_after_tool_call(call, hook_state, started, result=decision.result)
- return call, lambda: outcome.result
- return call, None
-
- async def _fire_after_tool_call(
- self,
- call: ToolCall,
- state: dict[str, Any],
- started: float,
- *,
- result: Any = None,
- error: Exception | None = None,
- ) -> ToolCallResult:
- duration_ms = int((time.monotonic() - started) * 1000)
- outcome = ToolCallResult(
- run_id=call.run_id,
- tool=call.tool,
- arguments=call.arguments,
- result=None if error is not None else result,
- error=error,
- duration_ms=duration_ms,
- )
- if self._hooks is not None:
- await self._hooks.after_tool_call(call, outcome, state=state)
- return outcome
-
-
-# Central registry for tools. Tools defined with the @tool decorator are automatically added here.
-REGISTRY: dict[str, Tool] = {}
-
-
-def _add_logging(tool: Tool) -> Tool:
- handler = tool.handler
-
- async def wrapped(*args, **kwargs):
- call_kwargs = kwargs.copy()
- if tool.context:
- call_kwargs.pop("context", None)
- reporter = _TOOL_CALL_REPORTER.get()
- if reporter is None:
- _log_tool_call(tool.name, args, call_kwargs)
- else:
- await _await_report(reporter.start(tool.name, args, call_kwargs))
- start = time.monotonic()
-
- try:
- result = handler(*args, **kwargs)
- if inspect.isawaitable(result):
- result = await result
- except Exception as exc:
- elapsed_time = (time.monotonic() - start) * 1000
- if reporter is None:
- logger.exception("tool.call.error name={} elapsed_time={:.2f}ms", tool.name, elapsed_time)
- else:
- await _await_report(reporter.error(tool.name, exc, elapsed_time))
- raise
- else:
- elapsed_time = (time.monotonic() - start) * 1000
- if reporter is None:
- logger.info("tool.call.success name={} elapsed_time={:.2f}ms", tool.name, elapsed_time)
- else:
- await _await_report(reporter.success(tool.name, result, elapsed_time))
- return result
-
- return replace(tool, handler=wrapped)
-
-
-def _shorten_text(text: str, width: int = 30, placeholder: str = "...") -> str:
- if len(text) <= width:
- return text
-
- # Reserve space for placeholder
- available = width - len(placeholder)
- if available <= 0:
- return placeholder
-
- return text[:available] + placeholder
-
-
-def _render_value(value: Any) -> str:
- try:
- rendered = json.dumps(value, ensure_ascii=False)
- except TypeError:
- rendered = repr(value)
- rendered = _shorten_text(rendered, width=100, placeholder="...")
- if rendered.startswith('"') and not rendered.endswith('"'):
- rendered = rendered + '"'
- if rendered.startswith("{") and not rendered.endswith("}"):
- rendered = rendered + "}"
- if rendered.startswith("[") and not rendered.endswith("]"):
- rendered = rendered + "]"
- return rendered
-
-
-def _log_tool_call(name: str, args: Any, kwargs: dict[str, Any]) -> None:
- params: list[str] = []
-
- for value in args:
- params.append(_render_value(value))
- for key, value in kwargs.items():
- rendered = _render_value(value)
- params.append(f"{key}={rendered}")
- params_str = f" {{ {', '.join(params)} }}" if params else ""
- logger.info("tool.call.start name={}{}", name, params_str)
-
-
-@overload
-def tool(
- func: Callable,
- *,
- name: str | None = ...,
- model: type[BaseModel] | None = ...,
- description: str | None = ...,
- context: bool = ...,
- exposure: ToolExposure = ...,
- renderer: Callable[[Any], str] | None = ...,
- deferred: bool = ...,
-) -> Tool: ...
-
-
-@overload
-def tool(
- func: None = ...,
- *,
- name: str | None = ...,
- model: type[BaseModel] | None = ...,
- description: str | None = ...,
- context: bool = ...,
- exposure: ToolExposure = ...,
- renderer: Callable[[Any], str] | None = ...,
- deferred: bool = ...,
-) -> Callable[[Callable], Tool]: ...
-
-
-def tool(
- func: Callable | None = None,
- *,
- name: str | None = None,
- model: type[BaseModel] | None = None,
- description: str | None = None,
- context: bool = False,
- exposure: ToolExposure = "auto",
- renderer: Callable[[Any], str] | None = None,
- deferred: bool = False,
-) -> Tool | Callable[[Callable], Tool]:
- """Decorator to convert a function into a Tool instance.
-
- Tools should return structured results; ``renderer`` turns such a result into the
- plain text shown to the model outside code mode (defaults to JSON for non-strings).
- ``exposure`` decides where the tool can be called from (see ``ToolExposure``), and
- ``deferred`` tools are loaded on demand through ``tool.describe``.
- """
-
- def decorator(func: Callable) -> Tool:
- if model is not None:
- if context and "context" not in inspect.signature(func).parameters:
- raise TypeError("Tool context is enabled but the handler lacks a 'context' parameter.")
-
- def handler(*args: Any, **kwargs: Any) -> Any:
- tool_context = kwargs.pop("context", None)
- parsed = model(*args, **kwargs)
- if context:
- return func(parsed, context=tool_context)
- return func(parsed)
-
- result = Tool(
- name=name or _to_snake_case(model.__name__),
- description=description if description is not None else (inspect.getdoc(func) or model.__doc__ or ""),
- parameters=model.model_json_schema(),
- handler=handler,
- context=context,
- exposure=exposure,
- renderer=renderer,
- output_schema=_output_schema(func),
- deferred=deferred,
- )
- else:
- result = Tool.from_callable(
- func,
- name=name,
- description=description,
- context=context,
- exposure=exposure,
- renderer=renderer,
- deferred=deferred,
- )
- tool_instance = _add_logging(result)
- REGISTRY[tool_instance.name] = tool_instance
- return tool_instance
-
- if func is None:
- return decorator
- return decorator(func)
+sys.modules[__name__] = _module
diff --git a/src/bub/tracing.py b/src/bub/tracing.py
index 5716fab7..d9b0fe11 100644
--- a/src/bub/tracing.py
+++ b/src/bub/tracing.py
@@ -6,11 +6,13 @@
import importlib
import json
import os
-from collections.abc import Iterator, Mapping
+from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager, suppress
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any
+import republic
+
if TYPE_CHECKING:
import opentelemetry.trace as otel
else:
@@ -70,7 +72,8 @@ def _json(value: Any) -> str:
def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]:
content = message.get("content")
if message.get("role") == "tool":
- return [{"type": "tool_call_response", "id": message.get("tool_call_id", ""), "response": content}]
+ call_id = message.get("tool_call_id") or (message.get("tool_call") or {}).get("id", "")
+ return [{"type": "tool_call_response", "id": call_id, "response": content}]
parts: list[dict[str, Any]] = []
if isinstance(content, str) and content:
parts.append({"type": "text", "content": content})
@@ -82,15 +85,14 @@ def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]:
# Preserve media structure without copying inline binary payloads.
parts.append({"type": "text", "content": f"[{part.get('type', 'media')} omitted]"})
for call in message.get("tool_calls") or []:
- function = call.get("function", {})
- arguments = function.get("arguments", {})
+ arguments = call.get("arguments", {})
if isinstance(arguments, str):
with suppress(ValueError):
arguments = json.loads(arguments)
parts.append({
"type": "tool_call",
"id": call.get("id", ""),
- "name": function.get("name", ""),
+ "name": call.get("name", ""),
"arguments": arguments,
})
return parts
@@ -153,9 +155,10 @@ def rename(self, name: str) -> None:
if self.recording:
self._span.update_name(name)
- def messages(self, key: str, messages: list[dict[str, Any]]) -> None:
+ def messages(self, key: str, messages: Sequence[Mapping[str, Any] | republic.Message]) -> None:
if self.recording:
- normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in messages]
+ dicts = [m.to_dict() if isinstance(m, republic.Message) else m for m in messages]
+ normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in dicts]
direction = "input" if key == "gen_ai.input.messages" else "output"
self.set(**{
key: _json(normalized),
diff --git a/src/bub/turn.py b/src/bub/turn.py
index cf54d8e0..8b90ed36 100644
--- a/src/bub/turn.py
+++ b/src/bub/turn.py
@@ -3,10 +3,13 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any
+from typing import TYPE_CHECKING, Any
from bub.envelope import Envelope
+if TYPE_CHECKING:
+ from bub.prompt import UserContent
+
type TurnState = dict[str, Any]
@@ -15,7 +18,7 @@ class TurnResult:
"""Result of one complete message turn."""
session_id: str
- prompt: str | list[dict[str, Any]]
+ prompt: list[UserContent]
model_output: str
outbounds: list[Envelope] = field(default_factory=list)
state: TurnState = field(default_factory=dict)
diff --git a/src/bub/types.py b/src/bub/types.py
deleted file mode 100644
index 7518373d..00000000
--- a/src/bub/types.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""Deprecated compatibility aliases for the former shared type module."""
-
-import warnings
-
-from bub.channels.contracts import MessageHandler
-from bub.envelope import Envelope
-from bub.turn import TurnState as State
-
-__all__ = ["Envelope", "MessageHandler", "State"]
-
-warnings.warn(
- "`bub.types` is deprecated; use `bub.envelope.Envelope`, `bub.turn.TurnState`, "
- "and `bub.channels.contracts.MessageHandler` instead.",
- DeprecationWarning,
- stacklevel=2,
-)
diff --git a/src/bub/utils.py b/src/bub/utils.py
index aab1af4a..73882cd3 100644
--- a/src/bub/utils.py
+++ b/src/bub/utils.py
@@ -4,7 +4,7 @@
from pathlib import Path
from typing import Any
-from bub.tape import TapeEntry
+from bub.core.tape import TapeEntry
from bub.turn import TurnState
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..698a4849
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Bub behavior tests and shared model response fixtures."""
diff --git a/tests/conftest.py b/tests/conftest.py
index 380894b0..56a64baf 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,11 +1,23 @@
from __future__ import annotations
+import os
from collections.abc import Callable, Generator
from pathlib import Path
+from typing import Any
+import httpx2
import pytest
import bub.configure as configure
+from tests.model_fakes import ProviderService
+
+
+@pytest.fixture(autouse=True)
+def isolate_bub_environment(monkeypatch: pytest.MonkeyPatch) -> None:
+ # Importing bub.configure loads the developer's .env into os.environ; tests set what they need.
+ for name in list(os.environ):
+ if name.startswith("BUB_"):
+ monkeypatch.delenv(name)
@pytest.fixture(autouse=True)
@@ -37,3 +49,47 @@ def _load(content: str = "") -> Path:
return config_file
return _load
+
+
+@pytest.fixture
+def provider_service() -> ProviderService:
+ return ProviderService()
+
+
+@pytest.fixture
+def codex_executable(tmp_path: Path) -> Path:
+ import sys
+
+ executable = tmp_path / "codex"
+ executable.write_text(
+ f"#!{sys.executable}\n"
+ + """
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any
+args = sys.argv[1:]
+if not args or args[0] != "login" or 'cli_auth_credentials_store="file"' not in args:
+ sys.exit(2)
+home = Path(os.environ["CODEX_HOME"])
+home.mkdir(parents=True, exist_ok=True)
+account = "device-account" if "--device-auth" in args else "browser-account"
+(home / "auth.json").write_text(json.dumps({"tokens": {"access_token": "test-token", "account_id": account}}))
+""",
+ encoding="utf-8",
+ )
+ executable.chmod(0o755)
+ return executable
+
+
+@pytest.fixture
+def provider_transport(provider_service: ProviderService, monkeypatch: pytest.MonkeyPatch) -> ProviderService:
+ constructor = httpx2.AsyncClient
+
+ def client(**kwargs: Any) -> httpx2.AsyncClient:
+ kwargs.setdefault("transport", httpx2.MockTransport(provider_service._respond))
+ return constructor(**kwargs)
+
+ monkeypatch.setattr(httpx2, "AsyncClient", client)
+ return provider_service
diff --git a/tests/model_fakes.py b/tests/model_fakes.py
new file mode 100644
index 00000000..c23bf7da
--- /dev/null
+++ b/tests/model_fakes.py
@@ -0,0 +1,116 @@
+"""HTTP provider fixtures shared by runtime behavior tests."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import AsyncIterator, Sequence
+from typing import Any
+
+import httpx2
+
+from bub.builtin.settings import AgentSettings
+from bub.core import Agent as CoreAgent
+from bub.core.tape import Tape
+from bub.core.tools import Tool
+from bub.hooks.interception import PluginAgentHooks
+from bub.prompt import UserContent
+from bub.streaming import StreamEvent
+
+
+class StepRunner:
+ """Run one model step through the core agent, assembled from settings like the builtin agent."""
+
+ def __init__(self, settings: AgentSettings, hooks: PluginAgentHooks | None = None) -> None:
+ self.settings = settings
+ self.hooks = hooks
+
+ def run(
+ self,
+ *,
+ tape: Tape,
+ model: str,
+ tools: Sequence[Tool],
+ system_prompt: str | None,
+ prompt: list[UserContent] | None,
+ steering_messages: list[list[UserContent]] | None = None,
+ ) -> AsyncIterator[StreamEvent]:
+ class Agent(CoreAgent):
+ async def pending_messages(self, tape: Tape) -> list[list[UserContent]]:
+ return steering_messages or []
+
+ chat_model = self.settings.chat_model(model, reasoning_effort=tape.context.state.get("reasoning_effort"))
+ agent = Agent(store=tape.store, tools=tools, system_prompt=system_prompt or ())
+ if self.hooks is not None:
+ self.hooks.subscribe(agent.events)
+ return agent.run_once(prompt, model=chat_model, tape=tape)
+
+
+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 reply_chat(
+ self,
+ text: str = "done",
+ calls: list[dict[str, Any]] | None = None,
+ *,
+ model: str = "actual-model",
+ input_tokens: int = 3,
+ output_tokens: int = 2,
+ ) -> None:
+ delta: dict[str, Any] = {"content": text}
+ if calls:
+ delta["tool_calls"] = [
+ {"index": index, "id": call["id"], "function": {"name": call["name"], "arguments": call["arguments"]}}
+ for index, call in enumerate(calls)
+ ]
+ self.reply(
+ sse([
+ {
+ "id": "response-1",
+ "model": model,
+ "choices": [{"delta": delta, "finish_reason": "tool_calls" if calls else "stop"}],
+ },
+ {"choices": [], "usage": {"prompt_tokens": input_tokens, "completion_tokens": output_tokens}},
+ "[DONE]",
+ ])
+ )
+
+ 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_agent_hooks.py b/tests/test_agent_hooks.py
index 5ce6fbd6..f633c0a7 100644
--- a/tests/test_agent_hooks.py
+++ b/tests/test_agent_hooks.py
@@ -3,36 +3,44 @@
from __future__ import annotations
from dataclasses import replace
+from pathlib import Path
from typing import Any
import pluggy
import pytest
+import republic
+from republic.errors import StreamIncompleteError
+from bub.builtin.settings import AgentSettings
+from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore
+from bub.core.tape import Tape, TapeContext
+from bub.core.tools import Tool, ToolExecutor
from bub.errors import BubError
from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl
from bub.hooks.interception import (
- AgentHooks,
- LlmCallDecision,
- LlmCallRequest,
- LlmCallResult,
+ LLMCallDecision,
+ LLMCallRequest,
+ LLMCallResult,
+ PluginAgentHooks,
ToolCall,
ToolCallDecision,
ToolCallResult,
)
from bub.hooks.runtime import HookRuntime
-from bub.tools import Tool, ToolExecutor
+from bub.prompt import to_content
+from tests.model_fakes import ProviderService, StepRunner, sse
-def make_hooks(*plugins: Any) -> AgentHooks:
+def make_hooks(*plugins: Any) -> PluginAgentHooks:
pm = pluggy.PluginManager(BUB_HOOK_NAMESPACE)
pm.add_hookspecs(BubHookSpecs)
for plugin in plugins:
pm.register(plugin)
- return AgentHooks(HookRuntime(pm))
+ return PluginAgentHooks(HookRuntime(pm))
-def request() -> LlmCallRequest:
- return LlmCallRequest(run_id="run-1", model="openai:gpt-x", messages=[{"role": "user", "content": "hi"}])
+def request() -> LLMCallRequest:
+ return LLMCallRequest(run_id="run-1", model="openai:gpt-x", messages=[republic.user("hi")])
class TestBeforeLlmCall:
@@ -40,33 +48,33 @@ class TestBeforeLlmCall:
async def test_chain_folds_modifications_in_order(self) -> None:
class SwapModel:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest:
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest:
return replace(request, model="anthropic:claude")
class AppendMessage:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest:
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest:
# must see SwapModel's change (registration-order chaining)
assert request.model == "anthropic:claude"
- return replace(request, messages=[*request.messages, {"role": "user", "content": "extra"}])
+ return replace(request, messages=[*request.messages, republic.user("extra")])
# pluggy LIFO: last registered runs first -> register AppendMessage first
hooks = make_hooks(AppendMessage(), SwapModel())
result, decision = await hooks.before_llm_call(request(), state={})
assert decision is None
assert result.model == "anthropic:claude"
- assert result.messages[-1]["content"] == "extra"
+ assert result.messages[-1].text == "extra"
@pytest.mark.asyncio
async def test_none_and_bad_returns_leave_request_unchanged(self) -> None:
class Noop:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> None:
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> None:
return None
class BadReturn:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> Any:
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> Any:
return "not-a-request"
hooks = make_hooks(Noop(), BadReturn())
@@ -77,12 +85,12 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> Any:
async def test_raising_impl_is_isolated(self) -> None:
class Boom:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest:
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest:
raise RuntimeError("plugin broke")
class After:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest:
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest:
return replace(request, model="fallback:model")
hooks = make_hooks(Boom(), After())
@@ -147,8 +155,8 @@ class Deny:
def before_tool_call(self, call: ToolCall, state: dict) -> ToolCallDecision:
return ToolCallDecision.deny("blocked by policy")
- executor = ToolExecutor(hooks=make_hooks(Deny()))
- execution = await executor.execute_async([(self.tool(), {"cmd": "rm -rf /"})])
+ executor = ToolExecutor(make_hooks(Deny()).subscribe())
+ execution = await executor.execute_many([(self.tool(), {"cmd": "rm -rf /"})])
assert execution.error is not None
assert "blocked by policy" in execution.error.message
assert execution.tool_results[0]["message"] == "blocked by policy"
@@ -160,8 +168,8 @@ class Replace:
def before_tool_call(self, call: ToolCall, state: dict) -> ToolCallDecision:
return ToolCallDecision.replace("cached result")
- executor = ToolExecutor(hooks=make_hooks(Replace()))
- execution = await executor.execute_async([(self.tool(), {"cmd": "ls"})])
+ executor = ToolExecutor(make_hooks(Replace()).subscribe())
+ execution = await executor.execute_many([(self.tool(), {"cmd": "ls"})])
assert execution.error is None
assert execution.tool_results == ["cached result"]
@@ -177,9 +185,9 @@ def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict) -
def failing(cmd: str) -> str:
raise ValueError("nope")
- executor = ToolExecutor(hooks=make_hooks(Observe()))
- await executor.execute_async([(self.tool(), {"cmd": "ls"})])
- await executor.execute_async([(Tool(name="bad", handler=failing, description="", parameters={}), {"cmd": "x"})])
+ executor = ToolExecutor(make_hooks(Observe()).subscribe())
+ await executor.execute_many([(self.tool(), {"cmd": "ls"})])
+ await executor.execute_many([(Tool(name="bad", handler=failing, description="", parameters={}), {"cmd": "x"})])
assert observed[0].result == "ran:ls"
assert observed[0].error is None
assert isinstance(observed[1].error, BubError) # original error object, kind/details preserved
@@ -194,8 +202,8 @@ def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict) -
if isinstance(result.result, str):
result.result = f"bounded:{result.result}"
- executor = ToolExecutor(hooks=make_hooks(BoundResult()))
- execution = await executor.execute_async([(self.tool(), {"cmd": "ls"})])
+ executor = ToolExecutor(make_hooks(BoundResult()).subscribe())
+ execution = await executor.execute_many([(self.tool(), {"cmd": "ls"})])
assert execution.tool_results == ["bounded:ran:ls"]
@@ -206,33 +214,33 @@ class Rewrite:
def before_tool_call(self, call: ToolCall, state: dict) -> ToolCallDecision:
return ToolCallDecision.proceed(arguments={"cmd": "safe-ls"})
- executor = ToolExecutor(hooks=make_hooks(Rewrite()))
- execution = await executor.execute_async([(self.tool(), {"cmd": "rm"})])
+ executor = ToolExecutor(make_hooks(Rewrite()).subscribe())
+ execution = await executor.execute_many([(self.tool(), {"cmd": "rm"})])
assert execution.tool_results == ["ran:safe-ls"]
@pytest.mark.asyncio
async def test_no_hooks_keeps_current_behavior(self) -> None:
- execution = await ToolExecutor().execute_async([(self.tool(), {"cmd": "ls"})])
+ execution = await ToolExecutor().execute_many([(self.tool(), {"cmd": "ls"})])
assert execution.tool_results == ["ran:ls"]
class TestAfterLlmCall:
@pytest.mark.asyncio
async def test_observe_only_and_isolated(self) -> None:
- observed: list[LlmCallResult] = []
+ observed: list[LLMCallResult] = []
class Boom:
@hookimpl
- def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None:
+ def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None:
raise RuntimeError("metrics plugin broke")
class Observe:
@hookimpl
- def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None:
+ def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None:
observed.append(result)
hooks = make_hooks(Boom(), Observe())
- result = LlmCallResult(run_id="run-1", text="hello", usage={"total_tokens": 5}, duration_ms=12)
+ result = LLMCallResult(run_id="run-1", text="hello", usage={"total_tokens": 5}, duration_ms=12)
await hooks.after_llm_call(request(), result, state={})
assert observed == [result]
@@ -242,12 +250,12 @@ class TestBeforeLlmCallFinish:
async def test_finish_decision_short_circuits(self) -> None:
class Limit:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallDecision:
- return LlmCallDecision.finish("call budget exhausted")
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallDecision:
+ return LLMCallDecision.finish("call budget exhausted")
class Later:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> None:
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> None:
raise AssertionError("must not run after finish")
hooks = make_hooks(Later(), Limit()) # LIFO: Limit runs first
@@ -259,92 +267,135 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> None:
class TestModelRunnerHookIntegration:
"""Regression tests for PR #255 review findings (effective request, exactly-once)."""
- def _runner_and_tape(self, hooks: AgentHooks, captured: dict):
- import bub
- from bub.builtin.model_runner import ModelRunner
- from bub.builtin.settings import AgentSettings
- from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore
- from bub.tape import Tape, TapeContext
-
- class FakeRunner(ModelRunner):
- async def completion_response(self, *, model, messages, tools, max_tokens=None, reasoning_effort=None):
- captured.update(model=model, max_tokens=max_tokens)
-
- async def chunks():
- return
- yield # pragma: no cover
-
- return chunks()
-
- settings = AgentSettings.model_construct(model="openai:orig", max_tokens=100, model_timeout_seconds=None)
- runner = FakeRunner(settings, hooks=hooks)
- store = AsyncTapeStoreAdapter(InMemoryTapeStore())
- tape = Tape(bub.home / "tapes", store, TapeContext(anchor=None)).scoped("t1")
+ def _runner_and_tape(self, hooks: PluginAgentHooks, tmp_path: Path, api_format: str | None = None):
+ runner = StepRunner(
+ AgentSettings(
+ model="openai:orig", max_tokens=100, client_args={"api_format": api_format} if api_format else {}
+ ),
+ hooks=hooks,
+ )
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("t1")
return runner, tape
@pytest.mark.asyncio
- async def test_rewritten_model_and_max_tokens_reach_provider_and_tape(self) -> None:
+ async def test_rewritten_model_and_max_tokens_reach_provider_and_tape(
+ self, tmp_path: Path, provider_transport: ProviderService
+ ) -> None:
class Reroute:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest:
+ def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest:
return replace(request, model="anthropic:new", max_tokens=42)
- captured: dict = {}
- runner, tape = self._runner_and_tape(make_hooks(Reroute()), captured)
- events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi")
+ provider_transport.reply(
+ sse([
+ {"type": "message_start", "message": {"id": "test", "usage": {}}},
+ {"type": "message_stop"},
+ ])
+ )
+ runner, tape = self._runner_and_tape(make_hooks(Reroute()), tmp_path)
+ events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi"))
async for _ in events:
pass
- assert captured == {"model": "anthropic:new", "max_tokens": 42}
+ assert provider_transport.body()["model"] == "new"
+ assert provider_transport.body()["max_tokens"] == 42
entries = list(await tape.store.fetch_all(tape.query().kinds("event")))
run_events = [e for e in entries if e.payload.get("name") == "run"]
assert run_events[-1].payload["data"]["model"] == "anthropic:new"
@pytest.mark.asyncio
- async def test_after_llm_call_not_fired_on_early_close(self) -> None:
- observed: list[LlmCallResult] = []
+ async def test_after_llm_call_not_fired_on_early_close(
+ self, tmp_path: Path, provider_transport: ProviderService
+ ) -> None:
+ observed: list[LLMCallResult] = []
class Observe:
@hookimpl
- def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None:
+ def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None:
observed.append(result)
- captured: dict = {}
- runner, tape = self._runner_and_tape(make_hooks(Observe()), captured)
-
- from bub.builtin.model_runner import ModelRunner # noqa: F401
+ provider_transport.reply_chat("done")
+ runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat")
- async def fake_events(completion, state, output):
- from bub.streaming import StreamEvent
-
- yield StreamEvent("text", {"delta": "a"})
- yield StreamEvent("text", {"delta": "b"})
-
- runner._completion_events = fake_events # type: ignore[method-assign]
- events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi")
+ events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi"))
iterator = events.__aiter__()
await iterator.__anext__()
await iterator.aclose()
- # Consumer close is intentionally NOT a terminal observation:
- # after_llm_call fires only for real completions and Exception failures.
assert observed == []
@pytest.mark.asyncio
- async def test_after_llm_call_fires_exactly_once_on_success(self) -> None:
- observed: list[LlmCallResult] = []
+ async def test_after_llm_call_fires_exactly_once_on_success(
+ self, tmp_path: Path, provider_transport: ProviderService
+ ) -> None:
+ observed: list[LLMCallResult] = []
class Observe:
@hookimpl
- def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None:
+ def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None:
observed.append(result)
- captured: dict = {}
- runner, tape = self._runner_and_tape(make_hooks(Observe()), captured)
- events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi")
+ provider_transport.reply_chat("done")
+ runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat")
+ events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi"))
async for _ in events:
pass
assert len(observed) == 1
assert observed[0].error is None
+ @pytest.mark.asyncio
+ async def test_failed_stream_reports_partial_text_once_without_trying_fallback(
+ self, tmp_path: Path, provider_transport: ProviderService
+ ) -> None:
+ observed: list[LLMCallResult] = []
+
+ class Observe:
+ @hookimpl
+ def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None:
+ observed.append(result)
+
+ provider_transport.reply(sse([{"choices": [{"delta": {"content": "partial"}}]}]))
+ runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat")
+ runner.settings.fallback_models = ["openai:fallback"]
+ events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi"))
+ with pytest.raises(StreamIncompleteError) as exc:
+ async for _ in events:
+ pass
+ assert len(observed) == 1
+ assert observed[0].text == "partial"
+ assert observed[0].error is exc.value
+ assert observed[0].usage is None
+ assert len(provider_transport.requests) == 1
+ assert not await tape.read_messages()
+
+ @pytest.mark.asyncio
+ async def test_refusal_text_reaches_the_hook_final_event_and_tape(
+ self, tmp_path: Path, provider_transport: ProviderService
+ ) -> None:
+ observed: list[LLMCallResult] = []
+
+ class Observe:
+ @hookimpl
+ def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None:
+ observed.append(result)
+
+ provider_transport.reply(
+ sse([
+ {"choices": [{"delta": {"refusal": "Cannot fulfill this request."}, "finish_reason": "stop"}]},
+ "[DONE]",
+ ])
+ )
+ runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat")
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")
+ )
+ ]
+ assert events[-1].data == {"ok": True, "text": "Cannot fulfill this request."}
+ assert len(observed) == 1
+ assert observed[0].text == "Cannot fulfill this request."
+ assert observed[0].error is None
+ assert (await tape.read_messages())[-1] == republic.assistant("Cannot fulfill this request.")
+
class TestToolCancellation:
@pytest.mark.asyncio
@@ -365,11 +416,9 @@ async def blocking(cmd: str) -> str:
await asyncio.Event().wait() # blocks until cancelled
return "unreachable"
- executor = ToolExecutor(hooks=make_hooks(Observe()))
+ executor = ToolExecutor(make_hooks(Observe()).subscribe())
task = asyncio.create_task(
- executor.execute_async([
- (Tool(name="block", handler=blocking, description="", parameters={}), {"cmd": "x"})
- ])
+ executor.execute_many([(Tool(name="block", handler=blocking, description="", parameters={}), {"cmd": "x"})])
)
await started.wait()
task.cancel()
diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py
index 519c1c66..e6b80e29 100644
--- a/tests/test_builtin_agent.py
+++ b/tests/test_builtin_agent.py
@@ -7,35 +7,36 @@
from unittest.mock import MagicMock, patch
import pytest
-from any_llm.types.completion import ChatCompletionChunk
+import republic
import bub.builtin.codemode
import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`)
-from bub.builtin.agent import Agent
-from bub.builtin.model_runner import ModelRunner
+from bub.builtin.agent import BubAgent
from bub.builtin.settings import AgentSettings
from bub.builtin.steering import InMemorySteeringInbox
-from bub.errors import BubError
-from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
-from bub.tape import TapeContext
-from bub.tools import REGISTRY, tool
+from bub.core import PromptContext
+from bub.core.context import default_tape_context
+from bub.core.events import StepFinished
+from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore
+from bub.core.tape import Tape, session_tape_name
+from bub.core.tools import REGISTRY, tool
+from bub.prompt import UserContent
+from bub.streaming import StreamEvent, StreamState
+from tests.model_fakes import ProviderService
# ---------------------------------------------------------------------------
# Agent.run() tests: merge_back logic and model passthrough
# ---------------------------------------------------------------------------
-class _FakeModelRunner(ModelRunner):
- def __init__(self, settings: AgentSettings) -> None:
- super().__init__(settings)
- self.completion_kwargs: dict[str, Any] | None = None
+@pytest.fixture(autouse=True)
+def model_provider(provider_transport: ProviderService) -> ProviderService:
+ provider_transport.reply_chat()
+ provider_transport.reply_chat()
+ return provider_transport
- async def completion_response(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]:
- self.completion_kwargs = kwargs
- return _chat_stream("done")
-
-def _make_agent() -> Agent:
+def _make_agent() -> BubAgent:
"""Build an Agent with a mocked framework, bypassing real LLM/tape init."""
framework = MagicMock()
framework.get_tape_store.return_value = None
@@ -43,141 +44,49 @@ def _make_agent() -> Agent:
framework.get_system_prompt.return_value = ""
framework.get_tape_sidecars.return_value = ()
- async def build_prompt(message: dict[str, Any], session_id: str, state: dict[str, Any]) -> str:
- return str(message["content"])
+ async def build_prompt(message: dict[str, Any], session_id: str, state: dict[str, Any]) -> list[UserContent]:
+ return [str(message["content"])]
framework.build_prompt = build_prompt
- with patch.object(Agent, "__init__", lambda self, fw: None):
- agent = Agent.__new__(Agent)
-
- agent.settings = AgentSettings.model_construct(model="test:model", api_key="k", api_base="b", client_args={})
- agent.command_prefix = agent.settings.command_prefix
- agent.framework = framework
- agent.tools = REGISTRY.copy()
- agent.tape_store = None
- agent.skill_dirs = None
- agent.model_runner = _FakeModelRunner(agent.settings)
+ settings = AgentSettings.model_construct(
+ model="openai:model", api_key="k", api_base=None, client_args={"api_format": "chat"}
+ )
+ with patch("bub.builtin.agent.load_settings", return_value=settings):
+ agent = BubAgent(framework)
+ agent.__dict__["_tapes"] = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context())
return agent
-def _model_runner(agent: Agent) -> _FakeModelRunner:
- assert isinstance(agent.model_runner, _FakeModelRunner)
- return agent.model_runner
-
-
-def _chat_chunk(content: str) -> ChatCompletionChunk:
- return ChatCompletionChunk.model_validate({
- "id": "chatcmpl_test",
- "object": "chat.completion.chunk",
- "created": 0,
- "model": "test:model",
- "choices": [
- {
- "index": 0,
- "finish_reason": "stop",
- "delta": {"role": "assistant", "content": content},
- }
- ],
- })
-
-
-async def _chat_stream(content: str) -> AsyncIterator[ChatCompletionChunk]:
- yield _chat_chunk(content)
-
-
class _ForkCapture:
- """Captures fork_tape enter and exit behavior."""
+ """Records how session tapes are forked and when the forks close."""
- def __init__(self) -> None:
+ def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None:
self.merge_back_values: list[bool] = []
self.exit_count = 0
+ fork_tape = Tape.fork_tape
+
+ @contextlib.asynccontextmanager
+ async def capture(tape: Tape, merge_back: bool = True) -> AsyncGenerator[Tape, None]:
+ self.merge_back_values.append(merge_back)
+ try:
+ async with fork_tape(tape, merge_back=merge_back) as forked:
+ yield forked
+ finally:
+ self.exit_count += 1
+
+ monkeypatch.setattr(Tape, "fork_tape", capture)
- @contextlib.asynccontextmanager
- async def fork_tape(self, tape_name: str, merge_back: bool = True) -> AsyncGenerator[None, None]:
- self.merge_back_values.append(merge_back)
- try:
- yield
- finally:
- self.exit_count += 1
-
-
-class _FakeTape:
- """Scoped tape stand-in for testing Agent.run()."""
-
- def __init__(self, fork_capture: _ForkCapture) -> None:
- self._fork = fork_capture
- self.name = "test-tape"
- self.context = TapeContext(state={})
- self.messages: list[dict[str, Any]] = []
- self.events: list[tuple[str, str, dict[str, Any]]] = []
-
- async def ensure_bootstrap_anchor(self) -> None:
- pass
-
- @contextlib.asynccontextmanager
- async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[_FakeTape, None]:
- async with self._fork.fork_tape(self.name, merge_back=merge_back):
- yield self
-
- async def read_messages(self) -> list[dict[str, Any]]:
- return list(self.messages)
-
- async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None:
- self.events.append((self.name, name, payload))
-
- async def record_chat(
- self,
- *,
- run_id: str,
- system_prompt: str | None,
- new_messages: list[dict[str, Any]],
- response_text: str | None,
- context_error: BubError | None = None,
- tool_calls: list[dict[str, Any]] | None = None,
- tool_results: list[Any] | None = None,
- error: BubError | None = None,
- response: Any | None = None,
- provider: str | None = None,
- model: str | None = None,
- usage: dict[str, Any] | None = None,
- ) -> None:
- if system_prompt:
- self.events.append((self.name, "system", {"content": system_prompt}))
- if context_error is not None:
- self.events.append((self.name, "error", context_error.as_dict()))
- self.messages.extend(new_messages)
- if tool_calls:
- self.events.append((self.name, "tool_call", {"calls": tool_calls}))
- if tool_results is not None:
- self.events.append((self.name, "tool_result", {"results": tool_results}))
- if error is not None and error is not context_error:
- self.events.append((self.name, "error", error.as_dict()))
- if response_text is not None:
- self.messages.append({"role": "assistant", "content": response_text})
- self.events.append((self.name, "run", {"run_id": run_id, "model": model, "error": error is not None}))
-
-
-class _FakeTapeFactory:
- """Minimal tape factory stand-in for testing Agent.run()."""
-
- def __init__(self, fork_capture: _ForkCapture) -> None:
- self.tape = _FakeTape(fork_capture)
- self.context = self.tape.context
-
- def session_tape(self, session_id: str, workspace: Any, context: TapeContext | None = None) -> _FakeTape:
- if context is not None:
- self.tape.context = context
- self.context = context
- return self.tape
+
+@pytest.fixture
+def fork_capture(monkeypatch: pytest.MonkeyPatch) -> _ForkCapture:
+ return _ForkCapture(monkeypatch)
@pytest.mark.asyncio
-async def test_agent_run_regular_session_merges_back() -> None:
+async def test_agent_run_regular_session_merges_back(fork_capture: _ForkCapture) -> None:
"""A regular (non-temp) session should merge tape entries back."""
agent = _make_agent()
- fork_capture = _ForkCapture()
- agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment]
result = await agent.run_stream(session_id="user/session1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108
@@ -191,11 +100,9 @@ async def test_agent_run_regular_session_merges_back() -> None:
@pytest.mark.asyncio
-async def test_agent_run_temp_session_does_not_merge_back() -> None:
+async def test_agent_run_temp_session_does_not_merge_back(fork_capture: _ForkCapture) -> None:
"""A temp/ session should NOT merge tape entries back."""
agent = _make_agent()
- fork_capture = _ForkCapture()
- agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment]
result = await agent.run_stream(session_id="temp/abc123", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108
@@ -209,12 +116,11 @@ async def test_agent_run_temp_session_does_not_merge_back() -> None:
@pytest.mark.asyncio
-async def test_agent_run_passes_model_to_llm() -> None:
- """The model parameter should be forwarded to any-llm."""
+async def test_agent_run_passes_model_to_llm(
+ model_provider: ProviderService,
+) -> None:
+ """The model parameter should be forwarded to Republic."""
agent = _make_agent()
- fork_capture = _ForkCapture()
- fake_tapes = _FakeTapeFactory(fork_capture)
- agent.tape = fake_tapes # type: ignore[assignment]
result = await agent.run_stream(
session_id="user/s1",
@@ -224,15 +130,14 @@ async def test_agent_run_passes_model_to_llm() -> None:
)
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
- assert completion_kwargs["model"] == "openai:gpt-4o"
+ assert completion_kwargs["model"] == "gpt-4o"
@pytest.mark.asyncio
async def test_agent_run_empty_prompt_returns_error() -> None:
agent = _make_agent()
- agent.tape = MagicMock()
result = await agent.run_stream(session_id="user/s1", prompt="", state={})
events = [event async for event in result]
@@ -244,75 +149,65 @@ async def test_agent_run_empty_prompt_returns_error() -> None:
@pytest.mark.asyncio
-async def test_agent_run_model_defaults_to_none() -> None:
- """When model is not specified, settings.model is used for any-llm."""
+async def test_agent_run_model_defaults_to_none(
+ model_provider: ProviderService,
+) -> None:
+ """When model is not specified, settings.model is used for Republic."""
agent = _make_agent()
- fork_capture = _ForkCapture()
- fake_tapes = _FakeTapeFactory(fork_capture)
- agent.tape = fake_tapes # type: ignore[assignment]
result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
- assert completion_kwargs["model"] == "test:model"
+ assert completion_kwargs["model"] == "model"
@pytest.mark.asyncio
@pytest.mark.parametrize("continuation", ["custom continuation", None])
async def test_agent_loop_awaits_continue_prompt_hook_with_stream_state(continuation: str | None) -> None:
agent = _make_agent()
- tape = _FakeTape(_ForkCapture())
- prompts: list[str | list[dict] | None] = []
- continuation_prompts: list[str | list[dict] | None] = []
+ tape = agent.tape("continuation")
+ prompts: list[list[UserContent] | None] = []
+ continuation_prompts: list[list[UserContent] | None] = []
observed_usage: list[dict[str, Any] | None] = []
- async def run_once(**kwargs: Any) -> AsyncStreamEvents:
- prompts.append(kwargs["prompt"])
- should_continue = len(prompts) == 1
-
- async def iterator() -> AsyncIterator[StreamEvent]:
- yield StreamEvent("final", {"tool_calls": ["call"] if should_continue else []})
-
- return AsyncStreamEvents(iterator(), state=StreamState(usage={"step": len(prompts)}))
+ async def run_once(
+ prompt: list[UserContent] | None, *, model: Any, tape: Tape, state: StreamState, **_: Any
+ ) -> AsyncIterator[StreamEvent]:
+ prompts.append(prompt)
+ state.usage = {"step": len(prompts)}
+ yield StreamEvent("final", {"tool_calls": ["call"] if len(prompts) == 1 else []})
- async def continue_prompt(*, prompt: str | list[dict] | None, tape: _FakeTape, state: StreamState) -> str | None:
+ async def continue_prompt(*, prompt: list[UserContent] | None, tape: Tape, state: StreamState) -> str | None:
continuation_prompts.append(prompt)
observed_usage.append(state.usage)
return continuation
- agent._run_once = run_once # type: ignore[method-assign]
agent.framework.continue_prompt = continue_prompt
+ agent.run_once = run_once # type: ignore[method-assign]
- events = [
- event
- async for event in agent._stream_events_with_auto_handoff(
- tape=tape, # type: ignore[arg-type]
- prompt="initial prompt",
- state=StreamState(),
- )
- ]
+ events = [event async for event in await agent.run_on(tape, ["initial prompt"], model=agent.settings.chat_model())]
assert [event.kind for event in events] == ["final", "final"]
- assert prompts == ["initial prompt", continuation]
- assert continuation_prompts == ["initial prompt"]
+ assert prompts == [["initial prompt"], None if continuation is None else [continuation]]
+ assert continuation_prompts == [["initial prompt"]]
assert observed_usage == [{"step": 1}]
@pytest.mark.asyncio
-async def test_agent_run_model_override_does_not_mutate_default() -> None:
+async def test_agent_run_model_override_does_not_mutate_default(
+ model_provider: ProviderService,
+) -> None:
"""A per-call model override must not leak into the agent's configured model.
The override is resolved per turn (``model or self.settings.model``) and
- forwarded to any-llm; it must never be written back to ``settings.model``.
+ forwarded to Republic; it must never be written back to ``settings.model``.
This is the agent-layer half of the guarantee that a session-scoped model
switch (state['model'] -> run_stream(model=...)) cannot bleed across
sessions the way a process-global env var would.
"""
agent = _make_agent()
- fork_capture = _ForkCapture()
- agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment]
default_model = agent.settings.model
result = await agent.run_stream(
@@ -323,18 +218,17 @@ async def test_agent_run_model_override_does_not_mutate_default() -> None:
)
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
- assert completion_kwargs["model"] == "openai:gpt-4o"
+ assert completion_kwargs["model"] == "gpt-4o"
assert agent.settings.model == default_model
@pytest.mark.asyncio
-async def test_agent_run_injects_steering_messages_once_by_session() -> None:
+async def test_agent_run_injects_steering_messages_once_by_session(
+ model_provider: ProviderService,
+) -> None:
agent = _make_agent()
- fork_capture = _ForkCapture()
- fake_tapes = _FakeTapeFactory(fork_capture)
- agent.tape = fake_tapes # type: ignore[assignment]
steering_inbox = InMemorySteeringInbox()
agent.framework.get_steering_inbox.return_value = steering_inbox
@@ -349,7 +243,7 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None:
result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
completion_messages = completion_kwargs["messages"]
assert completion_messages[-3:] == [
@@ -357,17 +251,18 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None:
{"role": "user", "content": "second steer"},
{"role": "user", "content": "hello"},
]
- assert fake_tapes.tape.messages == [
- {"role": "user", "content": "first steer"},
- {"role": "user", "content": "second steer"},
- {"role": "user", "content": "hello"},
- {"role": "assistant", "content": "done"},
+ messages = await agent.tape(session_tape_name("user/s1", Path("/tmp"))).read_messages() # noqa: S108
+ assert messages[-4:] == [
+ republic.user("first steer"),
+ republic.user("second steer"),
+ republic.user("hello"),
+ republic.assistant("done"),
]
result = await agent.run_stream(session_id="user/s1", prompt="again", state={"_runtime_workspace": "/tmp"}) # noqa: S108
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
completion_messages = completion_kwargs["messages"]
assert completion_messages[-1] == {"role": "user", "content": "again"}
@@ -375,7 +270,9 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None:
@pytest.mark.asyncio
-async def test_agent_run_resolves_allowed_tool_aliases_and_limits_prompt() -> None:
+async def test_agent_run_resolves_allowed_tool_aliases_and_limits_prompt(
+ model_provider: ProviderService,
+) -> None:
allowed_name = "tests.allowed_agent_tool"
denied_name = "tests.denied_agent_tool"
REGISTRY.pop(allowed_name, None)
@@ -390,9 +287,6 @@ def denied_agent_tool() -> str:
return "denied"
agent = _make_agent()
- fork_capture = _ForkCapture()
- fake_tapes = _FakeTapeFactory(fork_capture)
- agent.tape = fake_tapes # type: ignore[assignment]
result = await agent.run_stream(
session_id="user/s1",
@@ -402,16 +296,18 @@ def denied_agent_tool() -> str:
)
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
- assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_allowed_agent_tool"]
+ assert [tool["function"]["name"] for tool in completion_kwargs["tools"]] == ["tests_allowed_agent_tool"]
system_prompt = completion_kwargs["messages"][0]["content"]
assert "tests_allowed_agent_tool" not in system_prompt
assert "tests_denied_agent_tool" not in system_prompt
@pytest.mark.asyncio
-async def test_agent_run_excludes_command_tools() -> None:
+async def test_agent_run_excludes_command_tools(
+ model_provider: ProviderService,
+) -> None:
visible_name = "tests.visible_agent_tool"
internal_name = "tests.internal_agent_tool"
REGISTRY.pop(visible_name, None)
@@ -426,8 +322,6 @@ def internal_agent_tool() -> str:
return "internal"
agent = _make_agent()
- fork_capture = _ForkCapture()
- agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment]
result = await agent.run_stream(
session_id="user/s1",
@@ -437,37 +331,32 @@ def internal_agent_tool() -> str:
)
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
- assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_visible_agent_tool"]
+ assert [tool["function"]["name"] for tool in completion_kwargs["tools"]] == ["tests_visible_agent_tool"]
system_prompt = completion_kwargs["messages"][0]["content"]
assert "tests_visible_agent_tool" not in system_prompt
assert "tests_internal_agent_tool" not in system_prompt
@pytest.mark.asyncio
-async def test_agent_run_rejects_unknown_allowed_tools() -> None:
+async def test_agent_run_rejects_unknown_allowed_tools(fork_capture: _ForkCapture) -> None:
agent = _make_agent()
- fork_capture = _ForkCapture()
- fake_tapes = _FakeTapeFactory(fork_capture)
- agent.tape = fake_tapes # type: ignore[assignment]
-
- stream = await agent.run_stream(
- session_id="user/s1",
- prompt="hello",
- state={"_runtime_workspace": "/tmp"}, # noqa: S108
- allowed_tools=["tests_missing_agent_tool"],
- )
with pytest.raises(ValueError, match="tests_missing_agent_tool"):
- [event async for event in stream]
+ await agent.run_stream(
+ session_id="user/s1",
+ prompt="hello",
+ state={"_runtime_workspace": "/tmp"}, # noqa: S108
+ allowed_tools=["tests_missing_agent_tool"],
+ )
+ assert fork_capture.merge_back_values == []
@pytest.mark.asyncio
@pytest.mark.parametrize("prompt", [",bash command='echo hello'", ",echo hello"])
async def test_run_command_executes_shell_with_command_parameter(prompt: str, tmp_path) -> None:
agent = _make_agent()
- agent.tape = _FakeTapeFactory(_ForkCapture()) # type: ignore[assignment]
stream = await agent.run_stream(session_id="user/s1", prompt=prompt, state={"_runtime_workspace": str(tmp_path)})
events = [event async for event in stream]
@@ -486,8 +375,6 @@ async def test_run_command_model_switches_session_model_directly() -> None:
session tape (merged back at end of turn), not a side effect of save_state.
"""
agent = _make_agent()
- fork_capture = _ForkCapture()
- agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment]
state: dict[str, Any] = {"_runtime_workspace": "/tmp"} # noqa: S108
assert "model" not in REGISTRY or REGISTRY["model"].context is True
@@ -503,23 +390,22 @@ async def test_run_command_model_switches_session_model_directly() -> None:
@pytest.mark.asyncio
@pytest.mark.parametrize("allow_run_code", [True, False])
async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, allow_run_code: bool
+ model_provider: ProviderService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, allow_run_code: bool
) -> None:
from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, RUN_CODE_TOOL_NAME, run_code
monkeypatch.setenv("BUB_HOME", str(tmp_path / "home"))
agent = _make_agent()
agent.tools[RUN_CODE_TOOL_NAME] = run_code
- agent.tape = _FakeTapeFactory(_ForkCapture()) # type: ignore[assignment]
allowed_tools = ["bash", "bash.output", "fs.read", "tape.info", *([RUN_CODE_TOOL_NAME] if allow_run_code else [])]
state: dict[str, Any] = {"_runtime_workspace": str(tmp_path), "code_mode": True}
result = await agent.run_stream(session_id="user/s1", prompt="hello", state=state, allowed_tools=allowed_tools)
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
- tool_names = [tool.name for tool in completion_kwargs["tools"]]
+ tool_names = [tool["function"]["name"] for tool in completion_kwargs["tools"]]
system_prompt = completion_kwargs["messages"][0]["content"]
if not allow_run_code:
assert sorted(tool_names) == ["bash", "bash_output", "fs_read", "tape_info"]
@@ -541,20 +427,21 @@ async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub(
@pytest.mark.asyncio
-async def test_run_code_is_hidden_when_session_code_mode_is_off(tmp_path: Path) -> None:
+async def test_run_code_is_hidden_when_session_code_mode_is_off(
+ model_provider: ProviderService, tmp_path: Path
+) -> None:
from bub.builtin.codemode import RUN_CODE_TOOL_NAME, run_code
agent = _make_agent()
agent.tools[RUN_CODE_TOOL_NAME] = run_code
- agent.tape = _FakeTapeFactory(_ForkCapture()) # type: ignore[assignment]
result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": str(tmp_path)})
[event async for event in result]
- completion_kwargs = _model_runner(agent).completion_kwargs
+ completion_kwargs = model_provider.body()
assert completion_kwargs is not None
- assert RUN_CODE_TOOL_NAME not in [tool.name for tool in completion_kwargs["tools"]]
- assert "tape_info" in [tool.name for tool in completion_kwargs["tools"]]
+ assert RUN_CODE_TOOL_NAME not in [tool["function"]["name"] for tool in completion_kwargs["tools"]]
+ assert "tape_info" in [tool["function"]["name"] for tool in completion_kwargs["tools"]]
assert "" not in completion_kwargs["messages"][0]["content"]
@@ -562,7 +449,6 @@ async def test_run_code_is_hidden_when_session_code_mode_is_off(tmp_path: Path)
@pytest.mark.parametrize(("command", "expected"), [(",code_mode enable=true", True), (",code_mode false", False)])
async def test_code_mode_command_switches_session_code_mode(command: str, expected: bool) -> None:
agent = _make_agent()
- agent.tape = _FakeTapeFactory(_ForkCapture()) # type: ignore[assignment]
state: dict[str, Any] = {"_runtime_workspace": "/tmp"} # noqa: S108
stream = await agent.run_stream(session_id="user/s1", prompt=command, state=state)
@@ -572,3 +458,28 @@ async def test_code_mode_command_switches_session_code_mode(command: str, expect
deltas = [event.data.get("delta", "") for event in events if event.kind == "text"]
status = "enabled" if expected else "disabled"
assert f"Session code mode {status} (applies from the next turn)." in deltas
+
+
+@pytest.mark.asyncio
+async def test_agent_sections_and_handlers_apply_after_builtin_ones(model_provider: ProviderService) -> None:
+ agent = _make_agent()
+ agent.skill_dirs = []
+ agent.framework.get_system_prompt.return_value = ""
+ statuses: list[str] = []
+
+ @agent.system_prompt.add
+ def session(context: PromptContext) -> str:
+ return f"{context.state['session_id']}:{len(context.tools)}"
+
+ @agent.events.on(StepFinished)
+ def record(event: StepFinished) -> None:
+ statuses.append(event.status)
+
+ result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108
+ [event async for event in result]
+
+ request = model_provider.body()
+ system_prompt = request["messages"][0]["content"]
+ assert system_prompt.endswith(f"user/s1:{len(request['tools'])}")
+ assert system_prompt.startswith("\n\n")
+ assert statuses == ["ok"]
diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py
index 6ab2a7f1..a058bba0 100644
--- a/tests/test_builtin_cli.py
+++ b/tests/test_builtin_cli.py
@@ -2,6 +2,9 @@
import json
import os
+import re
+import sys
+from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from typing import Any
@@ -11,9 +14,9 @@
import typer
from inquirer_textual.common.InquirerResult import InquirerResult
from inquirer_textual.common.PromptSettings import PromptSettings
+from typer.main import get_command
from typer.testing import CliRunner
-import bub.builtin.auth as auth
import bub.builtin.cli as cli
import bub.builtin.onboarding as onboarding
import bub.configure as configure
@@ -21,14 +24,23 @@
from bub.framework import BubFramework
from bub.hooks import hookimpl
-TEST_ACCESS_TOKEN = "access" # noqa: S105
-TEST_REFRESH_TOKEN = "refresh" # noqa: S105
-
@pytest.fixture(autouse=True)
def no_model_discovery_network(monkeypatch):
monkeypatch.setattr(onboarding, "discover_models", lambda *args, **kwargs: [])
- monkeypatch.setattr("bub.builtin.codex_provider.load_openai_codex_oauth_tokens", lambda: None)
+
+
+def _terminal_output(monkeypatch: pytest.MonkeyPatch) -> StringIO:
+ output = StringIO()
+ monkeypatch.setattr(output, "isatty", lambda: True)
+ monkeypatch.setattr(sys, "stdout", output)
+ monkeypatch.setattr(sys, "stderr", output)
+ return output
+
+
+def _program_status(output: StringIO) -> dict[str, str]:
+ frames = re.findall(r"\x1b]7501;([^\x1b\x07]*)(?:\x1b\\|\x07)", output.getvalue())
+ return dict(pair.split("=", 1) for pair in frames[-1].split(":"))
def _fake_result(answer: Any, command: str | None = "enter") -> InquirerResult[Any]:
@@ -105,7 +117,7 @@ def onboard_config(self, current_config):
monkeypatch.setattr(
bub_inquirer,
"ask_confirm",
- lambda message, default=False: default,
+ lambda message, default=False, **kwargs: default,
)
monkeypatch.setattr(
bub_inquirer,
@@ -158,7 +170,7 @@ def test_onboard_collects_builtin_runtime_config(tmp_path: Path, monkeypatch) ->
monkeypatch.setattr(
bub_inquirer,
"ask_confirm",
- lambda message, default=False: message == "Stream output",
+ lambda message, default=False, **kwargs: message == "Stream output",
)
monkeypatch.setattr(
bub_inquirer,
@@ -216,6 +228,7 @@ def ask_confirm(message: str, default: bool = False) -> bool:
def test_onboard_can_install_gateway_after_saving_config(tmp_path: Path, monkeypatch) -> None:
+ terminal_output = _terminal_output(monkeypatch)
config_file = tmp_path / "config.yml"
framework = BubFramework(config_file=config_file)
framework.load_hooks()
@@ -229,13 +242,15 @@ def test_onboard_can_install_gateway_after_saving_config(tmp_path: Path, monkeyp
)
monkeypatch.setattr("bub.gateway_installer.is_gateway_service_supported", lambda: True)
- def confirm_install(message: str, default: bool = False) -> bool:
+ def confirm_install(message: str, default: bool = False) -> InquirerResult[bool]:
assert message == "Install gateway as a background service?"
assert default is False
assert configure.load(config_file)["enabled_channels"] == "telegram"
- return True
+ current = _program_status(terminal_output)
+ assert current["state"] == "blocked" and current["kind"] == "permission"
+ return _fake_result(True)
- monkeypatch.setattr(bub_inquirer, "ask_confirm", confirm_install)
+ monkeypatch.setattr(bub_inquirer.prompts, "confirm", confirm_install)
def install_gateway(workspace: Path, channels: list[str]) -> SimpleNamespace:
observed["workspace"] = workspace
@@ -244,38 +259,44 @@ def install_gateway(workspace: Path, channels: list[str]) -> SimpleNamespace:
monkeypatch.setattr("bub.gateway_installer.install_gateway", install_gateway)
- result = CliRunner().invoke(app, ["--workspace", os.fspath(tmp_path), "onboard"])
+ with pytest.raises(SystemExit) as stopped:
+ get_command(app).main(["--workspace", os.fspath(tmp_path), "onboard"])
- assert result.exit_code == 0
- assert f"Saved config to {config_file.resolve()}" in result.output
- assert "Installed and started the gateway with systemd" in result.output
+ assert stopped.value.code == 0
+ assert f"Saved config to {config_file.resolve()}" in terminal_output.getvalue()
+ assert "Installed and started the gateway with systemd" in terminal_output.getvalue()
+ assert _program_status(terminal_output)["state"] == "done"
assert observed == {"workspace": tmp_path.resolve(), "channels": []}
def test_onboard_keeps_saved_config_when_gateway_installation_fails(tmp_path: Path, monkeypatch) -> None:
from bub.gateway_installer import GatewayServiceError
+ terminal_output = _terminal_output(monkeypatch)
config_file = tmp_path / "config.yml"
framework = BubFramework(config_file=config_file)
framework.load_hooks()
app = framework.create_cli_app()
monkeypatch.setattr(framework, "collect_onboard_config", lambda *_: {"enabled_channels": "telegram"})
monkeypatch.setattr("bub.gateway_installer.is_gateway_service_supported", lambda: True)
- monkeypatch.setattr(bub_inquirer, "ask_confirm", lambda message, default=False: True)
+ monkeypatch.setattr(bub_inquirer, "ask_confirm", lambda message, default=False, **kwargs: True)
def fail_install(workspace: Path, channels: list[str]) -> None:
raise GatewayServiceError("user service unavailable")
monkeypatch.setattr("bub.gateway_installer.install_gateway", fail_install)
- result = CliRunner().invoke(app, ["onboard"])
+ with pytest.raises(SystemExit) as stopped:
+ get_command(app).main(["onboard"])
- assert result.exit_code == 1
- assert "Configuration remains saved" in result.output
+ assert stopped.value.code == 1
+ assert "Configuration remains saved" in terminal_output.getvalue()
assert configure.load(config_file)["enabled_channels"] == "telegram"
+ assert _program_status(terminal_output)["state"] == "error"
def test_onboard_aborts_immediately_when_builtin_prompt_is_interrupted(tmp_path: Path, monkeypatch) -> None:
+ terminal_output = _terminal_output(monkeypatch)
config_file = tmp_path / "config.yml"
asked_messages: list[str] = []
@@ -322,10 +343,12 @@ def fake_secret(message: str) -> str:
monkeypatch.setattr(bub_inquirer, "ask_text", fake_text)
monkeypatch.setattr(bub_inquirer, "ask_secret", fake_secret)
- result = CliRunner().invoke(app, ["onboard"])
+ with pytest.raises(SystemExit) as stopped:
+ get_command(app).main(["onboard"])
- assert result.exit_code == 1
- assert _rendered_onboard_banner() in result.stdout
+ assert stopped.value.code == 1
+ assert _rendered_onboard_banner() in terminal_output.getvalue()
+ assert _program_status(terminal_output)["state"] == "idle"
assert asked_messages == [
"LLM provider",
"API key (optional)",
@@ -414,7 +437,7 @@ def test_run_help_uses_the_configured_prefix(
framework.workspace = tmp_path
framework.load_builtin_hooks()
monkeypatch.setattr(
- "bub.builtin.model_runner.ModelRunner.run",
+ "bub.core.agent.Agent.run_once",
lambda *args, **kwargs: pytest.fail("help must not invoke the model"),
)
@@ -514,7 +537,7 @@ def test_onboard_collects_builtin_runtime_config_with_custom_provider(tmp_path:
monkeypatch.setattr(
bub_inquirer,
"ask_confirm",
- lambda message, default=False: False,
+ lambda message, default=False, **kwargs: False,
)
monkeypatch.setattr(
bub_inquirer,
@@ -543,29 +566,19 @@ def test_onboard_collects_builtin_runtime_config_with_custom_provider(tmp_path:
}
-def test_login_openai_command_runs_codex_oauth(tmp_path: Path) -> None:
- tokens = auth.OpenAICodexOAuthTokens(
- access_token=TEST_ACCESS_TOKEN,
- refresh_token=TEST_REFRESH_TOKEN,
- expires_at=1_900_000_000,
- account_id="acct_123",
+def test_login_command_reports_the_authenticated_account(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, codex_executable: Path
+) -> None:
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path / "credentials"))
+ result = CliRunner().invoke(
+ _create_app(), ["login", "codex", "--executable", str(codex_executable), "--device-auth"]
)
- login = patch("bub.builtin.auth.login_openai_codex_oauth", return_value=tokens)
-
- with login as login_mock:
- result = CliRunner().invoke(
- _create_app(),
- ["login", "openai", "--codex-home", str(tmp_path), "--manual", "--no-browser"],
- )
-
- assert result.exit_code == 0
+ assert result.exit_code == 0, result.output
assert "login: ok" in result.stdout
- assert "account_id: acct_123" in result.stdout
- assert f"auth_file: {tmp_path / 'auth.json'}" in result.stdout
- login_mock.assert_called_once()
- assert login_mock.call_args.kwargs["codex_home"] == tmp_path
- assert login_mock.call_args.kwargs["open_browser"] is False
- assert login_mock.call_args.kwargs["prompt_for_redirect"] is auth._prompt_for_codex_redirect
+ assert "account_id: device-account" in result.stdout
+ assert "BUB_MODEL=codex:" in result.stdout
+ assert (tmp_path / "credentials" / "auth.json").exists()
+ assert "test-token" not in result.stdout
def test_login_rejects_unknown_provider() -> None:
@@ -648,7 +661,7 @@ def test_ensure_project_initializes_project_and_adds_bub_dependency(tmp_path: Pa
assert captured == [
(("init", "--bare", "--name", "bub-project", "--app"), project),
- (("add", "--active", "--no-sync", "--editable", "/tmp/bub"), project), # noqa: S108
+ (("add", "--active", "--no-sync", "--extra", "app", "--editable", "/tmp/bub"), project), # noqa: S108
]
diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py
index 38f28d1c..2400582d 100644
--- a/tests/test_builtin_codex.py
+++ b/tests/test_builtin_codex.py
@@ -1,605 +1,234 @@
from __future__ import annotations
-import base64
import json
-import time
-from datetime import UTC, datetime
+import os
+import sys
from pathlib import Path
-from types import SimpleNamespace
-from typing import Any
-from unittest.mock import AsyncMock, MagicMock
-import httpx
import pytest
-from any_llm.constants import LLMProvider
-from any_llm.types.completion import CompletionParams
-from any_llm.types.responses import ResponsesParams
-from openai.types.responses.response_input_param import ResponseInputParam
-from openai.types.responses.response_output_text import ResponseOutputText
-from pydantic import TypeAdapter, ValidationError
+from typer.testing import CliRunner
-from bub.builtin.auth import (
- CodexOAuthRefreshError,
- OpenAICodexOAuthTokens,
- extract_openai_codex_account_id,
- load_openai_codex_oauth_tokens,
- openai_codex_oauth_resolver,
- save_openai_codex_oauth_tokens,
-)
-from bub.builtin.codex_provider import (
- DEFAULT_CODEX_INCLUDE,
- DEFAULT_CODEX_INSTRUCTIONS,
- DEFAULT_CODEX_TEXT_CONFIG,
- OpenaiCodexProvider,
- build_openai_codex_default_headers,
- resolve_openai_codex_api_base,
- should_use_openai_codex_provider,
-)
-from bub.builtin.model_runner import ModelOutputAccumulator, ModelRunner
-from bub.builtin.settings import AgentSettings, CustomProvider, ModelCandidate
+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.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 bub.prompt import to_content
+from tests.model_fakes import ProviderService, StepRunner, sse
-TEST_REFRESH_TOKEN = "refresh" # noqa: S105
-TEST_REFRESH_TOKEN_OLD = "refresh_old" # noqa: S105
-TEST_REFRESH_TOKEN_NEW = "refresh_new" # noqa: S105
-
-def _jwt_with_account(account_id: str, *, exp: int | None = None) -> str:
- header = _b64({"alg": "none"})
- claims: dict[str, Any] = {"https://api.openai.com/auth": {"chatgpt_account_id": account_id}}
- if exp is not None:
- claims["exp"] = exp
- payload = _b64(claims)
- return f"{header}.{payload}.sig"
-
-
-def _b64(payload: dict[str, Any]) -> str:
- raw = json.dumps(payload, separators=(",", ":")).encode()
- return base64.urlsafe_b64encode(raw).decode().rstrip("=")
-
-
-def _write_codex_auth_without_expiry(home: Path, access_token: str, last_refresh: int) -> None:
- (home / "auth.json").write_text(
- json.dumps({
- "last_refresh": datetime.fromtimestamp(last_refresh, tz=UTC).isoformat(),
- "tokens": {"access_token": access_token, "refresh_token": TEST_REFRESH_TOKEN},
- })
- )
-
-
-def test_openai_codex_oauth_tokens_round_trip(tmp_path: Path) -> None:
- tokens = OpenAICodexOAuthTokens(
- access_token=_jwt_with_account("acct_123"),
- refresh_token=TEST_REFRESH_TOKEN,
- expires_at=1_900_000_000,
- account_id="acct_123",
- )
-
- auth_path = save_openai_codex_oauth_tokens(tokens, tmp_path)
- loaded = load_openai_codex_oauth_tokens(tmp_path)
-
- assert auth_path == tmp_path / "auth.json"
- assert loaded == tokens
- assert auth_path.stat().st_mode & 0o777 == 0o600
-
-
-def test_openai_codex_oauth_resolver_refreshes_expired_token(tmp_path: Path) -> None:
- save_openai_codex_oauth_tokens(
- OpenAICodexOAuthTokens(
- access_token=_jwt_with_account("acct_old"),
- refresh_token=TEST_REFRESH_TOKEN_OLD,
- expires_at=int(time.time()) - 1,
- account_id="acct_old",
- ),
- tmp_path,
- )
- refreshed = OpenAICodexOAuthTokens(
- access_token=_jwt_with_account("acct_new"),
- refresh_token=TEST_REFRESH_TOKEN_NEW,
- expires_at=int(time.time()) + 3600,
- account_id="acct_new",
- )
-
- resolver = openai_codex_oauth_resolver(tmp_path, refresher=lambda refresh_token: refreshed)
-
- assert resolver("openai") == refreshed.access_token
- assert load_openai_codex_oauth_tokens(tmp_path) == refreshed
-
-
-def test_codex_auth_without_expires_at_uses_access_token_jwt_exp(tmp_path: Path) -> None:
- now = int(time.time())
- token = _jwt_with_account("acct_123", exp=now + 10 * 86400)
- _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200)
- refresh_calls: list[str] = []
-
- def fail_refresh(refresh_token: str) -> OpenAICodexOAuthTokens:
- refresh_calls.append(refresh_token)
- raise RuntimeError("refresh should not run")
-
- loaded = load_openai_codex_oauth_tokens(tmp_path)
- assert loaded is not None
- assert loaded.expires_at == now + 10 * 86400
- assert openai_codex_oauth_resolver(tmp_path, refresher=fail_refresh)("openai") == token
- assert refresh_calls == []
-
-
-@pytest.mark.parametrize(
- "expiry_offset, refresh_duration, expected_token", [(60, 0, True), (-60, 0, False), (1, 2, False)]
-)
-def test_codex_provider_uses_valid_token_or_reports_refresh_failure(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, expiry_offset: int, refresh_duration: int, expected_token: bool
+@pytest.mark.asyncio
+@pytest.mark.parametrize("content_kind", ["string", "text", "image", "assistant-history"])
+async def test_codex_sends_content_with_file_credentials(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider_service: ProviderService, content_kind: str
) -> None:
- now = int(time.time())
- monkeypatch.setattr("bub.builtin.auth.time.time", lambda: now)
- token = _jwt_with_account("acct_123", exp=now + expiry_offset)
- _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200)
-
- def fail_refresh(refresh_token: str, **kwargs: Any) -> OpenAICodexOAuthTokens:
- monkeypatch.setattr("bub.builtin.auth.time.time", lambda: now + refresh_duration)
- raise RuntimeError("refresh unavailable")
-
- monkeypatch.setattr("bub.builtin.auth.refresh_openai_codex_oauth_tokens", fail_refresh)
- if expected_token:
- OpenaiCodexProvider(codex_home=str(tmp_path))
- else:
- with pytest.raises(CodexOAuthRefreshError, match="Codex OAuth"):
- OpenaiCodexProvider(codex_home=str(tmp_path))
-
-
-def test_codex_auth_explicit_expiry_precedes_jwt_exp_and_missing_claim_stays_unknown(tmp_path: Path) -> None:
- now = int(time.time())
- token = _jwt_with_account("acct_123", exp=now + 10 * 86400)
- _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200)
- auth_path = tmp_path / "auth.json"
- payload = json.loads(auth_path.read_text())
- payload["tokens"]["expires_at"] = now + 3600
- auth_path.write_text(json.dumps(payload))
- loaded = load_openai_codex_oauth_tokens(tmp_path)
- assert loaded is not None
- assert loaded.expires_at == now + 3600
-
- for fallback_token in (_jwt_with_account("acct_123"), "not-a-jwt"):
- _write_codex_auth_without_expiry(tmp_path, fallback_token, last_refresh=now - 7200)
- loaded = load_openai_codex_oauth_tokens(tmp_path)
- assert loaded is not None
- assert loaded.expires_at is None
- refresher = MagicMock(side_effect=RuntimeError("refresh should not run"))
- assert openai_codex_oauth_resolver(tmp_path, refresher=refresher)("openai") == fallback_token
- refresher.assert_not_called()
-
-
-def test_codex_auth_uses_valid_token_when_expiry_metadata_is_invalid(tmp_path: Path) -> None:
- now = int(time.time())
- token = _jwt_with_account("acct_123", exp=now + 86400)
- _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200)
- auth_path = tmp_path / "auth.json"
- payload = json.loads(auth_path.read_text())
- payload["tokens"]["expires_at"] = "invalid"
- auth_path.write_text(json.dumps(payload))
-
- refresher = MagicMock(side_effect=RuntimeError("refresh should not run"))
- assert openai_codex_oauth_resolver(tmp_path, refresher=refresher)("openai") == token
- refresher.assert_not_called()
-
-
-def test_extract_openai_codex_account_id() -> None:
- assert extract_openai_codex_account_id(_jwt_with_account("acct_123")) == "acct_123"
- assert extract_openai_codex_account_id("not-a-jwt") is None
-
-
-def test_codex_provider_selection_requires_oauth_file_or_oauth_token(monkeypatch) -> None:
- monkeypatch.setattr(
- "bub.builtin.codex_provider.load_openai_codex_oauth_tokens",
- lambda: OpenAICodexOAuthTokens(
- access_token=_jwt_with_account("acct_123"),
- refresh_token=TEST_REFRESH_TOKEN,
- expires_at=1_900_000_000,
- ),
- )
-
- assert should_use_openai_codex_provider("openai", "gpt-5.5", api_key=None, api_base=None) is True
- assert (
- should_use_openai_codex_provider("openai", "gpt-4o", api_key=_jwt_with_account("acct_123"), api_base=None)
- is True
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path))
+ (tmp_path / "auth.json").write_text(
+ json.dumps({"tokens": {"access_token": "test-token", "account_id": "test-account"}})
)
- assert should_use_openai_codex_provider("openai", "gpt-5-codex", api_key="sk-test", api_base=None) is False
- assert should_use_openai_codex_provider("openai", "gpt-5-codex", api_key=None, api_base="https://api.test") is False
-
-
-def test_codex_provider_selection_uses_normal_openai_without_oauth(monkeypatch) -> None:
- monkeypatch.setattr("bub.builtin.codex_provider.load_openai_codex_oauth_tokens", lambda: None)
-
- assert should_use_openai_codex_provider("openai", "gpt-5.5", api_key=None, api_base=None) is False
-
-
-def test_model_runner_creates_codex_provider_for_codex_model(monkeypatch) -> None:
- fake_provider = MagicMock()
- provider_class = MagicMock(return_value=fake_provider)
- monkeypatch.setattr("bub.builtin.model_runner.OpenaiCodexProvider", provider_class)
- monkeypatch.setattr(
- "bub.builtin.codex_provider.load_openai_codex_oauth_tokens",
- lambda: OpenAICodexOAuthTokens(
- access_token=_jwt_with_account("acct_123"),
- refresh_token=TEST_REFRESH_TOKEN,
- expires_at=1_900_000_000,
- ),
- )
- candidate = ModelCandidate(provider=LLMProvider.OPENAI, model_id="gpt-5.5", name="openai:gpt-5.5")
-
- client = ModelRunner.create_llm_client(candidate, {"api_key": None, "api_base": None})
-
- assert client is fake_provider
- provider_class.assert_called_once_with(api_key=None, api_base=None)
-
-
-def test_custom_openai_provider_leaves_codex_oauth_in_place(monkeypatch) -> None:
- codex_class = MagicMock(return_value="codex")
- create = MagicMock(return_value="relay")
- monkeypatch.setattr("bub.builtin.model_runner.OpenaiCodexProvider", codex_class)
- monkeypatch.setattr("bub.builtin.model_runner.AnyLLM.create", create)
- monkeypatch.setattr(
- "bub.builtin.codex_provider.load_openai_codex_oauth_tokens",
- lambda: OpenAICodexOAuthTokens(
- access_token=_jwt_with_account("acct_123"),
- refresh_token=TEST_REFRESH_TOKEN,
- expires_at=1_900_000_000,
- ),
- )
- settings = AgentSettings.model_construct(
- model="relay:qwen/kimi-k3",
- fallback_models=["openai:gpt-5.5"],
- api_key=None,
- api_base=None,
- client_args={},
- providers={"relay": CustomProvider(type=LLMProvider.OPENAI, api_base="https://relay.test/v1", api_key="k")},
- )
-
- clients = [client for _, client in ModelRunner(settings).iter_llm_clients("relay:qwen/kimi-k3")]
-
- assert clients == ["relay", "codex"]
- create.assert_called_once_with(LLMProvider.OPENAI, api_key="k", api_base="https://relay.test/v1")
- codex_class.assert_called_once_with(api_key=None, api_base=None)
-
-
-def test_codex_provider_adds_response_defaults() -> None:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"))
- params = ResponsesParams(model="gpt-5-codex", input="hello", stream=True, text={"format": {"type": "text"}})
-
- prepared = provider._with_codex_response_defaults(params)
-
- assert prepared.store is False
- assert prepared.instructions == DEFAULT_CODEX_INSTRUCTIONS
- assert prepared.include == DEFAULT_CODEX_INCLUDE
- assert prepared.text == {**DEFAULT_CODEX_TEXT_CONFIG, "format": {"type": "text"}}
-
-
-def test_codex_provider_preserves_explicit_response_options() -> None:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"))
- params = ResponsesParams(
- model="gpt-5-codex",
- input="hello",
- instructions="custom",
- include=[],
- store=True,
- text={"verbosity": "low"},
- )
-
- prepared = provider._with_codex_response_defaults(params)
-
- assert prepared.store is True
- assert prepared.instructions == "custom"
- assert prepared.include == []
- assert prepared.text == {**DEFAULT_CODEX_TEXT_CONFIG, "verbosity": "low"}
-
-
-def test_codex_completion_params_use_official_responses_payload_fields() -> None:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"))
- params = CompletionParams(
- model_id="gpt-5.5",
- messages=[{"role": "user", "content": "hello"}],
- max_tokens=100,
- temperature=0.2,
- top_p=0.9,
- presence_penalty=0.1,
- frequency_penalty=0.1,
- user="user_123",
- stream=True,
- stream_options={"include_usage": True},
- )
-
- responses_params = provider._completion_params_to_responses_params(params)
- payload = responses_params.model_dump(exclude_none=True, exclude={"response_format"})
-
- assert payload == {
- "model": "gpt-5.5",
- "input": [{"role": "user", "content": "hello"}],
- "stream": True,
- }
-
-
-def test_codex_completion_params_convert_chat_tool_messages_to_responses_items() -> None:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"))
- params = CompletionParams(
- model_id="gpt-5.5",
- messages=[
- {"role": "user", "content": "run bash"},
- {
- "role": "assistant",
- "content": "",
- "tool_calls": [
- {
- "id": "call_1",
- "type": "function",
- "function": {"name": "bash", "arguments": '{"command":"pwd"}'},
- }
- ],
- },
- {"role": "tool", "tool_call_id": "call_1", "name": "bash", "content": "workspace"},
- ],
- stream=True,
- )
-
- responses_params = provider._completion_params_to_responses_params(params)
- payload = responses_params.model_dump(exclude_none=True, exclude={"response_format"})
-
- assert payload == {
- "model": "gpt-5.5",
- "input": [
- {"role": "user", "content": "run bash"},
- {
- "type": "function_call",
- "call_id": "call_1",
- "name": "bash",
- "arguments": '{"command":"pwd"}',
- "status": "completed",
- },
- {"type": "function_call_output", "call_id": "call_1", "output": "workspace"},
- ],
- "stream": True,
- }
-
-
-def test_codex_provider_resolves_codex_api_base_and_headers() -> None:
- token = _jwt_with_account("acct_123")
-
- assert resolve_openai_codex_api_base(None) == "https://chatgpt.com/backend-api/codex"
- assert resolve_openai_codex_api_base("https://example.test/responses") == "https://example.test/codex"
- assert build_openai_codex_default_headers(token) == {
- "chatgpt-account-id": "acct_123",
- "OpenAI-Beta": "responses=experimental",
- "originator": "bub",
- }
-
-
-def _validate_codex_input(items: list[dict[str, Any]]) -> None:
- for item in items:
- if item.get("role") == "assistant":
- # Assistant history requires output_text, as the real endpoint does.
- for part in item["content"]:
- ResponseOutputText.model_validate({"annotations": [], **part})
- else:
- TypeAdapter(ResponseInputParam).validate_python([item])
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize("content_kind", ["string", "text", "image", "file", "assistant-history"])
-async def test_codex_completion_accepts_chat_content_without_bad_request(content_kind: str, tmp_path: Path) -> None:
framework = BubFramework(config_file=tmp_path / "config.yml")
framework.load_builtin_hooks()
- message = ChannelMessage(session_id="content-test", channel="cli", content="Reply exactly BUB_READY.")
+ message = ChannelMessage(session_id="content", channel="cli", content="Reply BUB_READY.")
if content_kind == "image":
- message.media = [MediaItem(type="image", mime_type="image/png", url="https://example.test/image.png")]
- content = await framework.build_prompt(message, message.session_id, {})
+ message.media = [MediaItem("image", "image/png", url="https://example.test/image.png")]
+ prompt = await framework.build_prompt(message, message.session_id, {})
if content_kind == "text":
- content = [{"type": "text", "text": message.content}]
- elif content_kind == "file":
- content = [{"type": "text", "text": message.content}, {"type": "file", "file": {"file_id": "file-test"}}]
- messages = [{"role": "user", "content": content}]
- if content_kind == "assistant-history":
- messages = [
- {
- "role": "assistant",
- "content": [{"type": "text", "text": "Checking the previous request."}],
- "tool_calls": [
- {"id": "call_test", "type": "function", "function": {"name": "check", "arguments": "{}"}}
- ],
- },
- {"role": "tool", "tool_call_id": "call_test", "content": "Ready"},
- *messages,
- ]
-
- def respond(request: httpx.Request) -> httpx.Response:
- payload = json.loads(request.content)
- try:
- _validate_codex_input(payload["input"])
- except ValidationError:
- # Reproduce the endpoint's rejection of Chat Completions content parts.
- return httpx.Response(
- 400, json={"error": {"message": "Invalid Responses content", "type": "invalid_request_error"}}
+ prompt = [{"type": "text", "text": message.content}]
+ provider_service.reply(
+ sse([
+ {"type": "response.output_text.delta", "delta": "BUB_READY"},
+ {"type": "response.completed", "response": {"status": "completed"}},
+ ])
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("content")
+ await tape.ensure_bootstrap_anchor()
+ if content_kind == "assistant-history":
+ await tape.store.append(
+ tape.name,
+ TapeEntry.message({
+ "role": "assistant",
+ "content": [{"type": "text", "text": "Checking."}],
+ "tool_calls": [{"id": "call-1", "name": "check", "arguments": "{}"}],
+ }),
+ )
+ await tape.store.append(
+ tape.name, TapeEntry.message({"role": "tool", "tool_call_id": "call-1", "content": "Ready"})
)
events = [
- {"type": "response.output_text.delta", "delta": "BUB_READY"},
- {"type": "response.completed", "response": {"id": "resp_test", "model": "gpt-5.5"}},
+ event
+ async for event in runner.run(
+ tape=tape, model="codex:test", tools=[], system_prompt=None, prompt=to_content(prompt)
+ )
]
- return httpx.Response(
- 200,
- headers={"content-type": "text/event-stream"},
- text="".join(f"data: {json.dumps(event)}\n\n" for event in events),
- )
-
- async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"), http_client=http_client)
- stream = await provider.acompletion(model="gpt-5.5", messages=messages, stream=True)
- answer = "".join([chunk.choices[0].delta.content or "" async for chunk in stream if chunk.choices])
-
- assert answer == "BUB_READY"
-
-
-async def _codex_response_events():
- yield SimpleNamespace(type="response.output_text.delta", delta="hel")
- yield SimpleNamespace(type="response.output_text.delta", delta="lo")
- yield SimpleNamespace(
- type="response.completed",
- response=SimpleNamespace(
- id="resp_123",
- created_at=1,
- model="gpt-5-codex",
- usage=SimpleNamespace(
- input_tokens=3,
- output_tokens=2,
- total_tokens=5,
- input_tokens_details={"cached_tokens": 2},
- ),
- ),
- )
-
-
-@pytest.mark.asyncio
-async def test_codex_completion_stream_maps_response_events_to_completion_chunks() -> None:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"))
- provider._aresponses = AsyncMock(return_value=_codex_response_events()) # type: ignore[method-assign]
- params = CompletionParams(
- model_id="gpt-5-codex",
- messages=[{"role": "user", "content": "hello"}],
- stream=True,
- )
-
- completion = await provider._acompletion(params)
- chunks = [chunk async for chunk in completion]
-
- assert [chunk.choices[0].delta.content for chunk in chunks[:2]] == ["hel", "lo"]
- assert chunks[-1].choices[0].finish_reason == "stop"
- assert chunks[-1].usage is not None
- assert chunks[-1].usage.prompt_tokens == 3
- assert chunks[-1].usage.completion_tokens == 2
- assert chunks[-1].usage.prompt_tokens_details is not None
- assert chunks[-1].usage.prompt_tokens_details.cached_tokens == 2
-
-
-async def _codex_tool_response_events():
- yield SimpleNamespace(
- type="response.output_item.added",
- output_index=0,
- item=SimpleNamespace(type="function_call", id="fc_1", call_id="call_1", name="bash", arguments=""),
- )
- yield SimpleNamespace(type="response.function_call_arguments.delta", output_index=0, delta='{"command":')
- yield SimpleNamespace(type="response.function_call_arguments.delta", output_index=0, delta='"pwd"}')
- yield SimpleNamespace(
- type="response.completed",
- response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None),
- )
-
-
-@pytest.mark.asyncio
-async def test_codex_completion_stream_maps_response_tool_calls_to_completion_chunks() -> None:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"))
- provider._aresponses = AsyncMock(return_value=_codex_tool_response_events()) # type: ignore[method-assign]
- params = CompletionParams(
- model_id="gpt-5-codex",
- messages=[{"role": "user", "content": "hello"}],
- stream=True,
- )
-
- completion = await provider._acompletion(params)
- chunks = [chunk async for chunk in completion]
-
- first_tool_delta = chunks[0].choices[0].delta.tool_calls[0]
- assert first_tool_delta.id == "call_1"
- assert first_tool_delta.function.name == "bash"
- assert "".join(chunk.choices[0].delta.tool_calls[0].function.arguments or "" for chunk in chunks[1:3]) == (
- '{"command":"pwd"}'
- )
- assert chunks[-1].choices[0].finish_reason == "tool_calls"
-
-
-async def _codex_custom_tool_response_events():
- yield SimpleNamespace(
- type="response.output_item.added",
- output_index=1,
- item=SimpleNamespace(type="custom_tool_call", id="ctc_1", call_id="call_1", name="bash", input=""),
- )
- yield SimpleNamespace(
- type="response.custom_tool_call_input.delta", item_id="ctc_1", call_id="call_1", delta='{"command":'
- )
- yield SimpleNamespace(
- type="response.custom_tool_call_input.delta", item_id="ctc_1", call_id="call_1", delta='"pwd"}'
- )
- yield SimpleNamespace(
- type="response.completed",
- response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None),
- )
+ request = provider_service.requests[0]
+ body = provider_service.body()
+ assert str(request.url) == "https://chatgpt.com/backend-api/codex/responses"
+ assert request.headers["authorization"] == "Bearer test-token"
+ assert request.headers["ChatGPT-Account-Id"] == "test-account"
+ assert body["store"] is False
+ assert body["stream"] is True
+ assert "max_output_tokens" not in body
+ assert body["include"] == ["reasoning.encrypted_content"]
+ assert body["input"][-1]["content"][0]["type"] == "input_text"
+ if content_kind == "image":
+ assert body["input"][-1]["content"][-1] == {
+ "type": "input_image",
+ "image_url": "https://example.test/image.png",
+ }
+ if content_kind == "assistant-history":
+ assert body["input"][1]["type"] == "function_call"
+ assert body["input"][2]["type"] == "function_call_output"
+ assert events[-1].data == {"ok": True, "text": "BUB_READY"}
@pytest.mark.asyncio
-async def test_codex_completion_stream_maps_custom_tool_call_input_deltas_to_completion_chunks() -> None:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"))
- provider._aresponses = AsyncMock(return_value=_codex_custom_tool_response_events()) # type: ignore[method-assign]
- params = CompletionParams(
- model_id="gpt-5-codex",
- messages=[{"role": "user", "content": "hello"}],
- stream=True,
- )
-
- completion = await provider._acompletion(params)
- chunks = [chunk async for chunk in completion]
-
- first_tool_delta = chunks[0].choices[0].delta.tool_calls[0]
- assert first_tool_delta.index == 1
- assert first_tool_delta.id == "call_1"
- assert first_tool_delta.function.name == "bash"
- assert "".join(chunk.choices[0].delta.tool_calls[0].function.arguments or "" for chunk in chunks[1:3]) == (
- '{"command":"pwd"}'
- )
- assert chunks[-1].choices[0].finish_reason == "tool_calls"
-
-
-async def _codex_tool_done_name_null_response_events():
- yield SimpleNamespace(
- type="response.function_call_arguments.done",
- item_id="fc_1",
- output_index=0,
- name=None,
- arguments='{"message":"hello"}',
- )
- yield SimpleNamespace(
- type="response.output_item.done",
- output_index=0,
- item=SimpleNamespace(
- type="function_call",
- id="fc_1",
- call_id="call_1",
- name="echo",
- arguments='{"message":"hello"}',
- status="completed",
- ),
- )
- yield SimpleNamespace(
- type="response.completed",
- response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None),
- )
+async def test_codex_replays_encrypted_reasoning_and_tool_results_after_reload(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ reasoning = {"type": "reasoning", "id": "reason-1", "encrypted_content": "opaque", "summary": []}
+ provider_service.reply(
+ sse([
+ {"type": "response.output_item.done", "item": reasoning},
+ {
+ "type": "response.output_item.done",
+ "item": {
+ "type": "function_call",
+ "id": "fc-1",
+ "call_id": "call-1",
+ "name": "echo",
+ "arguments": "{}",
+ },
+ },
+ {"type": "response.completed", "response": {"status": "completed"}},
+ ])
+ )
+ provider_service.reply(
+ sse([
+ {"type": "response.output_text.delta", "delta": "done"},
+ {"type": "response.completed", "response": {"status": "completed"}},
+ ])
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(api_key="test", client_args={"http_client": client}))
+ tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("codex")
+ await tape.ensure_bootstrap_anchor()
+ tools = [Tool(name="echo", handler=lambda: "echoed")]
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape, model="codex:test", tools=tools, system_prompt=None, prompt=to_content("echo")
+ )
+ ]
+ reopened = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("codex")
+ events = [
+ event
+ async for event in runner.run(
+ tape=reopened, model="codex:test", tools=tools, system_prompt=None, prompt=None
+ )
+ ]
+ assert provider_service.body()["input"][1:] == [
+ reasoning,
+ {"type": "function_call", "call_id": "call-1", "name": "echo", "arguments": "{}"},
+ {"type": "function_call_output", "call_id": "call-1", "output": "echoed"},
+ ]
+ assert events[-1].data == {"ok": True, "text": "done"}
@pytest.mark.asyncio
-async def test_codex_completion_stream_keeps_tool_name_when_arguments_done_name_is_null() -> None:
- provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"))
- provider._aresponses = AsyncMock(return_value=_codex_tool_done_name_null_response_events()) # type: ignore[method-assign]
- params = CompletionParams(
- model_id="gpt-5-codex",
- messages=[{"role": "user", "content": "call echo"}],
- stream=True,
- )
-
- completion = await provider._acompletion(params)
- output = ModelOutputAccumulator()
- chunks = [chunk async for chunk in completion]
- for chunk in chunks:
- tool_calls = chunk.choices[0].delta.tool_calls
- if tool_calls:
- output.merge_delta_tool_calls(tool_calls)
-
- tool_call = output.tool_calls[0]
- assert tool_call.id == "call_1"
- assert tool_call.function.name == "echo"
- assert tool_call.function.arguments == '{"message":"hello"}'
- assert chunks[-1].choices[0].finish_reason == "tool_calls"
+async def test_named_endpoint_falls_back_to_codex_with_its_own_credentials(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ import httpx2
+
+ provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}}))
+ provider_service.reply(
+ sse([
+ {"type": "response.output_text.delta", "delta": "ready"},
+ {"type": "response.completed", "response": {"status": "completed"}},
+ ])
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(
+ AgentSettings(
+ model="relay:sample",
+ fallback_models=["codex:sample"],
+ api_key={"codex": "codex-key"},
+ providers={"relay": {"type": "openai", "api_key": "relay-key", "api_base": "https://relay.test/v1"}},
+ client_args={"http_client": client, "max_retries": 0},
+ )
+ )
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback")
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape, model="relay:sample", tools=[], system_prompt=None, prompt=to_content("hello")
+ )
+ ]
+ assert [str(request.url) for request in provider_service.requests] == [
+ "https://relay.test/v1/responses",
+ "https://chatgpt.com/backend-api/codex/responses",
+ ]
+ assert [request.headers["authorization"] for request in provider_service.requests] == [
+ "Bearer relay-key",
+ "Bearer codex-key",
+ ]
+ assert events[-1].data == {"ok": True, "text": "ready"}
+
+
+@pytest.mark.parametrize("device_auth", [False, True])
+@pytest.mark.parametrize("directory_option", [False, True])
+def test_login_stores_credentials_in_the_selected_directory(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, codex_executable: Path, device_auth: bool, directory_option: bool
+) -> None:
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path / "default-home"))
+ directory = tmp_path / "selected-home" if directory_option else tmp_path / "default-home"
+ args = ["codex", "--executable", str(codex_executable)]
+ if directory_option:
+ args.extend(["--codex-home", str(directory)])
+ if device_auth:
+ args.append("--device-auth")
+ result = CliRunner().invoke(app, args)
+ assert result.exit_code == 0, result.output
+ account = "device-account" if device_auth else "browser-account"
+ assert f"account_id: {account}" in result.output
+ assert json.loads((directory / "auth.json").read_text())["tokens"]["account_id"] == account
+ assert "test-token" not in result.output
+ assert os.environ["CODEX_HOME"] == str(tmp_path / "default-home")
+ if directory_option:
+ assert not (tmp_path / "default-home" / "auth.json").exists()
+
+
+@pytest.mark.parametrize("directory_option", [False, True])
+def test_login_reports_a_missing_executable(tmp_path: Path, directory_option: bool) -> None:
+ args = ["codex", "--executable", str(tmp_path / "missing")]
+ if directory_option:
+ args.extend(["--codex-home", str(tmp_path)])
+ result = CliRunner().invoke(app, args)
+ assert result.exit_code == 1
+ assert "Cannot start" in result.output
+
+
+@pytest.mark.parametrize("directory_option", [False, True])
+def test_failed_login_does_not_report_saved_credentials_as_success(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, directory_option: bool
+) -> None:
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path))
+ (tmp_path / "auth.json").write_text(
+ json.dumps({"tokens": {"access_token": "test-token", "account_id": "saved-account"}})
+ )
+ executable = tmp_path / "failed-codex"
+ executable.write_text(f"#!{sys.executable}\nimport sys\nsys.exit(2)\n")
+ executable.chmod(0o755)
+ args = ["codex", "--executable", str(executable)]
+ if directory_option:
+ args.extend(["--codex-home", str(tmp_path)])
+ result = CliRunner().invoke(app, args)
+ assert result.exit_code == 1
+ assert "login: failed" in result.output
+ assert "saved-account" not in result.output
+ assert "login: ok" not in result.output
diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py
index a2cc26f6..76147fe3 100644
--- a/tests/test_builtin_hook_impl.py
+++ b/tests/test_builtin_hook_impl.py
@@ -8,12 +8,13 @@
import pytest
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.tape import Tape, TapeContext, session_tape_name
+from bub.core.tools import REGISTRY
from bub.framework import BubFramework
-from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore
from bub.streaming import AsyncStreamEvents, StreamEvent
-from bub.tape import Tape, TapeContext
-from bub.tools import REGISTRY
class RecordingLifespan:
@@ -30,7 +31,6 @@ async def __aexit__(self, exc_type, exc, traceback) -> None:
def _fake_tape(home: Path) -> Tape:
return Tape(
- archive_path=home / "tapes",
store=AsyncTapeStoreAdapter(InMemoryTapeStore()),
context=TapeContext(),
)
@@ -43,10 +43,13 @@ def __init__(self, home: Path, *, tape: Tape | None = None) -> None:
self.tools = REGISTRY.copy()
# A real in-memory async tape so load_state's recovery path runs against
# the same store the tests write `model_switch` events to.
- self.tape = tape if tape is not None else _fake_tape(home)
+ self.tapes = tape if tape is not None else _fake_tape(home)
self.run_calls: list[tuple[str, str, dict[str, object]]] = []
self.run_stream_calls: list[tuple[str, str, dict[str, object], str | None]] = []
+ def tape(self, name: str) -> Tape:
+ return self.tapes.scoped(name)
+
async def run(self, *, session_id: str, prompt: str, state: dict[str, object]) -> str:
self.run_calls.append((session_id, prompt, state))
return "agent-output"
@@ -134,7 +137,7 @@ async def test_load_state_and_save_state_manage_lifespan_and_context(tmp_path: P
async def test_load_state_injects_model_recorded_on_session_tape(tmp_path: Path) -> None:
"""A model_switch event recorded on the session tape is restored into state on load."""
_, impl, agent = _build_impl(tmp_path)
- session = agent.tape.session_tape("resolved-session", impl.framework.workspace)
+ session = agent.tape(session_tape_name("resolved-session", impl.framework.workspace))
await session.append_event("model_switch", {"model": "openai:gpt-4o"})
message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello")
@@ -147,7 +150,7 @@ async def test_load_state_injects_model_recorded_on_session_tape(tmp_path: Path)
@pytest.mark.asyncio
async def test_load_state_injects_reasoning_effort_recorded_on_session_tape(tmp_path: Path) -> None:
_, impl, agent = _build_impl(tmp_path)
- session = agent.tape.session_tape("resolved-session", impl.framework.workspace)
+ session = agent.tape(session_tape_name("resolved-session", impl.framework.workspace))
await session.append_event("reasoning_effort_switch", {"reasoning_effort": "high"})
message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello")
@@ -160,7 +163,7 @@ async def test_load_state_injects_reasoning_effort_recorded_on_session_tape(tmp_
@pytest.mark.asyncio
async def test_load_state_injects_latest_code_mode_recorded_on_session_tape(tmp_path: Path) -> None:
_, impl, agent = _build_impl(tmp_path)
- session = agent.tape.session_tape("resolved-session", impl.framework.workspace)
+ session = agent.tape(session_tape_name("resolved-session", impl.framework.workspace))
await session.append_event("code_mode_switch", {"code_mode": True})
await session.append_event("code_mode_switch", {"code_mode": False})
message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello")
@@ -188,7 +191,7 @@ async def test_load_state_does_not_inject_model_for_unknown_session(tmp_path: Pa
async def test_load_state_restores_latest_recorded_model(tmp_path: Path) -> None:
"""When several switches were recorded, the most recent one wins."""
_, impl, agent = _build_impl(tmp_path)
- session = agent.tape.session_tape("resolved-session", impl.framework.workspace)
+ session = agent.tape(session_tape_name("resolved-session", impl.framework.workspace))
await session.append_event("model_switch", {"model": "openai:gpt-4o"})
await session.append_event("model_switch", {"model": "anthropic:claude-3"})
message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello")
@@ -207,9 +210,9 @@ async def test_build_prompt_marks_commands_and_prefixes_context(tmp_path: Path)
command_prompt = await impl.build_prompt(command, session_id="s", state={})
normal_prompt = await impl.build_prompt(normal, session_id="s", state={})
- assert command_prompt == ",help"
+ assert command_prompt == [",help"]
assert command.kind == "command"
- prompt_lines = normal_prompt.splitlines()
+ prompt_lines = normal_prompt[0].splitlines()
assert prompt_lines[0] == normal.context_str
assert prompt_lines[2] == "hello"
@@ -230,7 +233,7 @@ async def test_build_prompt_uses_system_timezone_for_context_date(
prompt = await impl.build_prompt(message, session_id="s", state={})
- date_line = prompt.splitlines()[1]
+ date_line = prompt[0].splitlines()[1]
assert date_line.startswith("---Date: ")
assert date_line.endswith("+08:00---")
finally:
@@ -339,9 +342,10 @@ def test_provide_channels_returns_cli_and_telegram(tmp_path: Path, monkeypatch:
class DummyCliChannel:
name = "cli"
- def __init__(self, on_receive, agent) -> None:
+ def __init__(self, on_receive, *, get_agent, workspace: Path) -> None:
self.on_receive = on_receive
- self.agent = agent
+ self.get_agent = get_agent
+ self.workspace = workspace
class DummyTelegramChannel:
name = "telegram"
@@ -367,7 +371,8 @@ def message_handler(message) -> None:
assert [channel.name for channel in channels] == ["telegram", "cli"]
assert channels[0].on_receive is message_handler
assert channels[1].on_receive is message_handler
- assert channels[1].agent is agent
+ assert channels[1].get_agent() is agent
+ assert channels[1].workspace == impl.framework.workspace
@pytest.mark.asyncio
@@ -437,7 +442,7 @@ def test_provide_tape_store_uses_bub_home_directory(tmp_path: Path, monkeypatch:
store = impl.provide_tape_store()
- assert isinstance(store, FileTapeStore)
+ assert isinstance(store, FuzzyFileTapeStore)
assert store._directory == tmp_path / "tapes"
diff --git a/tests/test_builtin_model_runner.py b/tests/test_builtin_model_runner.py
deleted file mode 100644
index 6fee8704..00000000
--- a/tests/test_builtin_model_runner.py
+++ /dev/null
@@ -1,405 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import AsyncIterator, Iterator
-from pathlib import Path
-from typing import Any
-
-import pytest
-from any_llm.constants import LLMProvider
-from any_llm.providers.anthropic.base import BaseAnthropicProvider
-from any_llm.providers.openai.base import BaseOpenAIProvider
-from any_llm.types.completion import (
- ChatCompletion,
- ChatCompletionChunk,
- ChatCompletionMessageFunctionToolCall,
- Function,
-)
-from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall, Custom
-
-from bub.builtin.context import default_tape_context
-from bub.builtin.model_runner import (
- ModelRunner,
- _adapt_messages_for_provider,
- parse_native_function_call,
- tool_invocation_from_native,
-)
-from bub.builtin.settings import AgentSettings, ModelCandidate
-from bub.errors import BubError, ErrorKind
-from bub.store import FileTapeStore
-from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, Tape, TapeContext
-from bub.tools import Tool, ToolExecutor
-
-
-@pytest.mark.parametrize("provider", [LLMProvider.GEMINI, LLMProvider.VERTEXAI])
-def test_adapt_messages_converts_video_url_for_google_providers(provider: LLMProvider) -> None:
- messages = [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "describe this video"},
- {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,dmlkZW8="}},
- {"type": "input_audio", "input_audio": {"data": "YXVkaW8=", "format": "ogg"}},
- ],
- }
- ]
-
- result = _adapt_messages_for_provider(messages, provider)
-
- assert result == [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "describe this video"},
- {"type": "file", "file": {"file_data": "data:video/mp4;base64,dmlkZW8="}},
- {"type": "file", "file": {"file_data": "data:audio/ogg;base64,YXVkaW8="}},
- ],
- }
- ]
- assert messages[0]["content"][1]["type"] == "video_url"
-
-
-def test_adapt_messages_keeps_native_multimodal_blocks_for_openrouter() -> None:
- messages = [
- {
- "role": "user",
- "content": [
- {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,dmlkZW8="}},
- {"type": "input_audio", "input_audio": {"data": "YXVkaW8=", "format": "ogg"}},
- ],
- }
- ]
-
- assert _adapt_messages_for_provider(messages, LLMProvider.OPENROUTER) is messages
-
-
-@pytest.mark.asyncio
-async def test_unknown_tool_placeholder_surfaces_error_without_hooks() -> None:
- tool_call = ChatCompletionMessageFunctionToolCall(
- id="call-1",
- type="function",
- function=Function(name="missing_tool", arguments="{}"),
- )
- invocation = tool_invocation_from_native(tool_call, {})
-
- execution = await ToolExecutor().execute_async([invocation])
-
- assert execution.error is not None
- assert "missing_tool" in execution.error.message
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize("streaming", [False, True])
-@pytest.mark.parametrize("content", [None, "", "Check byte equality.\nOnly exact equality counts."])
-@pytest.mark.parametrize("continuation_prompt", ["Continue.", "", None])
-async def test_tool_call_text_survives_into_next_request_after_tape_reload(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
- streaming: bool,
- content: str | None,
- continuation_prompt: str | None,
-) -> None:
- calls = [
- {"id": f"call-{name}", "type": "function", "function": {"name": name, "arguments": "{}"}}
- for name in ("inspect", "compare")
- ]
- response = BaseOpenAIProvider._convert_completion_response({
- "id": "completion-1",
- "object": "chat.completion",
- "created": 0,
- "model": "test-model",
- "choices": [
- {
- "index": 0,
- "finish_reason": "tool_calls",
- "message": {"role": "assistant", "content": content, "tool_calls": calls},
- }
- ],
- })
-
- async def stream() -> AsyncIterator[ChatCompletionChunk]:
- deltas = [{"content": part} for part in (content or "").splitlines(keepends=True)]
- deltas.append({"tool_calls": [{"index": index, **call} for index, call in enumerate(calls)]})
- for delta in deltas:
- yield ChatCompletionChunk.model_validate({
- "id": "completion-1",
- "object": "chat.completion.chunk",
- "created": 0,
- "model": "test-model",
- "choices": [{"index": 0, "delta": delta}],
- })
-
- requests: list[list[dict[str, Any]]] = []
-
- async def complete(**kwargs: Any) -> ChatCompletion | AsyncIterator[ChatCompletionChunk]:
- requests.append(kwargs["messages"])
- return stream() if streaming else response
-
- runner = ModelRunner(AgentSettings.model_construct(model="test-model", model_timeout_seconds=None))
- monkeypatch.setattr(runner, "completion_response", complete)
- tools = [Tool(name="inspect", handler=lambda: "files found"), Tool(name="compare", handler=lambda: "bytes differ")]
- root = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("test-tape")
- async with root.fork_tape() as tape:
- await tape.ensure_bootstrap_anchor()
- events = [
- event
- async for event in runner.run(
- tape=tape, model="test-model", tools=tools, system_prompt=None, prompt="Compare the outputs."
- )
- ]
-
- reloaded = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped(
- "test-tape"
- )
- async for _ in runner.run(
- tape=reloaded, model="test-model", tools=tools, system_prompt=None, prompt=continuation_prompt
- ):
- pass
-
- assert "".join(event.data["delta"] for event in events if event.kind == "text") == (content or "")
- expected_messages = [
- {"role": "user", "content": "Compare the outputs."},
- {"role": "assistant", "content": content or "", "tool_calls": calls},
- {"role": "tool", "content": "files found", "tool_call_id": "call-inspect", "name": "inspect"},
- {"role": "tool", "content": "bytes differ", "tool_call_id": "call-compare", "name": "compare"},
- ]
- if continuation_prompt is not None:
- expected_messages.append({"role": "user", "content": continuation_prompt})
- assert requests[1] == expected_messages
- if continuation_prompt is None:
- persisted = await reloaded.store.fetch_all(reloaded.query().kinds("message"))
- assert [entry.payload for entry in persisted if entry.payload.get("role") == "user"] == [
- {"role": "user", "content": "Compare the outputs."}
- ]
-
-
-@pytest.mark.asyncio
-async def test_build_messages_keeps_steering_when_continuation_has_no_prompt(tmp_path: Path) -> None:
- runner = ModelRunner(AgentSettings.model_construct(model="test-model"))
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("steering")
- await tape.ensure_bootstrap_anchor()
-
- messages, new_messages = await runner.build_messages(
- tape=tape,
- run_id="run-1",
- system_prompt=None,
- prompt=None,
- model="test-model",
- steering_messages=["new user direction"],
- )
-
- assert messages == new_messages == [{"role": "user", "content": "new user direction"}]
-
-
-@pytest.mark.parametrize("arguments", ["[]", "null", "1", "not json"])
-def test_function_tool_call_rejects_non_object_arguments(arguments: str) -> None:
- call = ChatCompletionMessageFunctionToolCall(
- id="call-1", type="function", function=Function(name="inspect", arguments=arguments)
- )
-
- with pytest.raises(BubError) as exc_info:
- parse_native_function_call(call)
-
- assert exc_info.value.kind == ErrorKind.INVALID_INPUT
-
-
-def test_custom_tool_call_is_not_treated_as_a_function_call() -> None:
- call = ChatCompletionMessageCustomToolCall(
- id="call-1", type="custom", custom=Custom(name="inspect", input="raw input")
- )
-
- with pytest.raises(BubError) as exc_info:
- parse_native_function_call(call)
-
- assert exc_info.value.kind == ErrorKind.INVALID_INPUT
-
-
-class _FakeStreamingOpenAIProvider(BaseOpenAIProvider):
- SUPPORTS_COMPLETION_STREAMING = True
-
- def __init__(self) -> None:
- self.completion_kwargs: dict[str, Any] | None = None
-
- async def acompletion(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]:
- self.completion_kwargs = kwargs
- include_usage = kwargs.get("stream_options") == {"include_usage": True}
-
- async def stream() -> AsyncIterator[ChatCompletionChunk]:
- yield ChatCompletionChunk.model_validate({
- "id": "chatcmpl_test",
- "object": "chat.completion.chunk",
- "created": 0,
- "model": "gpt-test",
- "choices": [
- {
- "index": 0,
- "finish_reason": None,
- "delta": {"role": "assistant", "content": "done"},
- }
- ],
- })
- final_chunk: dict[str, Any] = {
- "id": "chatcmpl_test",
- "object": "chat.completion.chunk",
- "created": 0,
- "model": "gpt-test",
- "choices": [],
- }
- if include_usage:
- final_chunk["usage"] = {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}
- yield ChatCompletionChunk.model_validate(final_chunk)
-
- return stream()
-
-
-class _FakeStreamingAnthropicProvider(BaseAnthropicProvider):
- def __init__(self) -> None:
- self.completion_kwargs: dict[str, Any] | None = None
-
- def _init_client(self, api_key: str | None = None, api_base: str | None = None, **kwargs: Any) -> None:
- pass
-
- async def acompletion(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]:
- self.completion_kwargs = kwargs
-
- async def stream() -> AsyncIterator[ChatCompletionChunk]:
- if False:
- yield
-
- return stream()
-
-
-class _FakeOpenAIModelRunner(ModelRunner):
- def __init__(self, settings: AgentSettings, llm: _FakeStreamingOpenAIProvider) -> None:
- super().__init__(settings)
- self._llm = llm
-
- def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, _FakeStreamingOpenAIProvider]]:
- yield ModelCandidate(provider=LLMProvider.OPENAI, model_id=model, name=f"openai:{model}"), self._llm
-
-
-class _FakeAnthropicModelRunner(ModelRunner):
- def __init__(self, settings: AgentSettings, llm: _FakeStreamingAnthropicProvider) -> None:
- super().__init__(settings)
- self._llm = llm
-
- def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, _FakeStreamingAnthropicProvider]]:
- yield ModelCandidate(provider=LLMProvider.ANTHROPIC, model_id=model, name=f"anthropic:{model}"), self._llm
-
-
-@pytest.mark.asyncio
-async def test_streaming_openai_usage_is_requested_and_recorded_in_tape(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
- clock = iter([10.0, 12.0])
- monkeypatch.setattr("bub.builtin.model_runner.monotonic", lambda: next(clock))
- store = InMemoryTapeStore()
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), TapeContext()).scoped("test-tape")
- llm = _FakeStreamingOpenAIProvider()
- runner = _FakeOpenAIModelRunner(
- AgentSettings.model_construct(model="openai:gpt-test", max_tokens=100, model_timeout_seconds=None),
- llm,
- )
-
- await tape.ensure_bootstrap_anchor()
- events = [
- event async for event in runner.run(tape=tape, model="gpt-test", tools=[], system_prompt=None, prompt="hello")
- ]
-
- assert llm.completion_kwargs is not None
- assert llm.completion_kwargs["stream"] is True
- assert llm.completion_kwargs["stream_options"] == {"include_usage": True}
- assert [(event.kind, event.data) for event in events] == [
- ("text", {"delta": "done"}),
- (
- "usage",
- {
- "usage": {"completion_tokens": 2, "prompt_tokens": 3, "total_tokens": 5},
- "elapsed_seconds": 2.0,
- },
- ),
- ("final", {"ok": True, "text": "done"}),
- ]
- run_events = [
- entry for entry in store.read("test-tape") or [] if entry.kind == "event" and entry.payload.get("name") == "run"
- ]
- assert len(run_events) == 1
- assert run_events[0].payload["data"]["usage"] == {
- "completion_tokens": 2,
- "prompt_tokens": 3,
- "total_tokens": 5,
- }
-
-
-@pytest.mark.asyncio
-async def test_anthropic_prompt_caching_is_requested() -> None:
- llm = _FakeStreamingAnthropicProvider()
- runner = _FakeAnthropicModelRunner(
- AgentSettings.model_construct(model="anthropic:claude-test", max_tokens=100),
- llm,
- )
-
- await runner.completion_response(model="claude-test", messages=[{"role": "user", "content": "hello"}], tools=[])
-
- assert llm.completion_kwargs is not None
- assert llm.completion_kwargs["stream"] is True
- assert llm.completion_kwargs["cache_control"] == {"type": "ephemeral"}
- assert "stream_options" not in llm.completion_kwargs
-
-
-@pytest.mark.asyncio
-async def test_run_applies_reasoning_effort_from_tape_state(tmp_path: Path) -> None:
- tape = Tape(
- tmp_path,
- AsyncTapeStoreAdapter(InMemoryTapeStore()),
- TapeContext(state={"reasoning_effort": "high"}),
- ).scoped("test-tape")
- llm = _FakeStreamingOpenAIProvider()
- runner = _FakeOpenAIModelRunner(
- AgentSettings.model_construct(
- model="openai:gpt-test",
- max_tokens=100,
- model_timeout_seconds=None,
- completion_args={"reasoning_effort": "low"},
- ),
- llm,
- )
-
- await tape.ensure_bootstrap_anchor()
- events = runner.run(tape=tape, model="gpt-test", tools=[], system_prompt=None, prompt="hello")
- [event async for event in events]
-
- assert llm.completion_kwargs is not None
- assert llm.completion_kwargs["reasoning_effort"] == "high"
-
-
-@pytest.mark.asyncio
-async def test_completion_args_are_forwarded_without_overriding_managed_args() -> None:
- llm = _FakeStreamingOpenAIProvider()
- runner = _FakeOpenAIModelRunner(
- AgentSettings.model_construct(
- model="openai:gpt-test",
- max_tokens=100,
- completion_args={
- "reasoning_effort": "high",
- "model": "ignored-model",
- "max_tokens": 1,
- "stream": False,
- "stream_options": {"include_usage": False},
- },
- ),
- llm,
- )
-
- await runner.completion_response(
- model="gpt-test",
- messages=[{"role": "user", "content": "hello"}],
- tools=[],
- max_tokens=42,
- )
-
- assert llm.completion_kwargs is not None
- assert llm.completion_kwargs["reasoning_effort"] == "high"
- assert llm.completion_kwargs["model"] == "gpt-test"
- assert llm.completion_kwargs["max_tokens"] == 42
- assert llm.completion_kwargs["stream"] is True
- assert llm.completion_kwargs["stream_options"] == {"include_usage": True}
diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py
index 343775d2..a3960c53 100644
--- a/tests/test_builtin_tools.py
+++ b/tests/test_builtin_tools.py
@@ -26,14 +26,14 @@
set_reasoning_effort,
tape_info,
)
+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 bub.errors import ErrorKind
-from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore
-from bub.tape import Tape, TapeContext
-from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool
def _tool_context(tmp_path, **state) -> ToolContext:
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
return ToolContext(tape=tape, run_id="test-run", state={"_runtime_workspace": str(tmp_path), **state})
@@ -54,10 +54,10 @@ async def test_tape_info_formats_token_cache_hit_rate(tmp_path) -> None:
new_messages=[],
response_text=None,
usage={
- "prompt_tokens": 8,
- "completion_tokens": 2,
+ "input_tokens": 8,
+ "output_tokens": 2,
"total_tokens": 10,
- "prompt_tokens_details": {"cached_tokens": 3},
+ "cached_tokens": 3,
},
)
@@ -378,7 +378,7 @@ async def test_bash_non_zero_exit_is_returned_as_tool_error(tmp_path) -> None:
command = _python_shell("import sys; print('boom'); sys.exit(7)")
executor = ToolExecutor()
- result = await executor.execute_async([(bash, {"command": command})], context=_tool_context(tmp_path))
+ result = await executor.execute_many([(bash, {"command": command})], context=_tool_context(tmp_path))
assert result.error is not None
assert result.error.kind is ErrorKind.TOOL
diff --git a/tests/test_channels.py b/tests/test_channels.py
index 41b945db..61932310 100644
--- a/tests/test_channels.py
+++ b/tests/test_channels.py
@@ -26,6 +26,7 @@
from bub.channels.manager import ChannelManager
from bub.channels.message import ChannelMessage
from bub.channels.telegram import BubMessageFilter, TelegramChannel, TelegramMessageParser
+from bub.core.events import EventBus
from bub.streaming import StreamEvent
from bub.turn import TurnResult
@@ -130,6 +131,7 @@ def __init__(self, channels: dict[str, Channel]) -> None:
self.resolved_sessions: dict[str, str] = {}
self.steering_inbox = None
self._hook_runtime = SimpleNamespace(notify_error=self._notify_error)
+ self.events = EventBus()
self.running_entries = 0
self.running_exits = 0
@@ -701,56 +703,22 @@ async def start(self, stop_event: asyncio.Event) -> None:
@pytest.mark.asyncio
-async def test_channel_manager_listen_and_run_passes_stream_output_setting(
- monkeypatch: pytest.MonkeyPatch, load_config
-) -> None:
+async def test_channel_manager_listen_and_run_passes_stream_output_setting(load_config) -> None:
_load_channel_config(load_config, enabled_channels="telegram", stream_output=True)
- framework = FakeFramework({"telegram": FakeChannel("telegram")})
+ channel = FakeChannel("telegram")
+ framework = FakeFramework({"telegram": channel})
+ process_inbound = framework.process_inbound
- import bub.channels.manager as manager_module
+ async def process(message, stream_output=False):
+ result = await process_inbound(message, stream_output)
+ channel.stop_event.set()
+ return result
+ framework.process_inbound = process
manager = ChannelManager(framework)
- calls = 0
- spawned_coroutines = []
- original_create_task = manager_module.asyncio.create_task
-
- class DummyTask:
- def add_done_callback(self, callback) -> None:
- return None
-
- def cancel(self) -> None:
- return None
-
- def exception(self):
- return None
-
- def create_task(coro):
- spawned_coroutines.append(coro)
- return DummyTask()
-
- async def wait_until_stopped(awaitable, current_stop_event):
- nonlocal calls
- calls += 1
- if calls == 1:
- return await awaitable
- close = getattr(awaitable, "close", None)
- if callable(close):
- close()
- raise asyncio.CancelledError
-
- async def shutdown() -> None:
- return None
-
- manager.shutdown = shutdown # type: ignore[method-assign]
- monkeypatch.setattr(manager_module.asyncio, "create_task", create_task)
- monkeypatch.setattr(manager_module, "wait_until_stopped", wait_until_stopped)
-
- listen_task = original_create_task(manager.listen_and_run())
- await asyncio.sleep(0)
await manager.on_receive(_message("hello", channel="telegram"))
- await listen_task
- assert len(spawned_coroutines) == 1
- await spawned_coroutines[0]
+ async with asyncio.timeout(2):
+ await manager.listen_and_run()
assert len(framework.process_calls) == 1
message, stream_output = framework.process_calls[0]
@@ -1166,7 +1134,7 @@ async def source() -> asyncio.AsyncIterator[StreamEvent]:
yield StreamEvent("text", {"delta": " "})
yield StreamEvent("text", {"delta": "first paragraph\n\n"})
yield StreamEvent("text", {"delta": "second paragraph"})
- yield StreamEvent("usage", {"usage": {"completion_tokens": 84}, "elapsed_seconds": 2.0})
+ yield StreamEvent("usage", {"usage": {"output_tokens": 84}, "elapsed_seconds": 2.0})
yield StreamEvent("final", {})
yielded = [event async for event in channel.stream_events(message, source())]
@@ -1181,15 +1149,15 @@ async def source() -> asyncio.AsyncIterator[StreamEvent]:
@pytest.mark.parametrize(
("usage", "elapsed", "expected"),
[
- ({"completion_tokens": 84, "prompt_tokens": 1000}, 2.0, "42.0"),
+ ({"output_tokens": 84, "input_tokens": 1000}, 2.0, "42.0"),
({"output_tokens": 21}, 2.0, "10.5"),
- ({"completion_tokens": 0}, 2.0, "0.0"),
+ ({"output_tokens": 0}, 2.0, "0.0"),
(None, 2.0, "-"),
({"total_tokens": 100}, 2.0, "-"),
- ({"completion_tokens": True}, 2.0, "-"),
- ({"completion_tokens": -1}, 2.0, "-"),
- ({"completion_tokens": 84}, 0.0, "-"),
- ({"completion_tokens": 84}, float("nan"), "-"),
+ ({"output_tokens": True}, 2.0, "-"),
+ ({"output_tokens": -1}, 2.0, "-"),
+ ({"output_tokens": 84}, 0.0, "-"),
+ ({"output_tokens": 84}, float("nan"), "-"),
],
)
def test_cli_toolbar_shows_latest_call_token_speed(usage, elapsed, expected) -> None:
@@ -1331,9 +1299,10 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp
@pytest.mark.asyncio
-async def test_cli_tool_reporter_finishes_before_next_model_output() -> None:
- from bub.channels.cli import _CliToolCallReporter
- from bub.tools import REGISTRY, tool, tool_call_reporter
+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
events: list[str] = []
@@ -1348,25 +1317,47 @@ async def write(self, function) -> None:
tool_call_error=lambda **kwargs: events.append("tool-error"),
)
presenter = OrderedPresenter()
- reporter = _CliToolCallReporter(renderer, presenter) # type: ignore[arg-type]
- tool_name = "tests.cli_ordered_tool"
- REGISTRY.pop(tool_name, None)
+ bus = EventBus()
+ _CliToolCallRenderer(renderer, presenter).subscribe(bus) # type: ignore[arg-type]
- @tool(name=tool_name)
def ordered_tool() -> str:
events.append("tool-body")
return "done"
- try:
- with tool_call_reporter(reporter):
- assert await ordered_tool.run() == "done"
- await presenter.write(lambda: events.append("next-model-text"))
- finally:
- REGISTRY.pop(tool_name, None)
+ execution = await ToolExecutor(bus).execute_many([(Tool.from_callable(ordered_tool), {})])
+ await presenter.write(lambda: events.append("next-model-text"))
+ assert execution.tool_results == ["done"]
assert events == ["tool-start", "tool-body", "tool-success", "next-model-text"]
+@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
+
+ errors: list[BaseException] = []
+
+ class Presenter:
+ async def write(self, function) -> None:
+ function()
+
+ renderer = SimpleNamespace(
+ tool_call_start=lambda **kwargs: None,
+ tool_call_error=lambda **kwargs: errors.append(kwargs["error"]),
+ )
+ bus = EventBus()
+ _CliToolCallRenderer(renderer, Presenter()).subscribe(bus) # type: ignore[arg-type]
+
+ def failing_tool() -> str:
+ raise RuntimeError("boom")
+
+ await ToolExecutor(bus).execute_many([(Tool.from_callable(failing_tool), {})])
+
+ assert [(type(error), str(error)) for error in errors] == [(RuntimeError, "boom")]
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize("expand_thinking", [False, True])
async def test_cli_stream_prints_panel_head_after_reasoning_block(expand_thinking: bool) -> None:
@@ -1872,3 +1863,48 @@ async def runner(*args, **kwargs):
return value
return runner
+
+
+@pytest.mark.parametrize("redirected", [False, True])
+def test_run_preserves_output_and_reports_status_only_on_a_terminal(redirected: bool) -> None:
+ script = textwrap.dedent(
+ """
+ from bub.framework import BubFramework
+ from bub.hooks import hookimpl
+ from bub.core.events import LLMCallDecision
+
+ class Reply:
+ @hookimpl
+ def before_llm_call(self, request, state):
+ return LLMCallDecision.finish("actual response")
+
+ framework = BubFramework()
+ framework.load_builtin_hooks()
+ framework.plugin_manager.register(Reply())
+ app = framework.create_cli_app()
+ app(["run", "request"])
+ """
+ )
+ if redirected:
+ result = subprocess.run(
+ [sys.executable, "-c", script], stdin=subprocess.DEVNULL, capture_output=True, timeout=10
+ )
+ assert result.returncode == 0, result.stderr.decode(errors="replace")
+ assert result.stdout == b"[cli:local]\nactual response\n"
+ return
+ master_fd, slave_fd = pty.openpty()
+ process = subprocess.Popen([sys.executable, "-c", script], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd)
+ os.close(slave_fd)
+ try:
+ raw = _read_pty_until_exit(master_fd, process, timeout=10)
+ finally:
+ if process.poll() is None:
+ process.terminate()
+ process.wait(timeout=1)
+ os.close(master_fd)
+ assert process.wait(timeout=1) == 0, raw.decode(errors="replace")
+ positions = {}
+ for frame in re.finditer(rb"\x1b]7501;([^\x1b\x07]*)(?:\x1b\\|\x07)", raw):
+ fields = dict(pair.split(b"=", 1) for pair in frame.group(1).split(b":"))
+ positions.setdefault(fields[b"state"], frame.start())
+ assert positions[b"working"] < raw.index(b"actual response") < positions[b"done"]
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
new file mode 100644
index 00000000..e52405e7
--- /dev/null
+++ b/tests/test_cli_main.py
@@ -0,0 +1,72 @@
+import subprocess
+import sys
+from importlib.metadata import PackageNotFoundError
+
+import pytest
+
+import bub.__main__ as cli
+
+REQUIREMENTS = [
+ "republic>=0.6.0a1",
+ "typer>=0.24.1; extra == 'app'",
+ "httpx[socks]>=0.28.1; extra == 'app'",
+ "opentelemetry-api>=1.39.0; extra == 'trace'",
+]
+
+
+def _installed(*names: str):
+ def distribution(name: str) -> object:
+ if name not in names:
+ raise PackageNotFoundError(name)
+ return object()
+
+ return distribution
+
+
+def test_missing_app_requirements_lists_only_uninstalled_app_extra_packages(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(cli, "requires", lambda name: REQUIREMENTS)
+ monkeypatch.setattr(cli, "distribution", _installed("republic", "typer"))
+
+ assert cli.missing_app_requirements() == ["httpx"]
+
+
+def test_bub_command_without_app_extra_exits_with_install_hint(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(cli, "requires", lambda name: REQUIREMENTS)
+ monkeypatch.setattr(cli, "distribution", _installed())
+
+ with pytest.raises(SystemExit) as exc_info:
+ cli.main()
+
+ message = str(exc_info.value.code)
+ assert "missing: typer, httpx" in message
+ assert "pip install 'bub[app]'" in message
+
+
+def test_bub_command_runs_the_cli_when_app_extra_is_installed(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls: list[str] = []
+ monkeypatch.setattr(cli, "missing_app_requirements", lambda: [])
+ monkeypatch.setattr(cli, "create_cli_app", lambda: lambda: calls.append("app"))
+
+ cli.main()
+
+ assert calls == ["app"]
+
+
+def test_uninstalled_source_tree_skips_the_check(monkeypatch: pytest.MonkeyPatch) -> None:
+ def requires(name: str) -> list[str]:
+ raise PackageNotFoundError(name)
+
+ monkeypatch.setattr(cli, "requires", requires)
+
+ assert cli.missing_app_requirements() == []
+
+
+def test_importing_the_cli_module_does_not_load_the_app_extra() -> None:
+ script = (
+ "import sys, bub.__main__\n"
+ "print(sorted({name.split('.')[0] for name in sys.modules} & {'typer', 'pluggy', 'pydantic_settings'}))\n"
+ "print('bub.framework' in sys.modules)\n"
+ )
+ result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True)
+
+ assert result.stdout.splitlines() == ["[]", "False"]
diff --git a/tests/test_cli_renderer.py b/tests/test_cli_renderer.py
index e3997706..4e90bdf5 100644
--- a/tests/test_cli_renderer.py
+++ b/tests/test_cli_renderer.py
@@ -23,18 +23,17 @@ def test_panel_head_and_end_commit_separate_terminal_lines() -> None:
assert lines[3] == "next prompt"
-def test_tool_call_renderer_hides_success_output() -> None:
+def test_tool_call_renderer_shows_call_and_elapsed_time() -> None:
console = Console(record=True, force_terminal=False, width=120)
renderer = CliRenderer(console)
- renderer.tool_call_start(name="demo.echo", args=(), kwargs={"value": "hello"})
- renderer.tool_call_success(name="demo.echo", result={"secret": "hidden-result"}, elapsed_ms=12.3)
+ renderer.tool_call_start(name="demo.echo", kwargs={"value": "hello"})
+ renderer.tool_call_success(name="demo.echo", elapsed_ms=12.3)
output = console.export_text()
assert '● demo.echo(value: "hello")' in output
assert " ⎿ completed in 12 ms" in output
- assert "hidden-result" not in output
assert "Tool call:" not in output
assert "Tool result:" not in output
diff --git a/tests/test_codemode.py b/tests/test_codemode.py
index 44ee8e24..29cb4ec1 100644
--- a/tests/test_codemode.py
+++ b/tests/test_codemode.py
@@ -9,7 +9,7 @@
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from types import SimpleNamespace
-from typing import Any, TypedDict
+from typing import Any, ClassVar, TypedDict
import pytest
from pydantic import BaseModel, Field
@@ -22,13 +22,13 @@
set_code_mode,
write_tool_stub,
)
-from bub.builtin.environment import LocalEnvironment, LocalProcess
-from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, CodeFailed
+from bub.core.events import EventBus, ToolCallStarted
+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, LocalEnvironment, LocalProcess
from bub.errors import BubError, ErrorKind
-from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult
-from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore
-from bub.tape import Tape, TapeContext
-from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool
+from bub.hooks.interception import ToolCallDecision
class Order(TypedDict):
@@ -52,11 +52,13 @@ def get_order(order_id: str, include_items: bool = False, *, context: ToolContex
return {"id": order_id, "total": 12.5}
-def _context(tmp_path: Path, tools: list[Tool] | None = None, **state: Any) -> ToolContext:
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
+def _context(
+ tmp_path: Path, tools: list[Tool] | None = None, *, events: EventBus | None = None, **state: Any
+) -> ToolContext:
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
if tools is not None:
state[CODE_TOOLS_STATE_KEY] = model_tools(tools)
- return ToolContext(tape=tape, run_id="run-1", state=state)
+ return ToolContext(tape=tape, run_id="run-1", state=state, events=events)
def test_stub_declares_keyword_only_functions_with_types_and_docs() -> None:
@@ -149,22 +151,18 @@ def get_mode(*, context: ToolContext) -> dict[str, bool]:
async def test_run_code_routes_tool_calls_through_hooks(tmp_path: Path) -> None:
calls: list[tuple[str, bool]] = []
- class Hooks:
- async def before_tool_call(self, call: ToolCall, state: dict[str, Any]) -> tuple[ToolCall, ToolCallDecision]:
- calls.append((call.tool, call.context is not None and call.context.code_mode))
- if call.tool == "blocked":
- return call, ToolCallDecision.deny("not allowed")
- return call, ToolCallDecision.proceed()
+ def before_tool_call(event: ToolCallStarted) -> None:
+ calls.append((event.call.tool, event.call.context is not None and event.call.context.code_mode))
+ if event.call.tool == "blocked":
+ event.decision = ToolCallDecision.deny("not allowed")
- async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict[str, Any]) -> None:
- return None
-
- agent = type("FakeAgent", (), {"model_runner": type("Runner", (), {"hooks": Hooks()})()})()
+ events = EventBus()
+ events.on(ToolCallStarted, before_tool_call)
order_tool = Tool.from_callable(get_order, name="orders.get", context=True)
blocked = Tool.from_callable(lambda: "secret", name="blocked")
code = "try:\n await tools.blocked()\nexcept Exception as exc:\n print(type(exc).__name__, exc)\n"
- output = await run_code.run(code=code, context=_context(tmp_path, [order_tool, blocked], _runtime_agent=agent))
+ output = await run_code.run(code=code, context=_context(tmp_path, [order_tool, blocked], events=events))
assert output == "BubError [tool] not allowed\n"
assert calls == [("blocked", True)]
@@ -197,7 +195,7 @@ async def test_run_code_rejects_positional_tool_arguments(tmp_path: Path) -> Non
@pytest.mark.asyncio
async def test_run_code_requires_code_mode(tmp_path: Path) -> None:
- execution = await ToolExecutor().execute_async([(run_code, {"code": "print(1)"})], context=_context(tmp_path))
+ execution = await ToolExecutor().execute_many([(run_code, {"code": "print(1)"})], context=_context(tmp_path))
assert execution.error is not None
assert execution.error.message == "Code mode is not enabled for this run."
@@ -389,17 +387,13 @@ async def call(**kwargs: Any) -> Any:
async def test_run_code_delegates_to_the_environment_code_runtime(tmp_path: Path) -> None:
calls: list[tuple[str, bool]] = []
- class Hooks:
- async def before_tool_call(self, call: ToolCall, state: dict[str, Any]) -> tuple[ToolCall, ToolCallDecision]:
- calls.append((call.tool, call.context is not None and call.context.code_mode))
- if call.tool == "blocked":
- return call, ToolCallDecision.deny("not allowed")
- return call, ToolCallDecision.proceed()
-
- async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict[str, Any]) -> None:
- return None
+ def before_tool_call(event: ToolCallStarted) -> None:
+ calls.append((event.call.tool, event.call.context is not None and event.call.context.code_mode))
+ if event.call.tool == "blocked":
+ event.decision = ToolCallDecision.deny("not allowed")
- agent = type("FakeAgent", (), {"model_runner": type("Runner", (), {"hooks": Hooks()})()})()
+ events = EventBus()
+ events.on(ToolCallStarted, before_tool_call)
order_tool = Tool.from_callable(get_order, name="orders.get", context=True)
blocked = Tool.from_callable(lambda: "secret", name="blocked")
code = (
@@ -408,7 +402,7 @@ async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: d
"try:\n await tools.blocked()\nexcept Exception as exc:\n print(exc)\n"
)
context = _context(
- tmp_path, [order_tool, blocked], _runtime_agent=agent, **{ENVIRONMENT_STATE_KEY: _InProcessEnvironment()}
+ tmp_path, [order_tool, blocked], events=events, **{ENVIRONMENT_STATE_KEY: _InProcessEnvironment()}
)
output = await run_code.run(code=code, context=context)
@@ -450,3 +444,291 @@ async def run_code(self, code: str, **kwargs: Any) -> None:
await run_code.run(code="print(1)", context=context)
assert runtime_error.value.message == "runtime crashed"
assert runtime_error.value.details == {"output": "partial\n", "stderr": "boom"}
+
+
+PNG_BYTES = b"\x89PNG\r\n\x1a\nfake"
+
+
+def test_stub_declares_media_tools_and_documents_republic() -> None:
+ from bub.builtin.codemode import attach_image
+
+ stub = render_tool_stub([attach_image])
+
+ ast.parse(stub)
+ assert "import republic" in stub
+ assert "async def attach_image(*, image: republic.Image | str) -> str:" in stub
+ assert "republic.get_model(spec: str | None = None) -> ChatModel" in stub
+ assert "republic.get_embedding_model(spec: str) -> EmbeddingModel" in stub
+ assert "republic.get_decision_model(spec: str) -> DecisionModel" in stub
+ assert "model.stream(prompt, **options) -> Stream" in stub
+
+
+@pytest.mark.asyncio
+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
+
+ (tmp_path / "charts").mkdir()
+ (tmp_path / "charts" / "chart.png").write_bytes(PNG_BYTES)
+ code = (
+ "print(await tools.attach_image(image='charts/chart.png'))\n"
+ "await tools.attach_audio(audio='https://example.com/a.mp3')\n"
+ "await tools.attach_video(video=republic.Video('video/mp4', data=b'mp4'))\n"
+ )
+ tools = [attach_image, attach_audio, attach_video]
+
+ result = await run_code.run(
+ code=code, context=_context(tmp_path, tools, **{ENVIRONMENT_STATE_KEY: LocalEnvironment(tmp_path)})
+ )
+
+ assert result_content(result) == [
+ "The image is attached to the run_code result.\n",
+ republic.Image("image/png", data=PNG_BYTES),
+ republic.Audio("audio/mpeg", url="https://example.com/a.mp3"),
+ republic.Video("video/mp4", data=b"mp4"),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_run_code_reports_media_that_cannot_be_loaded(tmp_path: Path) -> None:
+ from bub.builtin.codemode import attach_image
+
+ code = (
+ "for source in ('missing.png', 'notes'):\n"
+ " try:\n"
+ " await tools.attach_image(image=source)\n"
+ " except Exception as exc:\n"
+ " print(exc)\n"
+ )
+
+ output = await run_code.run(
+ code=code, context=_context(tmp_path, [attach_image], **{ENVIRONMENT_STATE_KEY: LocalEnvironment(tmp_path)})
+ )
+
+ lines = output.splitlines()
+ assert lines[0].startswith("[invalid_input] Cannot load the image: [Errno 2]")
+ assert lines[1] == "[invalid_input] Cannot load the image: cannot guess the media type of 'notes'"
+
+
+@pytest.mark.asyncio
+async def test_media_tools_only_work_inside_run_code(tmp_path: Path) -> None:
+ import republic
+
+ from bub.builtin.codemode import attach_image
+
+ with pytest.raises(BubError, match="can only be called from run_code"):
+ await attach_image.run(image=republic.Image("image/png", data=PNG_BYTES), context=_context(tmp_path))
+
+
+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
+
+ image = republic.Image("image/png", data=PNG_BYTES)
+ entries = [
+ TapeEntry.tool_call([{"id": "call-1", "name": "run_code", "arguments": "{}"}]),
+ TapeEntry.tool_result([content_result(["printed", image])]),
+ ]
+ context = default_tape_context()
+
+ messages = context.select(entries, context) # type: ignore[misc]
+
+ assert messages[-1].parts == (republic.Text("printed"), image)
+
+
+class _FakeChatModel:
+ calls: ClassVar[list[tuple[str, dict[str, Any], Any, Any]]] = []
+
+ def __init__(self, spec: str, kwargs: dict[str, Any]) -> None:
+ self.spec, self.kwargs = spec, kwargs
+
+ async def __aenter__(self) -> _FakeChatModel:
+ return self
+
+ async def __aexit__(self, *exc_info: object) -> None:
+ return None
+
+ async def chat(self, messages: Any, **options: Any) -> Any:
+ import republic
+
+ self.calls.append((self.spec, self.kwargs, messages, options))
+ message = republic.assistant("a chart")
+ return SimpleNamespace(
+ text=message.text,
+ reasoning="",
+ refusal=None,
+ finish_reason="stop",
+ model="gpt-x-1",
+ token_usage=republic.TokenUsage(input_tokens=3, output_tokens=2),
+ message=message,
+ )
+
+ async def embed_many(self, texts: list[str], *, dimensions: int | None = None) -> Any:
+ import republic
+
+ return republic.EmbeddingResponse([[float(len(text))] for text in texts], model="embed-1")
+
+ async def decide(self, state: Any, *, questions: dict[str, Any]) -> Any:
+ from republic.decisions import DecisionResponse, NoulAnswer
+
+ self.calls.append((self.spec, self.kwargs, state, questions))
+ return DecisionResponse({key: NoulAnswer(0.9) for key in questions})
+
+
+@pytest.mark.asyncio
+async def test_run_code_republic_models_are_served_by_bub_settings(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ import republic
+ from republic.decisions import Noul
+
+ from bub.builtin.settings import AgentSettings
+ from bub.core.codemode import republic_bridge
+
+ _FakeChatModel.calls = []
+ for getter in ("get_model", "get_embedding_model", "get_decision_model"):
+ monkeypatch.setattr(republic_bridge.republic, getter, lambda spec, **kwargs: _FakeChatModel(spec, kwargs))
+ agent = SimpleNamespace(settings=AgentSettings(model="openai:gpt-x", api_key="sk-test"))
+ (tmp_path / "chart.png").write_bytes(PNG_BYTES)
+ code = (
+ "import republic\n"
+ "from republic.decisions import Noul\n"
+ "model = republic.get_model()\n"
+ f"chart = republic.Image('image/png', data=open({str(tmp_path / 'chart.png')!r}, 'rb').read())\n"
+ "prompt = republic.user('Describe', chart)\n"
+ "reply = await model.chat([prompt], max_tokens=10)\n"
+ "print(reply.text, reply.finish_reason, reply.token_usage.output_tokens, reply.message.role)\n"
+ "emb = await republic.get_embedding_model('openai:embed').embed_many(['ab', 'abc'])\n"
+ "print(emb.vectors, emb.vector)\n"
+ "decision = await republic.get_decision_model('openai:judge').decide('x', questions={'ok': Noul('fine?')})\n"
+ "print(decision.ok.noul, decision.answers['ok'].type)\n"
+ )
+
+ output = await run_code.run(code=code, context=_context(tmp_path, [], _runtime_agent=agent, model="openai:gpt-y"))
+
+ assert output == "a chart stop 2 assistant\n[[2.0], [3.0]] [2.0]\n0.9 noul\n"
+ (chat_spec, chat_kwargs, messages, options), (decide_spec, _, state, questions) = _FakeChatModel.calls
+ assert chat_spec == "openai:gpt-y"
+ assert chat_kwargs["api_key"] == "sk-test"
+ assert messages == [republic.user("Describe", republic.Image("image/png", data=PNG_BYTES))]
+ assert options == {"max_tokens": 10}
+ assert (decide_spec, state, questions) == ("openai:judge", "x", {"ok": Noul("fine?")})
+
+
+@pytest.mark.asyncio
+async def test_run_code_republic_errors_raise_in_code(tmp_path: Path) -> None:
+ code = (
+ "try:\n"
+ " await republic.get_model().chat('hi')\n"
+ "except Exception as exc:\n"
+ " print(type(exc).__name__, exc)\n"
+ )
+
+ output = await run_code.run(code=code, context=_context(tmp_path, []))
+
+ assert output == "BubError [invalid_input] Republic models need a running Bub agent.\n"
+
+
+class _FakeStream:
+ closed: ClassVar[list[bool]] = []
+
+ def __init__(self, chunks: list[str]) -> None:
+ self.chunks = chunks
+
+ async def __aenter__(self) -> _FakeStream:
+ return self
+
+ async def __aexit__(self, *exc_info: object) -> None:
+ self.closed.append(True)
+
+ async def __aiter__(self) -> Any:
+ import republic
+ from republic.events import Completed, TextDelta, UsageDelta
+
+ for chunk in self.chunks:
+ if chunk == "boom":
+ raise RuntimeError("connection lost")
+ yield TextDelta(chunk)
+ await asyncio.sleep(0)
+ yield UsageDelta(republic.TokenUsage(output_tokens=len(self.chunks)))
+ message = republic.assistant("".join(self.chunks))
+ yield Completed(republic.Response(message, token_usage=republic.TokenUsage(output_tokens=len(self.chunks))))
+
+
+class _FakeStreamingModel:
+ async def __aenter__(self) -> _FakeStreamingModel:
+ return self
+
+ async def __aexit__(self, *exc_info: object) -> None:
+ return None
+
+ def stream(self, messages: Any, **options: Any) -> _FakeStream:
+ return _FakeStream(messages[-1].text.split())
+
+
+def _patch_streaming_model(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
+ from bub.builtin.settings import AgentSettings
+ from bub.core.codemode import republic_bridge
+
+ _FakeStream.closed = []
+ monkeypatch.setattr(republic_bridge.republic, "get_model", lambda spec, **kwargs: _FakeStreamingModel())
+ return SimpleNamespace(settings=AgentSettings(model="openai:gpt-x", api_key="sk-test"))
+
+
+@pytest.mark.asyncio
+async def test_run_code_streams_republic_chat_events(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ agent = _patch_streaming_model(monkeypatch)
+ code = (
+ "from republic.events import Completed, TextDelta, UsageDelta\n"
+ "async with republic.get_model().stream('a b c', max_tokens=5) as stream:\n"
+ " async for event in stream:\n"
+ " if isinstance(event, TextDelta):\n"
+ " print(event.chunk, end='|')\n"
+ " elif isinstance(event, UsageDelta):\n"
+ " print('usage', event.usage.output_tokens, end='|')\n"
+ " elif isinstance(event, Completed):\n"
+ " print('done', end='|')\n"
+ "print()\n"
+ "print(stream.text, stream.token_usage.output_tokens, stream.response.message.role)\n"
+ )
+
+ output = await run_code.run(code=code, context=_context(tmp_path, [], _runtime_agent=agent))
+
+ assert output == "a|b|c|usage 3|done|\nabc 3 assistant\n"
+ assert _FakeStream.closed == [True]
+
+
+@pytest.mark.asyncio
+async def test_run_code_stream_errors_raise_after_received_events(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ agent = _patch_streaming_model(monkeypatch)
+ code = (
+ "try:\n"
+ " async with republic.get_model().stream('a boom') as stream:\n"
+ " async for event in stream:\n"
+ " print(event.chunk)\n"
+ "except Exception as exc:\n"
+ " print(type(exc).__name__, exc)\n"
+ )
+
+ output = await run_code.run(code=code, context=_context(tmp_path, [], _runtime_agent=agent))
+
+ assert output == "a\nBubError [tool] republic.stream_next failed: RuntimeError: connection lost\n"
+ assert _FakeStream.closed == [True]
+
+
+@pytest.mark.asyncio
+async def test_run_code_closes_streams_the_code_left_open(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ agent = _patch_streaming_model(monkeypatch)
+ code = "stream = await republic.get_model().stream('a b').__aenter__()\nprint('opened')\n"
+
+ output = await run_code.run(code=code, context=_context(tmp_path, [], _runtime_agent=agent))
+
+ assert output == "opened\n"
+ assert _FakeStream.closed == [True]
diff --git a/tests/test_compat_aliases.py b/tests/test_compat_aliases.py
new file mode 100644
index 00000000..14b535a5
--- /dev/null
+++ b/tests/test_compat_aliases.py
@@ -0,0 +1,39 @@
+import importlib
+import subprocess
+import sys
+
+import pytest
+
+from bub.core import events
+from bub.hooks import interception
+
+MOVED_MODULES = {
+ "bub.tape": "bub.core.tape",
+ "bub.tools": "bub.core.tools",
+ "bub.store": "bub.core.store",
+ "bub.sidecars": "bub.core.sidecars",
+ "bub.builtin.commands": "bub.core.commands",
+ "bub.builtin.context": "bub.core.context",
+}
+
+
+@pytest.mark.parametrize(("old", "new"), MOVED_MODULES.items())
+def test_moved_module_alias_is_the_new_module(old: str, new: str) -> None:
+ assert importlib.import_module(old) is importlib.import_module(new)
+
+
+def test_top_level_module_aliases_load_no_app_dependencies() -> None:
+ script = (
+ "import sys, bub.tape, bub.tools, bub.store, bub.sidecars\n"
+ "loaded = {name.split('.')[0] for name in sys.modules}\n"
+ "print(sorted(loaded & {'typer', 'pluggy', 'pydantic_settings', 'yaml', 'rapidfuzz'}))\n"
+ )
+ result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True)
+
+ assert result.stdout.splitlines() == ["[]"]
+
+
+@pytest.mark.parametrize("suffix", ["Request", "Result", "Decision"])
+def test_interception_keeps_llm_call_payload_aliases(suffix: str) -> None:
+ assert getattr(interception, f"LlmCall{suffix}") is getattr(events, f"LLMCall{suffix}")
+ assert f"LlmCall{suffix}" in interception.__all__
diff --git a/tests/test_core_agent.py b/tests/test_core_agent.py
new file mode 100644
index 00000000..e6448325
--- /dev/null
+++ b/tests/test_core_agent.py
@@ -0,0 +1,433 @@
+from __future__ import annotations
+
+import ast
+import asyncio
+from dataclasses import replace
+from pathlib import Path
+from typing import Any
+
+import pytest
+import republic
+
+import bub.core
+from bub.core import Agent, PromptContext
+from bub.core.events import (
+ AgentEvent,
+ ChatFinished,
+ CommandFinished,
+ EventBus,
+ LLMCallDecision,
+ LLMCallFinished,
+ LLMCallStarted,
+ RunFinished,
+ RunStarted,
+ StepFinished,
+ ToolCallDecision,
+ ToolCallFinished,
+ ToolCallStarted,
+)
+from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore
+from bub.core.tools import Tool
+from bub.errors import BubError
+from tests.model_fakes import ProviderService, chat_events, sse, tool_events
+
+
+def _model(client: Any) -> republic.ChatModel:
+ return republic.get_model("openai:test", http_client=client, api_format="chat", api_key="test")
+
+
+def _tool_call(name: str) -> object:
+ return sse(tool_events([{"index": 0, "id": "call-1", "function": {"name": name, "arguments": "{}"}}]))
+
+
+@pytest.mark.asyncio
+async def test_core_agent_runs_tool_loop_and_emits_events(provider_service: ProviderService) -> None:
+ provider_service.reply(_tool_call("fs_read"))
+ provider_service.reply(sse(chat_events("It is a readme.")))
+ prompts: list[object] = []
+ seen: list[str] = []
+ store = InMemoryTapeStore()
+ read = Tool.from_callable(lambda path: f"contents of {path}", name="fs.read")
+ async with provider_service.client() as client:
+ model = _model(client)
+ agent = Agent(store=store, tools=[read], system_prompt="Be brief.")
+
+ @agent.system_prompt.add
+ def tools_section(context: PromptContext) -> str:
+ prompts.append((context.prompt, context.state.get("user"), [tool.name for tool in context.tools]))
+ assert context.model == "openai:test"
+ assert context.available_tools == [read]
+ return "Tools: " + ", ".join(tool.name for tool in context.tools)
+
+ @agent.system_prompt.add
+ async def empty_section(context: PromptContext) -> None:
+ return None
+
+ @agent.events.on(ToolCallStarted)
+ def rewrite_path(event: ToolCallStarted) -> None:
+ event.call = replace(event.call, arguments={"path": "README.md"})
+
+ assert agent.system_prompt.add("Removed section.") == "Removed section."
+ agent.system_prompt.remove("Removed section.")
+ assert agent.system_prompt.sections == ("Be brief.", tools_section, empty_section)
+ agent.events.on(AgentEvent, lambda event: seen.append(type(event).__name__))
+ async with agent.run(
+ ["What is this file?"], model=model, options={"max_tokens": 64}, tape="session", state={"user": "ada"}
+ ) as stream:
+ events = [event async for event in stream]
+
+ assert events[-1].data == {"ok": True, "text": "It is a readme."}
+ first, second = provider_service.body(0), provider_service.body(1)
+ assert first["messages"][0] == {"role": "system", "content": "Be brief.\n\nTools: fs_read"}
+ assert [tool["function"]["name"] for tool in first["tools"]] == ["fs_read"]
+ assert first["max_completion_tokens"] == 64
+ assert second["messages"][-1] == {"role": "tool", "tool_call_id": "call-1", "content": "contents of README.md"}
+ assert prompts == [(["What is this file?"], "ada", ["fs_read"]), ([], "ada", ["fs_read"])]
+ assert seen == [
+ "RunStarted",
+ "StepStarted",
+ "LLMCallStarted",
+ "LLMCallFinished",
+ "ToolCallStarted",
+ "ToolCallFinished",
+ "ChatFinished",
+ "StepFinished",
+ "StepStarted",
+ "LLMCallStarted",
+ "LLMCallFinished",
+ "ChatFinished",
+ "StepFinished",
+ "RunFinished",
+ ]
+ # The run's fork merged back into the store's tape.
+ tape = agent.tape("session")
+ roles = [message.role for message in await tape.read_messages()]
+ assert roles[-4:] == ["user", "assistant", "tool", "assistant"]
+ names = [entry.payload["name"] for entry in await tape.store.fetch_all(tape.query().kinds("event"))]
+ assert names.index("loop.start") < names.index("loop.step.start") < names.index("run") < names.index("loop.finish")
+
+
+@pytest.mark.asyncio
+async def test_before_events_short_circuit_remaining_handlers(provider_service: ProviderService) -> None:
+ provider_service.reply(_tool_call("secret"))
+ later: list[str] = []
+ finished: list[ChatFinished] = []
+
+ async with provider_service.client() as client:
+ model = _model(client)
+ agent = Agent(store=InMemoryTapeStore(), tools=[Tool.from_callable(lambda: "x", name="secret")])
+ agent.events.on(ToolCallStarted, lambda event: setattr(event, "decision", ToolCallDecision.deny("no secrets")))
+ agent.events.on(ToolCallStarted, lambda event: later.append("tool"))
+ agent.events.on(LLMCallStarted, lambda event: later.append("llm"))
+ agent.events.on(ChatFinished, finished.append)
+ async with agent.session("deny") as tape:
+ events = [event async for event in agent.run_once(["go"], model=model, tape=tape)]
+
+ result = next(event for event in events if event.kind == "tool_result").data["tool_results"][0]
+ assert result["message"] == "no secrets"
+ assert later == ["llm"]
+
+ agent.events.on(
+ LLMCallStarted, lambda event: setattr(event, "decision", LLMCallDecision.finish("budget exhausted"))
+ )
+ agent.events.on(LLMCallFinished, lambda event: later.append("after"))
+ async with agent.run(["again"], model=model, tape="budget") as stream:
+ events = [event async for event in stream]
+
+ assert events[-1].data == {"ok": True, "text": "budget exhausted"}
+ assert later == ["llm", "llm"]
+ assert finished[-1].response_text == "budget exhausted"
+ assert len(provider_service.requests) == 1
+
+
+@pytest.mark.asyncio
+async def test_removed_handler_no_longer_receives_events() -> None:
+ bus = EventBus()
+ received: list[str] = []
+
+ @bus.on(AgentEvent)
+ async def record(event: AgentEvent) -> None:
+ received.append(type(event).__name__)
+
+ def direct(event: AgentEvent) -> None:
+ received.append("direct")
+
+ assert bus.on(AgentEvent, direct) is direct
+ await bus.emit(AgentEvent(state={}))
+ bus.off(AgentEvent, record)
+ bus.off(AgentEvent, record)
+ await bus.emit(AgentEvent(state={}))
+
+ assert received == ["AgentEvent", "direct", "direct"]
+
+
+@pytest.mark.asyncio
+async def test_core_agent_stops_at_max_steps(provider_service: ProviderService) -> None:
+ provider_service.reply(_tool_call("noop"))
+ statuses: list[str] = []
+ async with provider_service.client() as client:
+ model = _model(client)
+ agent = Agent(
+ store=InMemoryTapeStore(),
+ tools=[Tool.from_callable(lambda: "ok", name="noop")],
+ max_steps=1,
+ )
+ agent.events.on(StepFinished, lambda event: statuses.append(event.status))
+ agent.events.on(RunStarted, lambda event: statuses.append(f"model={event.model}"))
+ agent.events.on(ToolCallFinished, lambda event: statuses.append(f"tool={event.result.result}"))
+ agent.events.on(RunFinished, lambda event: statuses.append(f"run={event.status}:{event.steps}:{event.error}"))
+ with pytest.raises(RuntimeError, match="max_steps_reached=1"):
+ async with agent.run(["loop"], model=model, tape="steps") as stream:
+ [event async for event in stream]
+ assert statuses == ["model=openai:test", "tool=ok", "continue", "run=error:1:max_steps_reached=1"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(("limit", "expected_peak"), [(None, 3), (2, 2), (1, 1)])
+async def test_max_tool_concurrency_limits_tool_calls_of_one_response(
+ provider_service: ProviderService, limit: int | None, expected_peak: int
+) -> None:
+ calls = [
+ {"index": i, "id": f"call-{i}", "function": {"name": "wait", "arguments": f'{{"n": {i}}}'}} for i in range(3)
+ ]
+ provider_service.reply(sse(tool_events(calls)))
+ provider_service.reply(sse(chat_events("done")))
+ running = 0
+ peak = 0
+ started: list[int] = []
+
+ async def wait(n: int) -> str:
+ nonlocal running, peak
+ running += 1
+ peak = max(peak, running)
+ await asyncio.sleep(0.01)
+ running -= 1
+ return "ok"
+
+ async with provider_service.client() as client:
+ agent = Agent(
+ model=_model(client),
+ tools=[Tool.from_callable(wait, name="wait")],
+ max_tool_concurrency=limit,
+ )
+ agent.events.on(ToolCallStarted, lambda event: started.append(event.call.arguments["n"]))
+ async with agent.run(["go"], tape="concurrency") as stream:
+ [event async for event in stream]
+
+ assert peak == expected_peak
+ assert started == [0, 1, 2]
+
+
+@pytest.mark.parametrize("limit", [0, -1])
+def test_max_tool_concurrency_must_be_positive(limit: int) -> None:
+ with pytest.raises(ValueError, match="max_tool_concurrency"):
+ Agent(max_tool_concurrency=limit)
+
+
+@pytest.mark.asyncio
+async def test_redirected_call_reuses_the_provider_of_a_republic_model(provider_service: ProviderService) -> None:
+ provider_service.reply(sse(chat_events()))
+ async with provider_service.client() as client:
+ agent = Agent(store=InMemoryTapeStore())
+
+ @agent.events.on(LLMCallStarted)
+ def reroute(event: LLMCallStarted) -> None:
+ event.request = replace(event.request, model="openai:other")
+
+ async with agent.run(["hi"], model=_model(client), tape="redirect") as stream:
+ events = [event async for event in stream]
+
+ assert events[-1].data == {"ok": True, "text": "done"}
+ 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)
+
+ assert agent.tape("a").store is agent.tape("b").store
+ assert isinstance(Agent().tape("a").store, AsyncTapeStoreAdapter)
+
+
+@pytest.mark.asyncio
+async def test_leaving_run_early_closes_the_stream_and_merges_the_fork(provider_service: ProviderService) -> None:
+ provider_service.reply(sse(chat_events("hello there")))
+ finished: list[RunFinished] = []
+ async with provider_service.client() as client:
+ agent = Agent()
+ agent.events.on(RunFinished, finished.append)
+ async with agent.run(["hi"], model=_model(client), tape="early") as stream:
+ async for event in stream:
+ if event.kind == "text":
+ break
+
+ messages = await agent.tape("early").read_messages()
+ assert [message.role for message in messages] == []
+ names = [entry.payload["name"] for entry in await agent.tape("early").store.fetch_all(agent.tape("early").query())]
+ assert "loop.start" in names
+ assert "loop.finish" in names
+ assert [(event.status, event.steps) for event in finished] == [("cancelled", 1)]
+
+
+@pytest.mark.asyncio
+async def test_run_finished_follows_a_run_closed_before_iteration(provider_service: ProviderService) -> None:
+ finished: list[RunFinished] = []
+ async with provider_service.client() as client:
+ agent = Agent()
+ agent.events.on(RunFinished, finished.append)
+ async with agent.run(["hi"], model=_model(client), tape="unread"):
+ pass
+
+ assert [(event.status, event.steps, event.error) for event in finished] == [("cancelled", 0, None)]
+ assert provider_service.requests == []
+
+
+@pytest.mark.asyncio
+async def test_commands_do_not_start_or_finish_a_run() -> None:
+ seen: list[str] = []
+ agent = Agent(tools=[Tool.from_callable(lambda: "tool ran", name="echo")], command_prefix=",")
+ agent.events.on(AgentEvent, lambda event: seen.append(type(event).__name__))
+ async with agent.run([",echo"], tape="command") as stream:
+ [event async for event in stream]
+
+ assert "RunStarted" not in seen
+ assert "RunFinished" not in seen
+ assert "CommandFinished" in seen
+
+
+@pytest.mark.asyncio
+async def test_commands_are_disabled_without_a_prefix(provider_service: ProviderService) -> None:
+ provider_service.reply(sse(chat_events("model answer")))
+ async with provider_service.client() as client:
+ agent = Agent(tools=[Tool.from_callable(lambda: "tool ran", name="echo")])
+ async with agent.run([",echo"], model=_model(client), tape="plain") as stream:
+ events = [event async for event in stream]
+
+ assert events[-1].data == {"ok": True, "text": "model answer"}
+ assert provider_service.body()["messages"][-1] == {"role": "user", "content": ",echo"}
+
+
+@pytest.mark.asyncio
+async def test_runs_call_the_agent_model_unless_given_one(provider_service: ProviderService) -> None:
+ provider_service.reply(sse(chat_events("default answer")))
+ provider_service.reply(sse(chat_events("override answer")))
+
+ async with provider_service.client() as client:
+ default = republic.get_model("openai:default", http_client=client, api_format="chat", api_key="test")
+ override = republic.get_model("openai:override", http_client=client, api_format="chat", api_key="test")
+ agent = Agent(model=default)
+ async with agent.run("hello", tape="plain") as stream:
+ default_events = [event async for event in stream]
+ async with agent.run("again", model=override, tape="plain") as stream:
+ override_events = [event async for event in stream]
+
+ assert default_events[-1].data == {"ok": True, "text": "default answer"}
+ assert override_events[-1].data == {"ok": True, "text": "override answer"}
+ assert [provider_service.body(index)["model"] for index in (0, 1)] == ["default", "override"]
+
+
+@pytest.mark.asyncio
+async def test_builtin_tools_are_sent_with_function_tools(provider_service: ProviderService) -> None:
+ provider_service.reply(sse(chat_events("searched")))
+ ping = Tool.from_callable(lambda: "pong", name="ping")
+ native = republic.tools.NativeTool("chat", {"type": "custom_search"})
+
+ async with provider_service.client() as client:
+ agent = Agent(model=_model(client), tools=[ping, republic.tools.WebSearch(), native])
+ async with agent.run("search", tape="builtin") as stream:
+ events = [event async for event in stream]
+
+ assert events[-1].data == {"ok": True, "text": "searched"}
+ assert list(agent.tools) == ["ping"]
+ assert agent.builtin_tools == [republic.tools.WebSearch(), native]
+ body = provider_service.body()
+ assert body["tools"][0]["function"]["name"] == "ping"
+ assert body["tools"][1] == {"type": "custom_search"}
+ assert "web_search_options" in body
+
+
+@pytest.mark.asyncio
+async def test_runs_without_a_model_raise_unless_they_are_commands() -> None:
+ agent = Agent(tools=[Tool.from_callable(lambda: "pong", name="ping")], command_prefix="/")
+
+ async with agent.run("/ping", tape="plain") as stream:
+ events = [event async for event in stream]
+ assert events[-1].data == {"text": "pong", "ok": True}
+
+ with pytest.raises(ValueError, match="no model to run"):
+ async with agent.run("hello", tape="plain") as stream:
+ [event async for event in stream]
+
+
+@pytest.mark.asyncio
+async def test_command_tool_calls_emit_tool_events_that_can_deny_them() -> None:
+ def greet(name: str, punctuation: str = "!") -> str:
+ return f"hello {name}{punctuation}"
+
+ agent = Agent(tools=[Tool.from_callable(greet, name="greet")], command_prefix="/")
+ calls: list[tuple[str, dict[str, Any]]] = []
+
+ @agent.events.on(ToolCallStarted)
+ def deny_bob(event: ToolCallStarted) -> None:
+ calls.append((event.call.tool, event.call.arguments))
+ if event.call.arguments["name"] == "bob":
+ event.decision = ToolCallDecision.deny("no bob")
+
+ model = republic.get_model("openai:never-called", api_key="test")
+ async with agent.run(["/greet ada ?"], model=model, tape="commands") as stream:
+ events = [event async for event in stream]
+ assert events[-1].data == {"text": "hello ada?", "ok": True}
+
+ with pytest.raises(BubError, match="no bob"):
+ async with agent.run(["/greet bob"], model=model, tape="commands") as stream:
+ [event async for event in stream]
+ with pytest.raises(ValueError, match="at most 2 positional arguments"):
+ async with agent.run(["/greet ada ? extra"], model=model, tape="commands") as stream:
+ [event async for event in stream]
+ assert calls == [("greet", {"name": "ada", "punctuation": "?"}), ("greet", {"name": "bob"})]
+
+
+@pytest.mark.asyncio
+async def test_command_prompt_calls_the_tool_without_the_model() -> None:
+ def greet(name: str, *, punctuation: str = "!") -> str:
+ return f"hello {name}{punctuation}"
+
+ agent = Agent(tools=[Tool.from_callable(greet, name="greet")], command_prefix="/")
+ finished: list[CommandFinished] = []
+ agent.events.on(CommandFinished, finished.append)
+ model = republic.get_model("openai:never-called", api_key="test")
+ async with agent.run([" /greet ada punctuation=?"], model=model, tape="commands") as stream:
+ events = [event async for event in stream]
+
+ assert [(event.kind, event.data) for event in events] == [
+ ("text", {"delta": "hello ada?"}),
+ ("final", {"text": "hello ada?", "ok": True}),
+ ]
+ tape = agent.tape("commands")
+ (command,) = [
+ entry.payload["data"]
+ for entry in await tape.store.fetch_all(tape.query().kinds("event"))
+ if entry.payload["name"] == "command"
+ ]
+ assert (command["name"], command["status"], command["output"]) == ("greet", "ok", "hello ada?")
+
+ with pytest.raises(ValueError, match="bash tool is not available"):
+ async with agent.run(["/unknown"], model=model, tape="commands") as stream:
+ [event async for event in stream]
+ assert [(event.line, event.name, event.status, event.output) for event in finished] == [
+ ("greet ada punctuation=?", "greet", "ok", "hello ada?"),
+ ("unknown", "unknown", "error", "bash tool is not available"),
+ ]
diff --git a/tests/test_core_agent_step.py b/tests/test_core_agent_step.py
new file mode 100644
index 00000000..0ef1ecff
--- /dev/null
+++ b/tests/test_core_agent_step.py
@@ -0,0 +1,460 @@
+from __future__ import annotations
+
+import json
+from contextlib import nullcontext
+from pathlib import Path
+
+import httpx2
+import pytest
+import republic
+from republic.errors import APIStatusError, StreamIncompleteError
+
+from bub.builtin.settings import AgentSettings
+from bub.core.context import default_tape_context
+from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore
+from bub.core.tape import Tape, TapeContext, TapeEntry
+from bub.core.tools import Tool
+from bub.errors import BubError, ErrorKind
+from bub.prompt import to_content
+from tests.model_fakes import ProviderService, StepRunner, chat_events, sse, tool_events
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("outcome", ["success", "http_error", "incomplete"])
+async def test_model_call_closes_its_owned_client(
+ tmp_path: Path, provider_service: ProviderService, monkeypatch: pytest.MonkeyPatch, outcome: str
+) -> None:
+ clients = []
+ constructor = httpx2.AsyncClient
+
+ def create_client(**kwargs):
+ client = constructor(**kwargs, transport=httpx2.MockTransport(provider_service._respond))
+ clients.append(client)
+ return client
+
+ monkeypatch.setattr(httpx2, "AsyncClient", create_client)
+ error = None
+ if outcome == "http_error":
+ provider_service.reply(httpx2.Response(401, json={"error": "unauthorized"}))
+ error = APIStatusError
+ else:
+ provider_service.reply(sse(chat_events() if outcome == "success" else chat_events()[:-1]))
+ if outcome == "incomplete":
+ error = StreamIncompleteError
+ runner = StepRunner(AgentSettings(client_args={"api_format": "chat", "max_retries": 0}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("owned")
+ with pytest.raises(error) if error is not None else nullcontext():
+ events = [
+ event
+ async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello")
+ ]
+ assert events[-1].data == {"ok": True, "text": "done"}
+ assert len(clients) == 1
+ assert clients[0].is_closed
+
+
+@pytest.mark.asyncio
+async def test_unknown_tool_returns_an_error_result(tmp_path: Path, provider_service: ProviderService) -> None:
+ provider_service.reply(
+ sse(
+ tool_events([
+ {"index": 0, "id": "call-1", "function": {"name": "missing_tool", "arguments": "{}"}},
+ ])
+ )
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("unknown")
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("Run the tool.")
+ )
+ ]
+ result = next(event.data["tool_results"][0] for event in events if event.kind == "tool_result")
+ assert result["kind"] == "tool"
+ assert "missing_tool" in result["message"]
+ assert events[-1].kind == "final"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("content", ["", "Check byte equality.\nOnly exact equality counts."])
+@pytest.mark.parametrize("continuation_prompt", ["Continue.", "", None])
+async def test_tool_turn_replays_after_tape_reload(
+ tmp_path: Path, provider_service: ProviderService, content: str, continuation_prompt: str | None
+) -> None:
+ provider_service.reply(
+ sse(
+ tool_events(
+ [
+ {"index": index, "id": f"call-{name}", "function": {"name": name, "arguments": "{}"}}
+ for index, name in enumerate(("inspect", "compare"))
+ ],
+ text=content,
+ )
+ )
+ )
+ provider_service.reply(sse(chat_events("Compared.")))
+ tools = [Tool(name="inspect", handler=lambda: "files found"), Tool(name="compare", handler=lambda: "bytes differ")]
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"}))
+ root = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn")
+ async with root.fork_tape() as tape:
+ await tape.ensure_bootstrap_anchor()
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape,
+ model="openai:test",
+ tools=tools,
+ system_prompt=None,
+ prompt=to_content("Compare the outputs."),
+ )
+ ]
+ reopened = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn")
+ follow_up = [
+ event
+ async for event in runner.run(
+ tape=reopened,
+ model="openai:test",
+ tools=tools,
+ system_prompt=None,
+ prompt=None if continuation_prompt is None else to_content(continuation_prompt),
+ )
+ ]
+ assert next(event.data["tool_results"] for event in events if event.kind == "tool_result") == [
+ "files found",
+ "bytes differ",
+ ]
+ messages = provider_service.body()["messages"]
+ users = [message["content"] for message in messages if message["role"] == "user"]
+ assert users == ["Compare the outputs.", *([continuation_prompt] if continuation_prompt is not None else [])]
+ assistant = next(message for message in messages if message["role"] == "assistant")
+ assert (assistant.get("content") or "") == content
+ assert {call["id"]: call["function"]["name"] for call in assistant["tool_calls"]} == {
+ "call-inspect": "inspect",
+ "call-compare": "compare",
+ }
+ assert {message["tool_call_id"]: message["content"] for message in messages if message["role"] == "tool"} == {
+ "call-inspect": "files found",
+ "call-compare": "bytes differ",
+ }
+ assert follow_up[-1].data == {"ok": True, "text": "Compared."}
+
+
+@pytest.mark.asyncio
+async def test_continuation_sends_steering_without_a_prompt(tmp_path: Path, provider_service: ProviderService) -> None:
+ provider_service.reply(sse(chat_events()))
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("steering")
+ await tape.ensure_bootstrap_anchor()
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape,
+ model="openai:test",
+ tools=[],
+ system_prompt=None,
+ prompt=None,
+ steering_messages=[["new user direction"]],
+ )
+ ]
+ assert provider_service.body()["messages"] == [{"role": "user", "content": "new user direction"}]
+ assert events[-1].data == {"ok": True, "text": "done"}
+ assert republic.user("new user direction") in await tape.read_messages()
+
+
+@pytest.mark.asyncio
+async def test_tool_result_without_its_call_in_context_is_still_sent(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(sse(chat_events()))
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("orphan")
+ await tape.store.append(
+ tape.name,
+ TapeEntry.message({"role": "tool", "tool_call_id": "call-1", "name": "inspect", "content": "Ready"}),
+ )
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape, model="openai:test", tools=[], system_prompt="Be brief.", prompt=to_content("Continue.")
+ )
+ ]
+ messages = provider_service.body()["messages"]
+ assert messages[0] == {"role": "system", "content": "Be brief."}
+ assert messages[1]["tool_calls"][0]["id"] == "call-1"
+ assert messages[1]["tool_calls"][0]["function"]["name"] == "inspect"
+ assert messages[2] == {"role": "tool", "tool_call_id": "call-1", "content": "Ready"}
+ assert events[-1].data == {"ok": True, "text": "done"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("arguments", ["[]", "null", "1", "not json"])
+async def test_invalid_tool_arguments_do_not_execute_the_handler(
+ tmp_path: Path, provider_service: ProviderService, arguments: str
+) -> None:
+ provider_service.reply(
+ sse(tool_events([{"index": 0, "id": "call-1", "function": {"name": "inspect", "arguments": arguments}}]))
+ )
+ invoked = []
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid")
+ with pytest.raises(BubError) as exc:
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape,
+ model="openai:test",
+ tools=[Tool(name="inspect", handler=lambda: invoked.append(True))],
+ system_prompt=None,
+ prompt=to_content("Inspect."),
+ )
+ ]
+ assert exc.value.kind == ErrorKind.INVALID_INPUT
+ assert not invoked
+ assert not await tape.store.fetch_all(tape.query().kinds("tool_result"))
+
+
+@pytest.mark.asyncio
+async def test_streaming_reports_usage_and_records_it_in_tape(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(sse(chat_events()))
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(max_tokens=100, client_args={"http_client": client, "api_format": "chat"}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("usage")
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello")
+ )
+ ]
+ assert provider_service.body()["stream_options"] == {"include_usage": True}
+ usage = next(event.data for event in events if event.kind == "usage")
+ assert usage["usage"]["input_tokens"] == 3
+ assert usage["usage"]["output_tokens"] == 2
+ assert usage["usage"]["total_tokens"] == 5
+ assert usage["elapsed_seconds"] >= 0
+ assert events[-1].data == {"ok": True, "text": "done"}
+ assert (await tape.info()).last_token_usage == 5
+
+
+@pytest.mark.asyncio
+async def test_anthropic_request_enables_caching_and_generation_options(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(
+ sse([
+ {"type": "message_start", "message": {"id": "test", "usage": {}}},
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "done"}},
+ {"type": "message_stop"},
+ ])
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(
+ AgentSettings(max_tokens=100, client_args={"http_client": client}, completion_args={"temperature": 0.2})
+ )
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cache")
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape, model="anthropic:test", tools=[], system_prompt=None, prompt=to_content("hello")
+ )
+ ]
+ assert provider_service.body()["cache_control"] == {"type": "ephemeral"}
+ assert provider_service.body()["temperature"] == 0.2
+ assert provider_service.body()["max_tokens"] == 100
+ assert events[-1].data == {"ok": True, "text": "done"}
+
+
+@pytest.mark.asyncio
+async def test_session_reasoning_and_runtime_options_override_completion_defaults(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(sse(chat_events()))
+ async with provider_service.client() as client:
+ runner = StepRunner(
+ AgentSettings(
+ client_args={
+ "http_client": client,
+ "api_format": "chat",
+ "extra_body": {"metadata": {"client": True}},
+ },
+ completion_args={
+ "reasoning_effort": "low",
+ "tools": [republic.Tool("ignored")],
+ "max_tokens": 1,
+ "extra_body": {"metadata": {"request": True}},
+ },
+ )
+ )
+ tape = Tape(
+ AsyncTapeStoreAdapter(InMemoryTapeStore()),
+ TapeContext(anchor=None, state={"reasoning_effort": "high"}),
+ ).scoped("reasoning")
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello")
+ )
+ ]
+ request = provider_service.body()
+ assert request["reasoning_effort"] == "high"
+ assert request["model"] == "test"
+ assert request["messages"] == [{"role": "user", "content": "hello"}]
+ assert request["max_completion_tokens"] == 16384
+ assert request["stream"] is True
+ assert "tools" not in request
+ assert request["metadata"] == {"client": True, "request": True}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("request_reasoning", [None, "medium"])
+async def test_wire_reasoning_follows_republic_extra_body_precedence(
+ tmp_path: Path, provider_service: ProviderService, request_reasoning: str | None
+) -> None:
+ provider_service.reply(sse(chat_events()))
+ async with provider_service.client() as client:
+ runner = StepRunner(
+ AgentSettings(
+ client_args={"http_client": client, "api_format": "chat", "extra_body": {"reasoning_effort": "low"}},
+ completion_args={"extra_body": {"reasoning_effort": request_reasoning} if request_reasoning else {}},
+ )
+ )
+ tape = Tape(
+ AsyncTapeStoreAdapter(InMemoryTapeStore()),
+ TapeContext(anchor=None, state={"reasoning_effort": "high"}),
+ ).scoped("wire-reasoning")
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello")
+ )
+ ]
+ assert provider_service.body()["reasoning_effort"] == (request_reasoning or "low")
+
+
+@pytest.mark.asyncio
+async def test_truncated_stream_never_executes_tools(tmp_path: Path, provider_service: ProviderService) -> None:
+ provider_service.reply(
+ sse([
+ {
+ "choices": [
+ {
+ "delta": {
+ "tool_calls": [
+ {"index": 0, "id": "call-1", "function": {"name": "echo", "arguments": "{}"}}
+ ]
+ }
+ }
+ ]
+ }
+ ])
+ )
+ invoked = []
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("truncated")
+ with pytest.raises(republic.errors.StreamIncompleteError):
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape,
+ model="openai:test",
+ tools=[Tool(name="echo", handler=lambda: invoked.append(True))],
+ system_prompt=None,
+ prompt=to_content("hello"),
+ )
+ ]
+ assert not invoked
+ assert not await tape.store.fetch_all(tape.query().kinds("tool_call", "tool_result"))
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("next_model", ["google:model-one", "google:model-two", "openai:model-one"])
+async def test_provider_state_is_replayed_only_to_its_api_format_after_reload(
+ tmp_path: Path, provider_service: ProviderService, next_model: str
+) -> None:
+ opaque = {"executableCode": {"language": "PYTHON", "code": "1 + 1"}}
+ provider_service.reply(
+ sse([
+ {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ opaque,
+ {
+ "functionCall": {"id": "call-1", "name": "lookup", "args": {}},
+ "thoughtSignature": "signature",
+ },
+ ]
+ },
+ "finishReason": "STOP",
+ }
+ ]
+ }
+ ])
+ )
+ provider_service.reply(
+ sse(
+ chat_events()
+ if next_model.startswith("openai:")
+ else [{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}]
+ )
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client}))
+ tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("metadata")
+ await tape.ensure_bootstrap_anchor()
+ tools = [Tool(name="lookup", handler=lambda: "lookup result")]
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape, model="google:model-one", tools=tools, system_prompt=None, prompt=to_content("lookup")
+ )
+ ]
+ reopened = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("metadata")
+ if next_model.startswith("openai:"):
+ runner.settings.client_args["api_format"] = "chat"
+ events = [
+ event
+ async for event in runner.run(
+ tape=reopened, model=next_model, tools=tools, system_prompt=None, prompt=to_content("Continue.")
+ )
+ ]
+ request = json.dumps(provider_service.body())
+ assert "lookup result" in request
+ assert ("signature" in request) is next_model.startswith("google:")
+ assert ("executableCode" in request) is next_model.startswith("google:")
+ assert events[-1].data == {"ok": True, "text": "done"}
+
+
+@pytest.mark.asyncio
+async def test_named_tool_choice_is_sent_to_anthropic(tmp_path: Path, provider_service: ProviderService) -> None:
+ provider_service.reply(
+ sse([
+ {"type": "message_start", "message": {"id": "test", "usage": {}}},
+ {"type": "message_stop"},
+ ])
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(
+ AgentSettings(client_args={"http_client": client}, completion_args={"tool_choice": republic.Tool("echo")})
+ )
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("choice")
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape,
+ model="anthropic:test",
+ tools=[Tool(name="echo", handler=lambda: "done")],
+ system_prompt=None,
+ prompt=to_content("hello"),
+ )
+ ]
+ assert provider_service.body()["tool_choice"] == {"type": "tool", "name": "echo"}
diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py
new file mode 100644
index 00000000..7194d045
--- /dev/null
+++ b/tests/test_core_boundary.py
@@ -0,0 +1,43 @@
+import subprocess
+import sys
+
+from bub.builtin.store import FuzzyFileTapeStore
+from bub.core.store import FileTapeStore, TapeQuery
+from bub.core.tape import TapeEntry
+
+APP_MODULES = ("typer", "pluggy", "pydantic_settings", "dotenv", "yaml", "rapidfuzz", "telegram", "rich")
+
+
+def test_importing_core_loads_no_app_dependencies() -> None:
+ script = (
+ "import sys, bub.core\n"
+ "loaded = {name.split('.')[0] for name in sys.modules}\n"
+ f"print(sorted(loaded & set({APP_MODULES!r})))\n"
+ "print('bub.framework' in sys.modules)\n"
+ )
+ result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True)
+
+ assert result.stdout.splitlines() == ["[]", "False"]
+
+
+def _search(store: FileTapeStore, text: str) -> list[str]:
+ return [entry.payload["content"] for entry in store.fetch_all(TapeQuery("tape", store).query(text))]
+
+
+def _write_messages(store: FileTapeStore) -> None:
+ for content in ["deploy the gateway", "unrelated", "deploy the gateway", "deploy again"]:
+ store.append("tape", TapeEntry.message({"role": "user", "content": content}))
+
+
+def test_core_file_store_matches_text_in_order(tmp_path) -> None:
+ store = FileTapeStore(tmp_path)
+ _write_messages(store)
+
+ assert _search(store, "deploy") == ["deploy the gateway", "deploy the gateway", "deploy again"]
+
+
+def test_builtin_file_store_returns_newest_unique_matches_first(tmp_path) -> None:
+ store = FuzzyFileTapeStore(tmp_path)
+ _write_messages(store)
+
+ assert _search(store, "deploy") == ["deploy again", "deploy the gateway"]
diff --git a/tests/test_core_codemode.py b/tests/test_core_codemode.py
new file mode 100644
index 00000000..4e84c507
--- /dev/null
+++ b/tests/test_core_codemode.py
@@ -0,0 +1,141 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+import pytest
+import republic
+
+from bub.core import Agent
+from bub.core.codemode import CodeMode, default_model_resolver
+from bub.core.events import ToolCallStarted
+from bub.core.store import InMemoryTapeStore
+from bub.core.tools import Tool, ToolContext, ToolExecutor
+from bub.environment import LocalEnvironment
+from tests.model_fakes import ProviderService, chat_events, sse, tool_events
+from tests.test_core_boundary import APP_MODULES
+
+
+def lookup(order_id: str) -> dict[str, Any]:
+ """Look up an order."""
+ return {"id": order_id, "total": 12.5}
+
+
+def _run_code_call(code: str) -> object:
+ arguments = json.dumps({"code": code})
+ return sse(tool_events([{"index": 0, "id": "call-1", "function": {"name": "run_code", "arguments": arguments}}]))
+
+
+def test_importing_core_codemode_loads_no_app_dependencies() -> None:
+ script = (
+ "import sys, bub.core.codemode\n"
+ "loaded = {name.split('.')[0] for name in sys.modules}\n"
+ f"print(sorted(loaded & set({APP_MODULES!r})))\n"
+ "print(sorted(name for name in sys.modules if name.startswith(('bub.builtin', 'bub.framework'))))\n"
+ )
+ result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True)
+
+ assert result.stdout.splitlines() == ["[]", "[]"]
+
+
+@pytest.mark.asyncio
+async def test_core_agent_calls_tools_from_code(provider_service: ProviderService, tmp_path: Path) -> None:
+ provider_service.reply(_run_code_call("order = await tools.orders_get(order_id='A1')\nprint(order['total'])"))
+ provider_service.reply(sse(chat_events("It costs 12.5.")))
+ code_mode = CodeMode([Tool.from_callable(lookup, name="orders.get")], environment=LocalEnvironment(tmp_path))
+ calls: list[tuple[str, bool]] = []
+
+ async with provider_service.client() as client:
+ model = republic.get_model("openai:test", http_client=client, api_format="chat", api_key="test")
+ agent = Agent(
+ store=InMemoryTapeStore(), tools=[code_mode.run_code], system_prompt=["Be brief.", code_mode.prompt]
+ )
+ agent.events.on(
+ ToolCallStarted,
+ lambda event: calls.append((event.call.tool, event.call.context.code_mode)), # type: ignore[union-attr]
+ )
+ async with agent.run(["How much is order A1?"], model=model, tape="session") as stream:
+ events = [event async for event in stream]
+
+ assert events[-1].data == {"ok": True, "text": "It costs 12.5."}
+ first, second = provider_service.body(0), provider_service.body(1)
+ assert [tool["function"]["name"] for tool in first["tools"]] == ["run_code"]
+ system_prompt = first["messages"][0]["content"]
+ assert system_prompt.startswith("Be brief.\n\n")
+ assert "async def orders_get(*, order_id: str) -> dict[str, Any]:" in system_prompt
+ assert "async def attach_image(" in system_prompt
+ assert second["messages"][-1] == {"role": "tool", "tool_call_id": "call-1", "content": "12.5\n"}
+ assert calls == [("run_code", False), ("orders_get", True)]
+
+
+def test_code_mode_prompt_points_at_a_stub_file_in_stub_dir(tmp_path: Path) -> None:
+ code_mode = CodeMode([Tool.from_callable(lookup)], stub_dir=tmp_path / "stubs", media_tools=False)
+
+ prompt = code_mode.prompt(_prompt_context())
+
+ assert prompt is not None
+ stub_path = next((tmp_path / "stubs").iterdir())
+ assert f"in the stub file: {stub_path}\n" in prompt
+ assert "async def lookup(*, order_id: str)" in stub_path.read_text(encoding="utf-8")
+ assert "attach_image" not in stub_path.read_text(encoding="utf-8").split("# `republic`")[0]
+
+
+@pytest.mark.asyncio
+async def test_code_mode_selects_tools_per_run_from_state(tmp_path: Path) -> None:
+ code_mode = CodeMode(lambda state: state.get("code_tools"), environment=LocalEnvironment(tmp_path))
+ tape = Agent().tape("session")
+
+ assert code_mode.prompt(_prompt_context()) is None
+ execution = await ToolExecutor().execute_many(
+ [(code_mode.run_code, {"code": "print(1)"})], context=ToolContext(tape=tape)
+ )
+ assert execution.error is not None
+ assert execution.error.message == "Code mode is not enabled for this run."
+
+ state = {"code_tools": [Tool.from_callable(lookup)]}
+ output = await code_mode.run_code.run(
+ code="print((await tools.lookup(order_id='B2'))['id'])", context=ToolContext(tape=tape, state=state)
+ )
+ assert output == "B2\n"
+ assert [tool.name for tool in code_mode.code_tools(state) or []] == [
+ "lookup",
+ "attach_image",
+ "attach_audio",
+ "attach_video",
+ ]
+
+
+def test_default_model_resolver_needs_a_spec_or_default() -> None:
+ context = ToolContext(tape=Agent().tape("session"))
+
+ assert default_model_resolver("openai:gpt-x")(None, context) == ("openai:gpt-x", {})
+ assert default_model_resolver("openai:gpt-x")("anthropic:claude", context) == ("anthropic:claude", {})
+ with pytest.raises(ValueError, match="No default model"):
+ default_model_resolver()(None, context)
+
+
+@pytest.mark.asyncio
+async def test_code_republic_models_use_the_resolver(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ def resolve(spec: str | None, context: ToolContext) -> tuple[str, dict[str, Any]]:
+ return f"resolved:{spec}", {"api_key": "secret"}
+
+ def get_model(spec: str, **kwargs: Any) -> Any:
+ raise RuntimeError(f"{spec} {kwargs}")
+
+ monkeypatch.setattr(republic, "get_model", get_model)
+ code_mode = CodeMode(environment=LocalEnvironment(tmp_path), models=resolve)
+ code = "try:\n await republic.get_model('x').chat('hi')\nexcept Exception as exc:\n print(exc)\n"
+
+ output = await code_mode.run_code.run(code=code, context=ToolContext(tape=Agent().tape("session")))
+
+ assert output == "[tool] republic.chat failed: RuntimeError: resolved:x {'api_key': 'secret'}\n"
+
+
+def _prompt_context() -> Any:
+ from bub.core import PromptContext
+
+ agent = Agent()
+ return PromptContext(agent=agent, tape=agent.tape("session"), prompt=[], tools=[], model="openai:test")
diff --git a/tests/test_environment.py b/tests/test_environment.py
index 127226aa..77f0c5ae 100644
--- a/tests/test_environment.py
+++ b/tests/test_environment.py
@@ -6,14 +6,14 @@
import pytest
import bub.builtin.tools as builtin_tools
-from bub.builtin.environment import LocalEnvironment, LocalProcess, environment_from_state
+from bub.builtin.environment import environment_from_state
from bub.builtin.shell_manager import ShellManager
-from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, Environment, Process
+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, LocalEnvironment, LocalProcess, Process
from bub.framework import BubFramework
from bub.hooks import hookimpl
-from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore
-from bub.tape import Tape, TapeContext
-from bub.tools import ToolContext
class MemoryEnvironment(Environment):
@@ -60,7 +60,7 @@ async def spawn(
def _context(tmp_path: Path, environment: Environment) -> ToolContext:
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
return ToolContext(
tape=tape, run_id="run", state={"_runtime_workspace": str(tmp_path), ENVIRONMENT_STATE_KEY: environment}
)
@@ -167,3 +167,18 @@ async def test_framework_has_no_environment_without_a_provider(tmp_path: Path) -
assert await framework.get_environment("session") is None
assert ENVIRONMENT_STATE_KEY not in state
+
+
+@pytest.mark.asyncio
+async def test_default_read_bytes_reads_files_through_the_environment_python(tmp_path: Path) -> None:
+ class SpawnOnlyEnvironment(RecordingEnvironment):
+ async def read_bytes(self, path: str) -> bytes:
+ return await Environment.read_bytes(self, path)
+
+ environment = SpawnOnlyEnvironment(tmp_path)
+ (tmp_path / "blob.bin").write_bytes(b"\x00\xffbinary")
+
+ assert await environment.read_bytes(str(tmp_path / "blob.bin")) == b"\x00\xffbinary"
+ assert len(environment.spawned) == 1
+ with pytest.raises(OSError, match="FileNotFoundError"):
+ await environment.read_bytes(str(tmp_path / "missing.bin"))
diff --git a/tests/test_file_tape_store_entry_ids.py b/tests/test_file_tape_store_entry_ids.py
index 0f73b91e..1f046fe9 100644
--- a/tests/test_file_tape_store_entry_ids.py
+++ b/tests/test_file_tape_store_entry_ids.py
@@ -2,9 +2,9 @@
import pytest
-from bub.sidecars import sidecar_tape_name
-from bub.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore
-from bub.tape import TapeEntry
+from bub.core.sidecars import sidecar_tape_name
+from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore
+from bub.core.tape import TapeEntry
@pytest.mark.asyncio
diff --git a/tests/test_fork_store_merge_back.py b/tests/test_fork_store_merge_back.py
index e614900e..8e756c70 100644
--- a/tests/test_fork_store_merge_back.py
+++ b/tests/test_fork_store_merge_back.py
@@ -2,8 +2,8 @@
import pytest
-from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery
-from bub.tape import TapeEntry
+from bub.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery
+from bub.core.tape import TapeEntry
@pytest.mark.asyncio
diff --git a/tests/test_framework.py b/tests/test_framework.py
index dc35f96b..51243f2e 100644
--- a/tests/test_framework.py
+++ b/tests/test_framework.py
@@ -8,6 +8,7 @@
from unittest.mock import patch
import pytest
+import republic
import typer
from typer.testing import CliRunner
@@ -145,6 +146,25 @@ def provide_tape_sidecar(self) -> Sidecar:
assert cast(Any, sidecars["shared"]).source == "high"
+@pytest.mark.asyncio
+async def test_build_prompt_converts_legacy_content_blocks_by_value() -> None:
+ framework = BubFramework()
+
+ class LegacyPlugin:
+ @hookimpl
+ def build_prompt(self, message, session_id, state):
+ return [
+ {"type": "text", "text": "describe"},
+ {"type": "image", "media_type": "image/png", "url": "https://example.test/a.png"},
+ ]
+
+ framework.plugin_manager.register(LegacyPlugin(), name="legacy")
+
+ prompt = await framework.build_prompt({"content": "ignored"}, session_id="s", state={})
+
+ assert prompt == ["describe", republic.Image("image/png", url="https://example.test/a.png")]
+
+
@pytest.mark.asyncio
async def test_continue_prompt_awaits_high_priority_async_hook() -> None:
framework = BubFramework()
@@ -162,6 +182,7 @@ class AsyncPlugin:
@hookimpl
async def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> str:
called.append("async")
+ # Hooks receive the legacy prompt form.
assert prompt == "current prompt"
assert state.usage == {"total_tokens": 42}
return "async prompt"
@@ -169,7 +190,7 @@ async def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> s
framework.plugin_manager.register(SyncPlugin(), name="sync")
framework.plugin_manager.register(AsyncPlugin(), name="async")
- prompt = await framework.continue_prompt(prompt="current prompt", tape=tape, state=state)
+ prompt = await framework.continue_prompt(prompt=["current prompt"], tape=tape, state=state)
assert prompt == "async prompt"
assert called == ["async"]
@@ -191,7 +212,7 @@ def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> None:
framework.plugin_manager.register(NoPromptPlugin())
result = await framework.continue_prompt(
- prompt="initial prompt", tape=cast(Any, SimpleNamespace()), state=StreamState()
+ prompt=["initial prompt"], tape=cast(Any, SimpleNamespace()), state=StreamState()
)
assert result is None
diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py
index a336d679..3605d401 100644
--- a/tests/test_hook_runtime.py
+++ b/tests/test_hook_runtime.py
@@ -1,5 +1,6 @@
import pluggy
import pytest
+import republic
from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl
from bub.hooks.runtime import HookRuntime
@@ -139,3 +140,27 @@ async def run_model(self, prompt, session_id, state):
assert stream is not None
events = [event async for event in stream]
assert [(event.kind, event.data) for event in events] == [("text", {"delta": "plain"})]
+
+
+@pytest.mark.asyncio
+async def test_model_hooks_receive_the_legacy_prompt_form() -> None:
+ received: list[object] = []
+
+ class PlainPlugin:
+ @hookimpl
+ async def run_model(self, prompt, session_id, state):
+ received.append(prompt)
+ return "plain"
+
+ runtime = _runtime_with_plugins(("plain", PlainPlugin()))
+
+ await runtime.run_model(prompt=["hello", "world"], session_id="s", state={})
+ await runtime.run_model(prompt=["look", republic.Image("image/png", data=b"png")], session_id="s", state={})
+
+ assert received == [
+ "hello\nworld",
+ [
+ {"type": "text", "text": "look"},
+ {"type": "image", "media_type": "image/png", "url": "data:image/png;base64,cG5n"},
+ ],
+ ]
diff --git a/tests/test_image_message.py b/tests/test_image_message.py
index fb19fc03..486db122 100644
--- a/tests/test_image_message.py
+++ b/tests/test_image_message.py
@@ -2,16 +2,17 @@
from __future__ import annotations
-import base64
from pathlib import Path
from types import SimpleNamespace
import pytest
+import republic
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.framework import BubFramework
+from bub.prompt import prompt_text, to_content, to_legacy_prompt
# ---------------------------------------------------------------------------
# MediaItem & ChannelMessage
@@ -298,18 +299,19 @@ def _build_impl(tmp_path: Path) -> tuple[BubFramework, BuiltinImpl]:
@pytest.mark.asyncio
-async def test_build_prompt_returns_string_without_media(tmp_path: Path) -> None:
+async def test_build_prompt_returns_text_without_media(tmp_path: Path) -> None:
_, impl = _build_impl(tmp_path)
message = ChannelMessage(session_id="s", channel="tg", content="hello")
result = await impl.build_prompt(message, session_id="s", state={})
- assert isinstance(result, str)
- assert "hello" in result
+ assert len(result) == 1
+ assert isinstance(result[0], str)
+ assert "hello" in result[0]
@pytest.mark.asyncio
-async def test_build_prompt_returns_multimodal_parts_with_image_media(tmp_path: Path) -> None:
+async def test_build_prompt_returns_text_and_inline_image(tmp_path: Path) -> None:
_, impl = _build_impl(tmp_path)
message = ChannelMessage(
session_id="s",
@@ -320,17 +322,10 @@ async def test_build_prompt_returns_multimodal_parts_with_image_media(tmp_path:
result = await impl.build_prompt(message, session_id="s", state={})
- assert isinstance(result, list)
assert len(result) == 2
-
- text_part = result[0]
- assert text_part["type"] == "text"
- assert "describe this" in text_part["text"]
-
- image_part = result[1]
- assert image_part["type"] == "image_url"
- expected = base64.b64encode(b"\xff\xd8").decode("utf-8")
- assert image_part["image_url"]["url"] == f"data:image/jpeg;base64,{expected}"
+ assert isinstance(result[0], str)
+ assert "describe this" in result[0]
+ assert result[1] == republic.Image("image/jpeg", data=b"\xff\xd8")
@pytest.mark.asyncio
@@ -348,14 +343,11 @@ async def test_build_prompt_with_multiple_images(tmp_path: Path) -> None:
result = await impl.build_prompt(message, session_id="s", state={})
- assert isinstance(result, list)
- assert len(result) == 3
- assert result[1]["type"] == "image_url"
- assert result[2]["type"] == "image_url"
+ assert result[1:] == [republic.Image("image/jpeg", data=b"A"), republic.Image("image/jpeg", data=b"B")]
@pytest.mark.asyncio
-async def test_build_prompt_returns_video_url_part_with_video_media(tmp_path: Path) -> None:
+async def test_build_prompt_returns_inline_video(tmp_path: Path) -> None:
_, impl = _build_impl(tmp_path)
message = ChannelMessage(
session_id="s",
@@ -366,15 +358,9 @@ async def test_build_prompt_returns_video_url_part_with_video_media(tmp_path: Pa
result = await impl.build_prompt(message, session_id="s", state={})
- assert isinstance(result, list)
assert len(result) == 2
- assert result[0]["type"] == "text"
- assert "describe this video" in result[0]["text"]
- expected = base64.b64encode(b"video").decode("utf-8")
- assert result[1] == {
- "type": "video_url",
- "video_url": {"url": f"data:video/mp4;base64,{expected}"},
- }
+ assert "describe this video" in result[0]
+ assert result[1] == republic.Video("video/mp4", data=b"video")
@pytest.mark.asyncio
@@ -389,16 +375,16 @@ async def test_build_prompt_skips_video_when_download_is_too_large(tmp_path: Pat
result = await impl.build_prompt(message, session_id="s", state={})
- assert isinstance(result, str)
- assert "describe this video" in result
+ assert len(result) == 1
+ assert "describe this video" in result[0]
@pytest.mark.asyncio
@pytest.mark.parametrize(
- ("mime_type", "expected_format"),
- [("audio/mpeg", "mp3"), ("audio/ogg", "ogg"), ("audio/x-wav", "wav")],
+ "mime_type",
+ ["audio/mpeg", "audio/ogg", "audio/x-wav"],
)
-async def test_build_prompt_returns_input_audio_part(tmp_path: Path, mime_type: str, expected_format: str) -> None:
+async def test_build_prompt_returns_inline_audio(tmp_path: Path, mime_type: str) -> None:
_, impl = _build_impl(tmp_path)
message = ChannelMessage(
session_id="s",
@@ -409,20 +395,12 @@ async def test_build_prompt_returns_input_audio_part(tmp_path: Path, mime_type:
result = await impl.build_prompt(message, session_id="s", state={})
- assert isinstance(result, list)
- assert result[0]["type"] == "text"
- assert "listen to this" in result[0]["text"]
- assert result[1] == {
- "type": "input_audio",
- "input_audio": {
- "data": base64.b64encode(b"audio").decode("utf-8"),
- "format": expected_format,
- },
- }
+ assert "listen to this" in result[0]
+ assert result[1] == republic.Audio(mime_type, data=b"audio")
@pytest.mark.asyncio
-async def test_build_prompt_skips_remote_audio_url(tmp_path: Path) -> None:
+async def test_build_prompt_preserves_remote_audio_url(tmp_path: Path) -> None:
_, impl = _build_impl(tmp_path)
message = ChannelMessage(
session_id="s",
@@ -433,8 +411,7 @@ async def test_build_prompt_skips_remote_audio_url(tmp_path: Path) -> None:
result = await impl.build_prompt(message, session_id="s", state={})
- assert isinstance(result, str)
- assert "listen to this" in result
+ assert result[1] == republic.Audio("audio/ogg", url="https://example.com/audio.ogg")
@pytest.mark.asyncio
@@ -449,35 +426,32 @@ async def test_build_prompt_command_ignores_media(tmp_path: Path) -> None:
result = await impl.build_prompt(message, session_id="s", state={})
- assert isinstance(result, str)
- assert result == ",help"
+ assert result == [",help"]
assert message.kind == "command"
# ---------------------------------------------------------------------------
-# _extract_text_from_parts
+# Prompt content compatibility
# ---------------------------------------------------------------------------
-def test_extract_text_from_parts() -> None:
- from bub.builtin.agent import _extract_text_from_parts
-
- parts = [
+def test_legacy_prompt_blocks_convert_to_user_content() -> None:
+ content = to_content([
{"type": "text", "text": "hello"},
- {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,X"}},
+ {"type": "image", "media_type": "image/jpeg", "url": "data:image/jpeg;base64,WA=="},
{"type": "text", "text": "world"},
- ]
- assert _extract_text_from_parts(parts) == "hello\nworld"
-
-
-def test_extract_text_from_parts_empty() -> None:
- from bub.builtin.agent import _extract_text_from_parts
+ ])
- assert _extract_text_from_parts([]) == ""
+ assert content == ["hello", republic.Image("image/jpeg", data=b"X"), "world"]
+ assert prompt_text(content) == "hello\nworld"
+ assert to_content("hello") == ["hello"]
+ assert to_content(content) == content
-def test_extract_text_from_parts_no_text_parts() -> None:
- from bub.builtin.agent import _extract_text_from_parts
-
- parts = [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,X"}}]
- assert _extract_text_from_parts(parts) == ""
+def test_user_content_converts_back_to_legacy_prompt() -> None:
+ assert to_legacy_prompt(["hello", "world"]) == "hello\nworld"
+ assert to_legacy_prompt([]) == ""
+ assert to_legacy_prompt(["look", republic.Image("image/png", url="https://example.test/a.png")]) == [
+ {"type": "text", "text": "look"},
+ {"type": "image", "media_type": "image/png", "url": "https://example.test/a.png"},
+ ]
diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py
index 81cc08b7..1bdd83e1 100644
--- a/tests/test_install_scripts.py
+++ b/tests/test_install_scripts.py
@@ -173,7 +173,7 @@ def test_install_sh_uses_existing_uv_with_minimal_preset(tmp_path: Path) -> None
venv_path = tmp_path / "home" / ".bub" / ".venv"
assert uv_calls[1:] == [
f"venv --python 3.12 --allow-existing {venv_path}",
- f"pip install --python {venv_path / 'bin' / 'python'} --upgrade bub",
+ f"pip install --python {venv_path / 'bin' / 'python'} --upgrade bub[app]",
]
executable_link = tmp_path / "home" / ".local" / "bin" / "bub"
assert executable_link.is_symlink()
diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py
index 2ae8478b..1c68bd1a 100644
--- a/tests/test_onboarding.py
+++ b/tests/test_onboarding.py
@@ -3,26 +3,25 @@
import asyncio
import os
from pathlib import Path
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, Mock, patch
+from unittest.mock import Mock, patch
-import httpx
+import httpx2 as httpx
import pytest
+import republic
import typer
-from any_llm import AnyLLM
-from any_llm.constants import LLMProvider
-from any_llm.exceptions import AuthenticationError
+from republic.errors import APITimeoutError, AuthenticationError, UnsupportedFeatureError
from typer.testing import CliRunner
from bub import configure, inquirer
-from bub.builtin import codex_provider, onboarding
+from bub.builtin import onboarding
+from bub.builtin.onboarding import discover_models
+from bub.builtin.settings import USER_AGENT
from bub.framework import BubFramework
from bub.hooks import hookimpl
@pytest.fixture(autouse=True)
def isolated_environment(monkeypatch):
- monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: None)
with patch.dict(os.environ, {}, clear=True):
yield
@@ -64,7 +63,7 @@ def discover(provider, api_base=None, api_key=None, **kwargs):
return answers, calls
-@pytest.mark.parametrize("provider", [p for p in onboarding.PROVIDERS if p != "custom"])
+@pytest.mark.parametrize("provider", [p for p in onboarding.PROVIDERS if p not in {"custom", "codex"}])
def test_all_providers_check_connection_before_selecting_models(prompts, provider):
answers, calls = prompts
answers["provider"] = provider
@@ -73,7 +72,7 @@ def test_all_providers_check_connection_before_selecting_models(prompts, provide
names = [name for name, _ in calls]
assert names[0] == "LLM provider"
assert names.index("API key (optional)") < names.index("discover") < names.index("LLM model (type to search)")
- if provider in {"openai-compatible", "azure", "ollama"}:
+ if provider in {"openai-compatible", "azure-openai", "ollama", "magpie"}:
assert names.index("API base URL") < names.index("API key (optional)")
else:
assert "API base URL" not in names
@@ -120,7 +119,12 @@ def discover(provider, api_base=None, api_key=None, **kwargs):
config = onboarding.collect_model_config({})
assert probes == [("openai", "https://wrong.test/v1", "bad-key"), ("openai", "https://correct.test/v1", "good-key")]
- assert config == {"model": "openai:model-b", "api_base": "https://correct.test/v1", "api_key": "good-key"}
+ assert config == {
+ "model": "openai:model-b",
+ "api_base": "https://correct.test/v1",
+ "api_key": "good-key",
+ "client_args": {"api_format": "chat"},
+ }
assert [name for name, _ in calls].count("LLM provider") == 1
assert "bad-key" not in capsys.readouterr().out
@@ -162,18 +166,24 @@ def discover(provider, **kwargs):
monkeypatch.setattr(onboarding, "discover_models", discover)
config = onboarding.collect_model_config({})
- assert probes == [
- {"api_base": "https://first.test/v1", "api_key": "first-key"},
- {"api_base": "https://first.test/v1", "api_key": "first-key"},
- {"api_base": "https://second.test/v1", "api_key": "second-key"},
- ]
+ headers = {"User-Agent": USER_AGENT}
+ first = {
+ "api_base": "https://first.test/v1",
+ "api_key": "first-key",
+ "env_prefix": "BUB_OPENAI",
+ "api_format": "chat",
+ "headers": headers,
+ }
+ second = {**first, "api_base": "https://second.test/v1", "api_key": "second-key"}
+ assert probes == [first, first, second]
+ assert config["client_args"] == {"api_format": "chat"}
assert probes[-1] == onboarding.AgentSettings.model_validate(config).model_client_kwargs("openai")
assert [name for name, _ in calls].count("LLM provider") == 1
def test_unsupported_discovery_goes_directly_to_manual_entry(prompts, monkeypatch, capsys):
_, calls = prompts
- monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=NotImplementedError))
+ monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=UnsupportedFeatureError("not supported")))
menu = Mock(side_effect=AssertionError("Unavailable discovery must not offer edit/retry"))
monkeypatch.setattr(inquirer, "ask_select", menu)
@@ -185,28 +195,15 @@ def test_unsupported_discovery_goes_directly_to_manual_entry(prompts, monkeypatc
menu.assert_not_called()
-def test_azure_capability_skips_client_creation_and_prompts_for_model(prompts, monkeypatch, capsys):
+def test_unavailable_discovery_goes_to_manual_entry(prompts, monkeypatch, capsys):
answers, _ = prompts
- answers["provider"] = "azure"
- provider_class = SimpleNamespace(
- SUPPORTS_LIST_MODELS=False, API_BASE=None, ENV_API_BASE_NAME="AZURE_AI_CHAT_ENDPOINT"
- )
- monkeypatch.setattr(AnyLLM, "get_provider_class", lambda provider: provider_class)
- create = Mock(side_effect=AssertionError("Unsupported discovery must not construct an SDK client"))
- monkeypatch.setattr(AnyLLM, "create", create)
- monkeypatch.setattr(
- onboarding,
- "discover_models",
- lambda provider, **kwargs: asyncio.run(onboarding._discover_models(provider, **kwargs)),
- )
+ answers["provider"] = "azure-openai"
+ monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=UnsupportedFeatureError("not supported")))
menu = Mock(side_effect=AssertionError("Unsupported discovery must not offer retry"))
monkeypatch.setattr(inquirer, "ask_select", menu)
-
config = onboarding.collect_model_config({})
-
- assert config == {"model": "azure:model-b", "api_base": answers["base"], "api_key": answers["key"]}
+ assert config["model"] == "azure-openai:model-b"
assert "Model discovery is unavailable" in capsys.readouterr().out
- create.assert_not_called()
menu.assert_not_called()
@@ -231,12 +228,12 @@ def test_successful_discovery_still_allows_unlisted_model(prompts, monkeypatch):
assert onboarding.collect_model_config({})["model"] == "openai:private-model"
-def test_compatible_server_without_auth_gets_a_working_saved_key(prompts):
+def test_compatible_server_without_auth_keeps_auth_unset(prompts):
answers, calls = prompts
answers.update(provider="openai-compatible", key="")
config = onboarding.collect_model_config({})
- assert config["api_key"] == "not-required"
- assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "not-required")
+ assert "api_key" not in config
+ assert dict(calls)["discover"] == ("openai", "https://example.test/v1", None)
def test_environment_key_is_used_for_check_but_not_written_to_config(prompts, monkeypatch):
@@ -249,64 +246,60 @@ def test_environment_key_is_used_for_check_but_not_written_to_config(prompts, mo
assert "api_base" not in config
-@pytest.mark.parametrize("variable", ["BUB_API_BASE", "BUB_OPENAI_API_BASE"])
-def test_connection_check_honors_environment_overrides_and_client_options(prompts, monkeypatch, capsys, variable):
- monkeypatch.setenv(variable, "https://override.test/v1")
- received = []
+@pytest.fixture
+def model_discovery(prompts, monkeypatch):
+ requests = []
+ constructor = httpx.AsyncClient
- def discover(*args, **kwargs):
- received.append((args, kwargs))
- return ["model-a", "model-b"]
+ def respond(request):
+ requests.append(request)
+ return httpx.Response(200, json={"data": [{"id": "model-a"}, {"id": "model-b"}]})
- monkeypatch.setattr(onboarding, "discover_models", discover)
- onboarding.collect_model_config({"client_args": {"default_headers": {"X-Workspace": "test"}}})
- assert received == [
- (
- ("openai",),
- {"api_base": "https://override.test/v1", "api_key": "test-key", "default_headers": {"X-Workspace": "test"}},
- )
- ]
- assert "environment settings override" in capsys.readouterr().out
+ monkeypatch.setattr(onboarding, "discover_models", discover_models)
+ monkeypatch.setattr(
+ httpx, "AsyncClient", lambda **kwargs: constructor(**kwargs, transport=httpx.MockTransport(respond))
+ )
+ return requests
-def test_connection_check_overrides_auth_fields_in_client_args_like_runtime(prompts, monkeypatch):
- received = []
+@pytest.mark.parametrize("variable", ["BUB_API_BASE", "BUB_OPENAI_API_BASE"])
+def test_connection_check_honors_environment_endpoint_and_headers(model_discovery, monkeypatch, capsys, variable):
+ monkeypatch.setenv(variable, "https://override.test/v1")
+ config = onboarding.collect_model_config({"client_args": {"headers": {"X-Workspace": "test"}}})
+ assert str(model_discovery[0].url) == "https://override.test/v1/models"
+ assert model_discovery[0].headers["X-Workspace"] == "test"
+ assert model_discovery[0].headers["authorization"] == "Bearer test-key"
+ assert config["model"] == "openai:model-b"
+ assert "environment settings override" in capsys.readouterr().out
- def discover(*args, **kwargs):
- received.append((args, kwargs))
- return ["model-a", "model-b"]
- monkeypatch.setattr(onboarding, "discover_models", discover)
- onboarding.collect_model_config({
- "client_args": {
- "api_key": "unused-key",
- "api_base": "https://unused.test/v1",
- "timeout": 5,
- }
+def test_connection_check_uses_entered_credentials_and_endpoint(model_discovery):
+ config = onboarding.collect_model_config({
+ "client_args": {"api_key": "unused-key", "api_base": "https://unused.test/v1", "timeout": 5}
})
- assert received == [(("openai",), {"api_base": None, "api_key": "test-key", "timeout": 5})]
+ assert str(model_discovery[0].url) == "https://api.openai.com/v1/models"
+ assert model_discovery[0].headers["authorization"] == "Bearer test-key"
+ assert config["model"] == "openai:model-b"
@pytest.mark.parametrize("compatible", [False, True])
-def test_other_provider_config_does_not_shadow_environment_connection(prompts, monkeypatch, compatible):
- answers, calls = prompts
+def test_other_provider_config_does_not_shadow_environment_connection(
+ prompts, model_discovery, monkeypatch, compatible
+):
+ answers, _ = prompts
answers.update(provider="openai-compatible" if compatible else "openai", key="")
monkeypatch.setenv("BUB_OPENAI_API_KEY", "environment-key")
monkeypatch.setenv("BUB_OPENAI_API_BASE", "https://example.test/v1")
- current = {
- "api_key": {"anthropic": "other-key"},
- "api_base": {"anthropic": "https://other.test"},
- }
+ current = {"api_key": {"anthropic": "other-key"}, "api_base": {"anthropic": "https://other.test"}}
update = onboarding.collect_model_config(current)
- assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "environment-key")
+ assert str(model_discovery[0].url) == "https://example.test/v1/models"
+ assert model_discovery[0].headers["authorization"] == "Bearer environment-key"
assert "api_key" not in update
merged = configure.merge({}, current, update)
- settings = onboarding.AgentSettings.model_validate(merged)
- assert settings.model_client_kwargs(LLMProvider.OPENAI) == {
- "api_base": "https://example.test/v1",
- "api_key": "environment-key",
- }
assert merged["api_key"] == {"anthropic": "other-key"}
+ assert merged["model"] == "openai:model-b"
+ if compatible:
+ assert merged["client_args"]["api_format"] == "chat"
def test_provider_credentials_keep_other_providers_when_merged(prompts):
@@ -354,9 +347,9 @@ def test_changed_endpoint_clears_saved_credentials_even_with_the_same_menu_choic
"api_base": "https://old.test/v1",
"api_key": "old-key",
})
- assert dict(calls)["discover"] == ("openai", answers["base"], "not-required")
+ assert dict(calls)["discover"] == ("openai", answers["base"], None)
assert "API key (Enter to keep current key)" not in dict(calls)
- assert config["api_key"] == "not-required"
+ assert config["api_key"] is None
def test_editing_endpoint_clears_key_before_blank_key_is_reused(prompts, monkeypatch):
@@ -379,7 +372,7 @@ def discover(provider, **kwargs):
"api_base": "https://first.test/v1",
"api_key": "saved-key",
})
- assert [probe["api_key"] for probe in probes] == ["saved-key", "not-required"]
+ assert [probe["api_key"] for probe in probes] == ["saved-key", None]
assert [name for name, _ in calls].count("API key (Enter to keep current key)") == 1
assert config["api_base"] == "https://second.test/v1"
@@ -431,7 +424,7 @@ def test_blank_compatible_key_uses_environment_for_discovery_and_saved_config(pr
monkeypatch.setattr(
onboarding, "discover_models", lambda *args, **kwargs: asyncio.run(real_discover(*args, **kwargs))
)
- real_create = AnyLLM.create
+ real_create = republic.get_provider
requests = []
def respond(request):
@@ -450,11 +443,11 @@ def respond(request):
def create_client(provider, **kwargs):
return real_create(provider, **kwargs, http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond)))
- monkeypatch.setattr(AnyLLM, "create", create_client)
+ monkeypatch.setattr(republic, "get_provider", create_client)
config = onboarding.collect_model_config({})
assert "api_key" not in config
runtime_settings = onboarding.AgentSettings.model_validate(config)
- onboarding.discover_models("openai", **runtime_settings.model_client_kwargs(LLMProvider.OPENAI))
+ onboarding.discover_models("openai", **runtime_settings.model_client_kwargs("openai"))
assert len(requests) == 2
assert all(request.headers["authorization"] == "Bearer environment-key" for request in requests)
assert all(str(request.url) == "https://example.test/v1/models" for request in requests)
@@ -469,42 +462,21 @@ def test_compatible_key_detects_provider_mapping_in_environment(prompts, monkeyp
assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "environment-key")
-def test_oauth_login_skips_api_key_discovery_and_preserves_runtime_auth(prompts, monkeypatch, capsys):
+def test_codex_skips_api_key_and_model_discovery(prompts, capsys):
answers, calls = prompts
- answers["key"] = ""
- monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object())
+ answers.update(provider="codex", key="")
config = onboarding.collect_model_config({})
- assert config == {"model": "openai:model-b"}
+ assert config == {"model": "codex:model-b"}
assert "discover" not in dict(calls)
- assert "OAuth login" in capsys.readouterr().out
- assert codex_provider.should_use_openai_codex_provider("openai", "model-b", api_key=None, api_base=None)
-
-
-@pytest.mark.parametrize("initial", [{}, {"api_base": None}, {"api_base": ""}, {"api_base": {"openai": ""}}])
-def test_leaving_default_url_blank_during_edit_does_not_disable_oauth(prompts, monkeypatch, initial):
- answers, _ = prompts
- answers.update(key="", base="")
- actions = iter([onboarding.EDIT_CONNECTION, onboarding.MANUAL_MODEL])
- monkeypatch.setattr(inquirer, "ask_select", lambda *args, **kwargs: next(actions))
-
- def fail(*args, **kwargs):
- raise AuthenticationError("no API key")
-
- monkeypatch.setattr(onboarding, "discover_models", fail)
- config = onboarding.collect_model_config(initial)
- settings = onboarding.AgentSettings.model_validate(config)
- api_base = settings.model_client_kwargs("openai")["api_base"]
- assert not api_base
- monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object())
- assert codex_provider.should_use_openai_codex_provider("openai", "model-b", api_key=None, api_base=api_base)
+ assert "API key (optional)" not in dict(calls)
+ assert "Codex account login" in capsys.readouterr().out
-def test_explicit_official_base_does_not_switch_to_oauth(prompts, monkeypatch):
+def test_openai_does_not_implicitly_select_codex(prompts):
answers, calls = prompts
answers["key"] = ""
- monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object())
config = onboarding.collect_model_config({"model": "openai:model-b", "api_base": onboarding.OPENAI_BASE})
- assert config["api_base"] == onboarding.OPENAI_BASE
+ assert config["model"] == "openai:model-b"
assert "discover" in dict(calls)
@@ -553,27 +525,18 @@ def test_compatible_url_is_validated_before_sending_credentials(monkeypatch, inv
def test_discovery_uses_entered_endpoint_and_credentials_and_closes_client(monkeypatch):
requests = []
clients = []
- create = AnyLLM.create
+ constructor = httpx.AsyncClient
def respond(request):
requests.append(request)
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "data": [
- {"id": name, "object": "model", "created": 0, "owned_by": "test"}
- for name in ["model-b", "model-a", "model-a"]
- ],
- },
- )
+ return httpx.Response(200, json={"data": [{"id": name} for name in ["model-b", "model-a", "model-a"]]})
- def create_client(provider, **kwargs):
- client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
+ def create_client(**kwargs):
+ client = constructor(**kwargs, transport=httpx.MockTransport(respond))
clients.append(client)
- return create(provider, **kwargs, http_client=client)
+ return client
- monkeypatch.setattr(AnyLLM, "create", create_client)
+ monkeypatch.setattr(httpx, "AsyncClient", create_client)
assert onboarding.discover_models("openai", api_base="https://example.test/custom/v1", api_key="test-key") == [
"model-a",
"model-b",
@@ -585,23 +548,44 @@ def create_client(provider, **kwargs):
assert clients[0].is_closed
-def test_discovery_timeout_cancels_request_and_closes_client(monkeypatch):
- closed = AsyncMock()
- cancelled = []
+def test_discovery_timeout_is_passed_to_the_client_and_closes_it(monkeypatch):
+ clients = []
+ timeouts = []
+ constructor = httpx.AsyncClient
+
+ def respond(request):
+ timeouts.append(request.extensions["timeout"])
+ raise httpx.ReadTimeout("timed out", request=request)
+
+ def create_client(**kwargs):
+ client = constructor(**kwargs, transport=httpx.MockTransport(respond))
+ clients.append(client)
+ return client
- async def list_models():
- try:
- await asyncio.Event().wait()
- finally:
- cancelled.append(True)
+ monkeypatch.setattr(httpx, "AsyncClient", create_client)
+ with pytest.raises(APITimeoutError) as exc:
+ # A configured client timeout does not apply to the connection check.
+ onboarding.discover_models(
+ "openai", api_base="https://example.test/v1", api_key="test-key", timeout=600, max_retries=0
+ )
+ assert timeouts[0]["read"] == onboarding.CONNECTION_TIMEOUT
+ assert "timed out" in onboarding._connection_error(exc.value)
+ assert clients[0].is_closed
- monkeypatch.setattr(onboarding, "CONNECTION_TIMEOUT", 0.01)
+
+def test_onboarding_updates_named_endpoint_and_discovers_its_provider_type(prompts, monkeypatch):
+ _, calls = prompts
monkeypatch.setattr(
- AnyLLM,
- "create",
- lambda *args, **kwargs: SimpleNamespace(alist_models=list_models, client=SimpleNamespace(close=closed)),
+ inquirer, "ask_fuzzy", lambda message, **kwargs: "relay" if message == "LLM provider" else "model-b"
)
- with pytest.raises(TimeoutError):
- onboarding.discover_models("openai", api_base="https://example.test/v1", api_key="test-key")
- assert cancelled == [True]
- closed.assert_awaited_once()
+ original = {
+ "model": "relay:sample",
+ "providers": {"relay": {"type": "openai", "api_base": "https://old.test/v1", "api_key": "old-key"}},
+ }
+ update = onboarding.collect_model_config(original)
+ settings = onboarding.AgentSettings.model_validate(configure.merge({}, original, update))
+ assert update["model"] == "relay:model-b"
+ assert settings.providers["relay"].type == "openai"
+ assert settings.providers["relay"].api_base == "https://example.test/v1"
+ assert settings.providers["relay"].api_key == "test-key"
+ assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "test-key")
diff --git a/tests/test_otlp.py b/tests/test_otlp.py
index caa563ab..b852869b 100644
--- a/tests/test_otlp.py
+++ b/tests/test_otlp.py
@@ -75,7 +75,7 @@ def unexpected_configuration(*args, **kwargs):
logfire.configure = unexpected_configuration
from bub.__main__ import _instrument_bub
from bub.tracing import Span
-_instrument_bub('INFO') # CLI import already configured it; repeat must not duplicate exports.
+_instrument_bub('INFO')
root = Span('invoke_agent bub', {'gen_ai.operation.name': 'invoke_agent'})
with root.activate():
child = Span('chat test', {'gen_ai.operation.name': 'chat'})
diff --git a/tests/test_program_status.py b/tests/test_program_status.py
new file mode 100644
index 00000000..8ef3ef6e
--- /dev/null
+++ b/tests/test_program_status.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import asyncio
+import re
+import sys
+from io import StringIO
+
+import pytest
+import typer
+from typer.main import get_command
+
+from bub.channels.base import Channel
+from bub.channels.message import ChannelMessage
+from bub.framework import BubFramework
+from bub.hooks import hookimpl
+
+
+class MessageChannel(Channel):
+ name = "test"
+
+ def __init__(self, deliver):
+ self.deliver = deliver
+
+ async def start(self, stop_event):
+ self.delivery = asyncio.create_task(self.deliver(stop_event))
+
+ async def stop(self):
+ await self.delivery
+
+ async def send(self, message):
+ typer.echo(message.content)
+
+
+def reports(output):
+ return [
+ dict(pair.split("=", 1) for pair in body.split(":"))
+ for body in re.findall(r"\x1b]7501;(.*?)\x1b\\", output.getvalue())
+ ]
+
+
+@pytest.mark.parametrize("second_waits", ["model", "admission"])
+def test_listener_stays_working_until_accepted_messages_finish(tmp_path, monkeypatch, second_waits):
+ terminal = StringIO()
+ monkeypatch.setattr(terminal, "isatty", lambda: True)
+ monkeypatch.setattr(sys, "stdout", terminal)
+ ready = asyncio.Event()
+ failed = asyncio.Event()
+ resume = asyncio.Event()
+ framework = BubFramework(config_file=tmp_path / "config.yml")
+ framework.load_builtin_hooks()
+
+ async def exchange(receive, stop_event):
+ try:
+ async with asyncio.timeout(2):
+ while reports(terminal)[-1]["state"] != "idle":
+ await asyncio.sleep(0)
+ await receive(ChannelMessage("first", "test", "fail"))
+ await receive(ChannelMessage("second", "test", "succeed"))
+ await failed.wait()
+ await asyncio.sleep(0)
+ assert reports(terminal)[-1]["state"] == "working"
+ resume.set()
+ while reports(terminal)[-1]["state"] != "done":
+ await asyncio.sleep(0)
+ assert "response" in terminal.getvalue()
+ finally:
+ resume.set()
+ stop_event.set()
+
+ class Hooks:
+ @hookimpl
+ def provide_channels(self, message_handler):
+ return [MessageChannel(lambda stop_event: exchange(message_handler, stop_event))]
+
+ @hookimpl
+ async def admit_message(self, session_id, message, turn):
+ if session_id == "second" and second_waits == "admission":
+ ready.set()
+ await resume.wait()
+
+ @hookimpl
+ async def run_model(self, prompt, session_id, state):
+ if session_id == "first":
+ await ready.wait()
+ failed.set()
+ raise RuntimeError("failed")
+ ready.set()
+ await resume.wait()
+ return "response"
+
+ framework.plugin_manager.register(Hooks())
+ app = framework.create_cli_app()
+
+ get_command(app).main(["--workspace", str(tmp_path), "gateway", "--enable-channel", "test"], standalone_mode=False)
+
+ assert reports(terminal)[-1]["state"] == "idle"
+ assert all(item["state"] != "error" for item in reports(terminal))
diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py
new file mode 100644
index 00000000..1b7bb911
--- /dev/null
+++ b/tests/test_provider_messages.py
@@ -0,0 +1,259 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import httpx2
+import pytest
+
+from bub.builtin.settings import AgentSettings
+from bub.channels.message import ChannelMessage, MediaItem
+from bub.core.context import default_tape_context
+from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore
+from bub.core.tape import Tape, TapeContext
+from bub.core.tools import Tool
+from bub.errors import BubError, ErrorKind
+from bub.framework import BubFramework
+from bub.prompt import to_content
+from tests.model_fakes import ProviderService, StepRunner, chat_events, sse
+
+
+@pytest.mark.asyncio
+async def test_channel_media_reaches_gemini_and_survives_tape_reload(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ async def image():
+ return b"image"
+
+ async def audio():
+ return b"audio"
+
+ async def video():
+ return b"video"
+
+ framework = BubFramework(config_file=tmp_path / "config.yml")
+ framework.load_builtin_hooks()
+ channel = ChannelMessage(
+ session_id="media",
+ channel="telegram",
+ content="Describe the attachments.",
+ media=[
+ MediaItem("image", "image/png", data_fetcher=image),
+ MediaItem("audio", "audio/ogg", data_fetcher=audio),
+ MediaItem("video", "video/mp4", data_fetcher=video),
+ ],
+ )
+ prompt = await framework.build_prompt(channel, channel.session_id, {})
+ for _ in range(2):
+ provider_service.reply(
+ sse([{"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}])
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client}))
+ tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("media")
+ await tape.ensure_bootstrap_anchor()
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape, model="google:test", tools=[], system_prompt=None, prompt=to_content(prompt)
+ )
+ ]
+ reopened = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("media")
+ follow_up = [
+ event
+ async for event in runner.run(
+ tape=reopened, model="google:test", tools=[], system_prompt=None, prompt=to_content("Continue.")
+ )
+ ]
+ expected = [
+ {"inlineData": {"mimeType": "image/png", "data": "aW1hZ2U="}},
+ {"inlineData": {"mimeType": "audio/ogg", "data": "YXVkaW8="}},
+ {"inlineData": {"mimeType": "video/mp4", "data": "dmlkZW8="}},
+ ]
+ assert provider_service.body(0)["contents"][0]["parts"][1:] == expected
+ assert provider_service.body(1)["contents"][0]["parts"][1:] == expected
+ assert events[-1].data == {"ok": True, "text": "ready"}
+ assert follow_up[-1].data == {"ok": True, "text": "ready"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("external_client", [False, True])
+async def test_consumer_close_releases_the_request_and_respects_client_ownership(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, external_client: bool
+) -> None:
+ closed = asyncio.Event()
+
+ class Body(httpx2.AsyncByteStream):
+ async def __aiter__(self):
+ yield b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n'
+ await asyncio.Event().wait()
+
+ async def aclose(self):
+ closed.set()
+
+ async with httpx2.AsyncClient(
+ transport=httpx2.MockTransport(lambda _: httpx2.Response(200, stream=Body()))
+ ) as client:
+ monkeypatch.setattr(httpx2, "AsyncClient", lambda **kwargs: client)
+ client_args = {"api_format": "chat"}
+ if external_client:
+ client_args["http_client"] = client
+ runner = StepRunner(AgentSettings(client_args=client_args))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cancel")
+ events = runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello"))
+ assert (await anext(events)).data == {"delta": "first"}
+ await events.aclose()
+ assert closed.is_set()
+ assert client.is_closed is not external_client
+ assert not await tape.store.fetch_all(tape.query().kinds("message"))
+
+
+@pytest.mark.asyncio
+async def test_primary_success_does_not_require_an_available_fallback(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(sse(chat_events()))
+ async with provider_service.client() as client:
+ runner = StepRunner(
+ AgentSettings(
+ fallback_models=["not-installed:test"], client_args={"http_client": client, "api_format": "chat"}
+ )
+ )
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback")
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello")
+ )
+ ]
+ assert len(provider_service.requests) == 1
+ assert events[-1].data == {"ok": True, "text": "done"}
+
+
+@pytest.mark.asyncio
+async def test_fresh_process_replays_tool_signatures_to_the_provider(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(
+ sse([
+ {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "functionCall": {"id": "call-1", "name": "lookup", "args": {}},
+ "thoughtSignature": "signature",
+ }
+ ]
+ },
+ "finishReason": "STOP",
+ }
+ ]
+ }
+ ])
+ )
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client}))
+ tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("persisted")
+ await tape.ensure_bootstrap_anchor()
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape,
+ model="google:test",
+ tools=[Tool(name="lookup", handler=lambda: "lookup result")],
+ system_prompt=None,
+ prompt=to_content("Look it up."),
+ )
+ ]
+ code = """
+import asyncio, json, sys
+from pathlib import Path
+import httpx2
+from bub.core.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
+async def main():
+ bodies = []
+ def respond(request):
+ bodies.append(json.loads(request.content))
+ event = {"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}
+ return httpx2.Response(200, headers={"content-type": "text/event-stream"}, text="data: " + json.dumps(event) + "\\n\\n")
+ async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as client:
+ directory = Path(sys.argv[1])
+ tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(directory)), default_tape_context()).scoped("persisted")
+ runner = StepRunner(AgentSettings(client_args={"http_client": client}))
+ events = [event async for event in runner.run(tape=tape, model="google:test", tools=[], system_prompt=None, prompt=["Continue."])]
+ print(json.dumps({"request": bodies[0], "result": events[-1].data}))
+asyncio.run(main())
+"""
+ result = subprocess.run([sys.executable, "-c", code, str(tmp_path)], capture_output=True, text=True, check=True)
+ replay = json.loads(result.stdout)
+ assistant = next(content for content in replay["request"]["contents"] if content["role"] == "model")
+ call = next(part for part in assistant["parts"] if "functionCall" in part)
+ assert call["functionCall"] == {"id": "call-1", "name": "lookup", "args": {}}
+ assert call["thoughtSignature"] == "signature"
+ assert "lookup result" in json.dumps(replay["request"])
+ assert replay["result"] == {"ok": True, "text": "ready"}
+
+
+@pytest.mark.asyncio
+async def test_media_urls_preserve_explicit_mime_types(tmp_path: Path, provider_service: ProviderService) -> None:
+ provider_service.reply(sse([{"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}]))
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("urls")
+ events = [
+ event
+ async for event in runner.run(
+ tape=tape,
+ model="google:test",
+ tools=[],
+ system_prompt=None,
+ prompt=to_content([
+ {
+ "type": "image",
+ "media_type": "image/png",
+ "url": "https://example.test/photo.png?signature=opaque",
+ },
+ {"type": "image", "media_type": "image/webp", "url": "https://example.test/attachment"},
+ ]),
+ )
+ ]
+ assert provider_service.body()["contents"][0]["parts"] == [
+ {"fileData": {"mimeType": "image/png", "fileUri": "https://example.test/photo.png?signature=opaque"}},
+ {"fileData": {"mimeType": "image/webp", "fileUri": "https://example.test/attachment"}},
+ ]
+ assert events[-1].data == {"ok": True, "text": "ready"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "block",
+ [
+ {"type": "image", "url": "https://example.test/photo"},
+ {"type": "audio", "media_type": "audio/wav"},
+ {"type": "document", "media_type": "application/pdf", "url": "https://example.test/file.pdf"},
+ ],
+)
+async def test_invalid_content_fails_without_a_provider_request(
+ tmp_path: Path, provider_service: ProviderService, block: dict
+) -> None:
+ async with provider_service.client() as client:
+ runner = StepRunner(AgentSettings(client_args={"http_client": client}))
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid")
+ with pytest.raises(BubError) as exc:
+ _ = [
+ event
+ async for event in runner.run(
+ tape=tape, model="google:test", tools=[], system_prompt=None, prompt=to_content([block])
+ )
+ ]
+ assert exc.value.kind == ErrorKind.INVALID_INPUT
+ assert not provider_service.requests
diff --git a/tests/test_sdk_isolation.py b/tests/test_sdk_isolation.py
index 9f5552c5..72407ebb 100644
--- a/tests/test_sdk_isolation.py
+++ b/tests/test_sdk_isolation.py
@@ -1,16 +1,18 @@
from pathlib import Path
-from unittest.mock import Mock
import pytest
-from bub.builtin import Agent
+from bub.builtin import BubAgent
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.tape import Tape, session_tape_name
+from bub.core.tools import Tool, ToolContext
from bub.framework import BubFramework
-from bub.store import InMemoryTapeStore
+from bub.hooks import hookimpl
from bub.streaming import AsyncStreamEvents, StreamEvent
-from bub.tape import Tape
-from bub.tools import Tool, ToolContext
def _reply() -> AsyncStreamEvents:
@@ -21,6 +23,23 @@ async def events():
return AsyncStreamEvents(events())
+def capture_steps(monkeypatch: pytest.MonkeyPatch) -> list[tuple[CoreAgent, Tape, BubModel]]:
+ """Replace model steps with a fixed reply and record the core agents, tapes and models that ran them."""
+ steps: list[tuple[CoreAgent, Tape, BubModel]] = []
+
+ def run_once(self: CoreAgent, prompt: object, *, model: BubModel, tape: Tape, **_: object) -> AsyncStreamEvents:
+ steps.append((self, tape, model))
+ return _reply()
+
+ monkeypatch.setattr(CoreAgent, "run_once", run_once)
+ return steps
+
+
+async def step_tool_names(step: tuple[CoreAgent, Tape, BubModel]) -> list[str]:
+ agent, tape, _ = step
+ return [tool.name for tool in await agent.prepare_tools(tape, None)]
+
+
@pytest.fixture
def framework(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> BubFramework:
monkeypatch.setenv("BUB_HOME", str(tmp_path))
@@ -35,9 +54,9 @@ 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.builtin.model_runner.ModelRunner.run", lambda *args, **kwargs: _reply())
- custom = Agent(framework, tools=[show_help], skill_dirs=[], command_prefix="::")
- configured = Agent(framework, tools=[show_help], skill_dirs=[])
+ monkeypatch.setattr("bub.core.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=[])
for agent, prompt, expected in [
(custom, " ::help ", "Commands use '::'"),
@@ -54,12 +73,12 @@ async def test_sdk_agents_execute_only_their_own_command_prefix(
@pytest.mark.parametrize("prefix", [",", "::"])
@pytest.mark.parametrize("enabled", [True, False])
async def test_prefixed_code_mode_command_is_restored_by_a_new_agent(
- framework: BubFramework, prefix: str, enabled: bool
+ framework: BubFramework, monkeypatch: pytest.MonkeyPatch, prefix: str, enabled: bool
) -> None:
store = InMemoryTapeStore()
tools = [set_code_mode, run_code]
- agent = Agent(framework, tools=tools, tape_store=store, skill_dirs=[], command_prefix=prefix)
- tape = agent.tape.session_tape("sdk", framework.workspace)
+ agent = BubAgent(framework, tools=tools, tape_store=store, skill_dirs=[], command_prefix=prefix)
+ tape = agent.tape(session_tape_name("sdk", framework.workspace))
await tape.append_event("code_mode_switch", {"code_mode": not enabled})
stream = await agent.run_stream(session_id="sdk", prompt=f"{prefix}code_mode enable={str(enabled).lower()}")
@@ -69,15 +88,13 @@ async def test_prefixed_code_mode_command_is_restored_by_a_new_agent(
event.data.get("delta") == f"Session code mode {status} (applies from the next turn)." for event in events
)
- restored = Agent(framework, tools=tools, tape_store=store, skill_dirs=[], command_prefix=prefix)
- runner = Mock(side_effect=lambda **kwargs: _reply())
- restored.model_runner.run = runner
+ restored = BubAgent(framework, tools=tools, tape_store=store, skill_dirs=[], command_prefix=prefix)
+ steps = capture_steps(monkeypatch)
stream = await restored.run_stream(session_id="sdk", prompt="hello")
assert [event.kind async for event in stream] == ["text", "final"]
- call = runner.call_args.kwargs
- assert call["tape"].context.state["code_mode"] is enabled
- assert [tool.name for tool in call["tools"]] == (["run_code"] if enabled else [])
+ assert steps[-1][1].context.state["code_mode"] is enabled
+ assert await step_tool_names(steps[-1]) == (["run_code"] if enabled else [])
@pytest.mark.asyncio
@@ -91,13 +108,13 @@ async def lookup(**kwargs: object) -> dict[str, str]:
handler=lookup,
renderer=lambda result: result["output"],
)
- agent = Agent(framework, tools=[command_tool], skill_dirs=[], command_prefix="::")
+ agent = BubAgent(framework, tools=[command_tool], skill_dirs=[], command_prefix="::")
stream = await agent.run_stream(session_id="sdk", prompt=f"::{command}")
output = "".join([event.data["delta"] async for event in stream if event.kind == "text"])
assert output == "found"
- tape = agent.tape.session_tape("sdk", framework.workspace)
+ tape = agent.tape(session_tape_name("sdk", framework.workspace))
events = list(await tape.store.fetch_all(tape.query().kinds("event")))
recorded = [entry.payload["data"] for entry in events if entry.payload.get("name") == "command"]
assert len(recorded) == 1
@@ -109,21 +126,20 @@ async def lookup(**kwargs: object) -> dict[str, str]:
@pytest.mark.parametrize("has_saved_state", [False, True])
@pytest.mark.parametrize("override", [False, True])
async def test_sdk_recovers_only_its_store_and_honors_explicit_overrides(
- framework: BubFramework, has_saved_state: bool, override: bool
+ framework: BubFramework, monkeypatch: pytest.MonkeyPatch, has_saved_state: bool, override: bool
) -> None:
builtin = framework.plugin_manager.get_plugin("builtin")
- builtin_tape = builtin._get_agent().tape.session_tape("shared", framework.workspace)
+ builtin_tape = builtin._get_agent().tape(session_tape_name("shared", framework.workspace))
await builtin_tape.append_event("model_switch", {"model": "test:other"})
await builtin_tape.append_event("reasoning_effort_switch", {"reasoning_effort": "low"})
- agent = Agent(framework, tools=[], tape_store=InMemoryTapeStore(), skill_dirs=[])
- tape = agent.tape.session_tape("shared", framework.workspace)
+ agent = BubAgent(framework, tools=[], tape_store=InMemoryTapeStore(), skill_dirs=[])
+ tape = agent.tape(session_tape_name("shared", framework.workspace))
if has_saved_state:
await tape.append_event("model_switch", {"model": "test:saved"})
await tape.append_event("reasoning_effort_switch", {"reasoning_effort": "high"})
- runner = Mock(side_effect=lambda **kwargs: _reply())
- agent.model_runner.run = runner
+ steps = capture_steps(monkeypatch)
stream = await agent.run_stream(
session_id="shared",
prompt="hello",
@@ -131,11 +147,12 @@ async def test_sdk_recovers_only_its_store_and_honors_explicit_overrides(
reasoning_effort="medium" if override else None,
)
assert [event.kind async for event in stream] == ["text", "final"]
- call = runner.call_args.kwargs
expected_model = "test:saved" if has_saved_state else agent.settings.model
- assert call["model"] == ("test:explicit" if override else expected_model)
- state = call["tape"].context.state
- assert state.get("reasoning_effort") == ("medium" if override else "high" if has_saved_state else None)
+ assert steps[-1][2].name == ("test:explicit" if override else expected_model)
+ expected_effort = "medium" if override else "high" if has_saved_state else None
+ assert steps[-1][2].options.get("reasoning_effort") == expected_effort
+ state = steps[-1][1].context.state
+ assert state.get("reasoning_effort") == expected_effort
assert state["_runtime_agent"] is agent
@@ -152,24 +169,26 @@ def test_instance_tool_names_resolve_aliases_and_exclusions_from_one_index() ->
@pytest.mark.asyncio
-async def test_agent_allowlist_accepts_unregistered_instance_tool(framework: BubFramework) -> None:
+async def test_agent_allowlist_accepts_unregistered_instance_tool(
+ framework: BubFramework, monkeypatch: pytest.MonkeyPatch
+) -> None:
tool = Tool.from_callable(lambda: "found", name="sdk.lookup")
- agent = Agent(framework, tools=[tool], tape_store=InMemoryTapeStore(), skill_dirs=[])
- runner = Mock(side_effect=lambda **kwargs: _reply())
- agent.model_runner.run = runner
+ agent = BubAgent(framework, tools=[tool], tape_store=InMemoryTapeStore(), skill_dirs=[])
+ steps = capture_steps(monkeypatch)
stream = await agent.run_stream(session_id="sdk", prompt="lookup", allowed_tools=[" SDK_LOOKUP "])
assert [event.kind async for event in stream] == ["text", "final"]
- assert [tool.name for tool in runner.call_args.kwargs["tools"]] == ["sdk_lookup"]
+ assert await step_tool_names(steps[-1]) == ["sdk_lookup"]
@pytest.mark.asyncio
@pytest.mark.parametrize("allowed_tools", [None, ["SDK_LOOKUP"]])
-async def test_subagent_uses_parent_instance_tools(framework: BubFramework, allowed_tools: list[str] | None) -> None:
+async def test_subagent_uses_parent_instance_tools(
+ framework: BubFramework, monkeypatch: pytest.MonkeyPatch, allowed_tools: list[str] | None
+) -> None:
tool = Tool.from_callable(lambda: "found", name="sdk.lookup")
- agent = Agent(framework, tools=[tool, run_subagent], tape_store=InMemoryTapeStore(), skill_dirs=[])
- runner = Mock(side_effect=lambda **kwargs: _reply())
- agent.model_runner.run = runner
- tape: Tape = agent.tape.session_tape("parent", framework.workspace)
+ agent = BubAgent(framework, tools=[tool, run_subagent], tape_store=InMemoryTapeStore(), skill_dirs=[])
+ steps = capture_steps(monkeypatch)
+ tape: Tape = agent.tape(session_tape_name("parent", framework.workspace))
context = ToolContext(
tape=tape,
state={"_runtime_agent": agent, "session_id": "parent", "_runtime_workspace": str(framework.workspace)},
@@ -177,4 +196,22 @@ async def test_subagent_uses_parent_instance_tools(framework: BubFramework, allo
result = await run_subagent.run(prompt="lookup", allowed_tools=allowed_tools, context=context)
assert result["output"] == "done"
assert run_subagent.render(result) == "done"
- assert [tool.name for tool in runner.call_args.kwargs["tools"]] == ["sdk_lookup"]
+ assert await step_tool_names(steps[-1]) == ["sdk_lookup"]
+
+
+@pytest.mark.asyncio
+async def test_builtin_channels_create_the_agent_with_the_running_store(framework: BubFramework) -> None:
+ store = InMemoryTapeStore()
+
+ class StorePlugin:
+ @hookimpl
+ def provide_tape_store(self) -> InMemoryTapeStore:
+ return store
+
+ framework.plugin_manager.register(StorePlugin(), name="store")
+ cli = framework.get_channels(lambda message: None)["cli"] # Channels exist before running().
+
+ async with framework.running():
+ await cli._agent.tape("early").append_event("probe", {}) # type: ignore[attr-defined]
+
+ assert store.list_tapes() == ["early"]
diff --git a/tests/test_session_settings.py b/tests/test_session_settings.py
index 0996138c..c6c19886 100644
--- a/tests/test_session_settings.py
+++ b/tests/test_session_settings.py
@@ -5,13 +5,13 @@
import pytest
from bub.builtin.settings import load_session_settings, set_session_setting
-from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore
-from bub.tape import Tape, TapeContext
-from bub.tools import ToolContext
+from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore
+from bub.core.tape import Tape, TapeContext
+from bub.core.tools import ToolContext
def _context(tmp_path: Path) -> ToolContext:
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("session")
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("session")
return ToolContext(tape=tape, run_id="run-1", state={})
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 5c34d950..41926d9a 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,15 +1,20 @@
from __future__ import annotations
import os
+from pathlib import Path
from unittest.mock import patch
+import httpx2
import pytest
-from any_llm.constants import LLMProvider
from pydantic import ValidationError
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.store import AsyncTapeStoreAdapter, InMemoryTapeStore
+from bub.core.tape import Tape, TapeContext
+from bub.prompt import to_content
+from tests.model_fakes import ProviderService, StepRunner, chat_events, sse
def _settings_with_env(env: dict[str, str]) -> AgentSettings:
@@ -53,20 +58,6 @@ def test_settings_no_keys_return_none() -> None:
assert settings.completion_args == {}
-@pytest.mark.parametrize("provider", ["openai", LLMProvider.OPENAI, "acme"])
-def test_client_options_resolve_provider_names_and_enum_values(provider: str) -> None:
- settings = _settings_with_env({
- f"BUB_{provider.upper()}_API_KEY": "environment-key",
- f"BUB_{provider.upper()}_API_BASE": "https://example.test/v1",
- "BUB_CLIENT_ARGS": '{"api_key": "ignored-key", "api_base": "https://ignored.test", "timeout": 5}',
- })
- assert settings.model_client_kwargs(provider) == {
- "api_key": "environment-key",
- "api_base": "https://example.test/v1",
- "timeout": 5,
- }
-
-
def test_settings_provider_names_are_lowercased() -> None:
settings = _settings_with_env({"BUB_OPENROUTER_API_KEY": "sk-or"})
@@ -98,7 +89,7 @@ def test_settings_load_values_from_yaml(load_config) -> None:
api_base:
openai: https://api.openai.com
client_args:
- extra_headers:
+ headers:
HTTP-Referer: https://openclaw.ai
X-Title: OpenClaw
completion_args:
@@ -114,7 +105,7 @@ def test_settings_load_values_from_yaml(load_config) -> None:
assert settings.api_key == {"openai": "sk-yaml"}
assert settings.api_base == {"openai": "https://api.openai.com"}
assert settings.client_args == {
- "extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"},
+ "headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"},
}
assert settings.completion_args == {"reasoning_effort": "high"}
@@ -125,7 +116,7 @@ def test_env_settings_override_yaml(load_config) -> None:
api_key: sk-yaml
max_steps: 77
client_args:
- extra_headers:
+ headers:
HTTP-Referer: https://yaml.example
X-Title: YAML App
""".strip()
@@ -135,7 +126,7 @@ def test_env_settings_override_yaml(load_config) -> None:
{
"BUB_MODEL": "anthropic:claude-3-7-sonnet",
"BUB_API_KEY": "sk-env",
- "BUB_CLIENT_ARGS": '{"extra_headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}',
+ "BUB_CLIENT_ARGS": '{"headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}',
"BUB_COMPLETION_ARGS": '{"reasoning_effort":"medium"}',
"BUB_MAX_STEPS": "12",
},
@@ -148,7 +139,7 @@ def test_env_settings_override_yaml(load_config) -> None:
assert settings.api_key == "sk-env"
assert settings.max_steps == 12
assert settings.client_args == {
- "extra_headers": {"HTTP-Referer": "https://env.example", "X-Title": "Env App"},
+ "headers": {"HTTP-Referer": "https://env.example", "X-Title": "Env App"},
}
assert settings.completion_args == {"reasoning_effort": "medium"}
@@ -194,41 +185,197 @@ def test_load_settings_returns_loaded_config(load_config) -> None:
assert settings.model == "openrouter:openrouter/free"
-def test_custom_provider_names_an_endpoint_beside_the_builtin_ones(load_config) -> None:
+async def _run(settings: AgentSettings, tmp_path: Path) -> str:
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("settings")
+ events = [
+ event
+ async for event in StepRunner(settings).run(
+ tape=tape, model=settings.model, tools=[], system_prompt=None, prompt=to_content("Hello")
+ )
+ ]
+ assert events[-1].data["ok"]
+ return events[-1].data["text"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("provider", ["openai", "acme"])
+async def test_client_options_resolve_provider_names(
+ provider: str, tmp_path: Path, provider_transport: ProviderService
+) -> None:
+ provider_transport.reply_chat()
+ env = {
+ f"BUB_{provider.upper()}_API_KEY": "environment-key",
+ f"BUB_{provider.upper()}_API_BASE": "https://example.test/v1",
+ "BUB_CLIENT_ARGS": '{"api_key": "ignored-key", "api_base": "https://ignored.test", "api_format": "chat"}',
+ }
+ if provider == "acme":
+ env["BUB_PROVIDERS"] = '{"acme": {"type": "openai"}}'
+ settings = _settings_with_env(env)
+ settings.model = f"{provider}:model"
+ assert await _run(settings, tmp_path) == "done"
+ assert provider_transport.requests[0].url.host == "example.test"
+ assert provider_transport.requests[0].headers["authorization"] == "Bearer environment-key"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "provider,header", [("google", "x-goog-api-key"), ("azure-openai", "api-key"), ("openrouter", "authorization")]
+)
+@pytest.mark.parametrize("explicit_key", [False, True])
+async def test_provider_requests_use_environment_or_explicit_credentials(
+ provider: str, header: str, explicit_key: bool, tmp_path: Path, provider_service: ProviderService
+) -> None:
+ prefix = provider.upper().replace("-", "_")
+ provider_service.reply(
+ sse(
+ [{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}]
+ if provider == "google"
+ else chat_events()
+ )
+ )
+ with patch.dict(
+ os.environ,
+ {f"{prefix}_API_KEY": "environment-key", f"{prefix}_API_BASE": "https://provider.test/v1"},
+ clear=True,
+ ):
+ async with provider_service.client() as client:
+ settings = AgentSettings(
+ model=f"{provider}:model:variant",
+ api_key={provider: "explicit-key"} if explicit_key else None,
+ client_args={"http_client": client, "api_format": "gemini" if provider == "google" else "chat"},
+ )
+ assert await _run(settings, tmp_path) == "done"
+ request = provider_service.requests[0]
+ key = "explicit-key" if explicit_key else "environment-key"
+ assert request.headers[header] == (f"Bearer {key}" if header == "authorization" else key)
+ assert request.url.host == "provider.test"
+ if provider == "google":
+ assert "model:variant" in request.url.path
+ else:
+ assert provider_service.body()["model"] == "model:variant"
+
+
+@pytest.mark.asyncio
+async def test_provider_and_completion_extras_follow_republic_deep_merge(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(sse([{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}]))
+ async with provider_service.client() as client:
+ settings = AgentSettings(
+ model="google:test",
+ max_tokens=100,
+ client_args={
+ "http_client": client,
+ "extra_body": {"generationConfig": {"maxOutputTokens": 1, "temperature": 0.4, "topK": 12}},
+ },
+ completion_args={"extra_body": {"generationConfig": {"temperature": 0.9, "topP": 0.8}}},
+ )
+ assert await _run(settings, tmp_path) == "done"
+ assert provider_service.body()["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}]
+ assert provider_service.body()["generationConfig"] == {
+ "maxOutputTokens": 1,
+ "temperature": 0.9,
+ "topK": 12,
+ "topP": 0.8,
+ }
+
+
+@pytest.mark.asyncio
+async def test_named_endpoint_and_builtin_fallback_use_separate_credentials(
+ load_config, tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}}))
+ provider_service.reply(sse(chat_events()))
with patch.dict(os.environ, {}, clear=True):
- load_config(
- """
+ load_config("""
model: relay:qwen/kimi-k3
fallback_models:
- openai:gpt-5
api_key:
- openai: sk-openai
+ openai: openai-key
+api_base:
+ openai: https://openai.test/v1
providers:
relay:
type: openai
api_base: https://relay.test/v1
api_key: relay-key
-""".strip(),
- )
+""")
+ async with provider_service.client() as client:
+ settings = load_settings()
+ settings.client_args = {"http_client": client, "api_format": "chat", "max_retries": 0}
+ assert await _run(settings, tmp_path) == "done"
+ assert [request.url.host for request in provider_service.requests] == ["relay.test", "openai.test"]
+ assert [request.headers["authorization"] for request in provider_service.requests] == [
+ "Bearer relay-key",
+ "Bearer openai-key",
+ ]
+ assert [provider_service.body(index)["model"] for index in range(2)] == ["qwen/kimi-k3", "gpt-5"]
+
+
+@pytest.mark.asyncio
+async def test_named_endpoint_uses_its_environment_credentials(
+ tmp_path: Path, provider_service: ProviderService
+) -> None:
+ provider_service.reply(
+ sse([
+ {"type": "message_start", "message": {"id": "test", "usage": {}}},
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "done"}},
+ {"type": "message_stop"},
+ ])
+ )
+ with patch.dict(
+ os.environ,
+ {
+ "BUB_MODEL": "relay:test",
+ "BUB_PROVIDERS": '{"relay": {"type": "anthropic", "api_base": "https://relay.test"}}',
+ "BUB_RELAY_API_KEY": "environment-key",
+ },
+ clear=True,
+ ):
+ async with provider_service.client() as client:
+ settings = AgentSettings(client_args={"http_client": client})
+ assert await _run(settings, tmp_path) == "done"
+ assert provider_service.requests[0].url.host == "relay.test"
+ assert provider_service.requests[0].headers["x-api-key"] == "environment-key"
- settings = load_settings()
- relay, openai = settings.model_candidates(settings.model)
- assert (relay.provider, relay.model_id, relay.provider_name) == (LLMProvider.OPENAI, "qwen/kimi-k3", "relay")
- assert (openai.provider, openai.model_id, openai.provider_name) == (LLMProvider.OPENAI, "gpt-5", None)
- assert settings.model_client_kwargs("relay") == {"api_key": "relay-key", "api_base": "https://relay.test/v1"}
- assert settings.model_client_kwargs("openai") == {"api_key": "sk-openai", "api_base": None}
+def test_custom_provider_rejects_unknown_type() -> None:
+ with pytest.raises(ValidationError, match="providers"):
+ _settings_with_env({"BUB_PROVIDERS": '{"relay": {"type": "nope"}}'})
+
+@pytest.mark.parametrize("provider", ["azure-openai", "github-copilot"])
+def test_hyphenated_provider_names_resolve_environment_credentials(provider: str) -> None:
+ prefix = provider.upper().replace("-", "_")
+ settings = _settings_with_env({f"BUB_{prefix}_API_KEY": "provider-key"})
+ assert settings.api_key == {provider: "provider-key"}
-def test_custom_provider_falls_back_to_per_provider_settings() -> None:
- settings = _settings_with_env({
- "BUB_PROVIDERS": '{"relay": {"type": "anthropic", "api_base": "https://relay.test"}}',
- "BUB_RELAY_API_KEY": "env-key",
- })
- assert settings.model_client_kwargs("relay") == {"api_key": "env-key", "api_base": "https://relay.test"}
+def test_model_clients_identify_as_bub() -> None:
+ import republic
+ import bub
-def test_custom_provider_rejects_unknown_type() -> None:
- with pytest.raises(ValidationError, match="providers"):
- _settings_with_env({"BUB_PROVIDERS": '{"relay": {"type": "nope"}}'})
+ settings = AgentSettings(api_key="sk-test")
+ kwargs = settings.model_client_kwargs("openai")
+ provider = republic.get_provider("openai", **kwargs)
+
+ assert provider.headers["User-Agent"] == f"bub/{bub.__version__}"
+ custom = AgentSettings(api_key="sk-test", client_args={"headers": {"User-Agent": "mine", "X-Team": "a"}})
+ assert custom.model_client_kwargs("openai")["headers"] == {"User-Agent": "mine", "X-Team": "a"}
+
+
+def test_bub_model_resolves_custom_providers_and_default_fallbacks() -> None:
+ from bub.builtin.model import BubModel
+
+ model = BubModel(
+ "relay:gpt", fallbacks=["OpenAI:mini"], providers={"relay": "openai"}, client_options=lambda name: {"n": name}
+ )
+
+ assert [(c.provider, c.model_id, c.provider_name) for c in model.candidates()] == [
+ ("openai", "gpt", "relay"),
+ ("openai", "mini", None),
+ ]
+ assert [c.name for c in model.candidates("anthropic:claude")] == ["anthropic:claude"]
+ assert model.client_kwargs("relay") == {"n": "relay"}
diff --git a/tests/test_spill.py b/tests/test_spill.py
index 1e05cb01..d89286bd 100644
--- a/tests/test_spill.py
+++ b/tests/test_spill.py
@@ -6,11 +6,10 @@
import pluggy
import pytest
-from any_llm.types.completion import ChatCompletion
+import republic
-from bub.builtin.context import default_tape_context
+from bub.builtin.archive import reset_tape
from bub.builtin.hook_impl import BuiltinImpl
-from bub.builtin.model_runner import ModelRunner
from bub.builtin.settings import AgentSettings
from bub.builtin.spill import (
SPILL_READ_MODEL_NAME,
@@ -21,24 +20,28 @@
spill_read,
)
from bub.builtin.tools import render_tools_prompt
+from bub.core.context import default_tape_context
+from bub.core.events import EventBus, ToolCallFinished
+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 AgentHooks, ToolCall, ToolCallDecision, ToolCallResult
+from bub.hooks.interception import PluginAgentHooks, ToolCall, ToolCallResult
from bub.hooks.runtime import HookRuntime
-from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore
-from bub.tape import Tape
-from bub.tools import Tool, ToolContext, ToolExecutor, model_tools
+from tests.model_fakes import ProviderService, StepRunner
-class _SpillHooks:
- async def before_tool_call(self, call: ToolCall, state: dict[str, Any]) -> tuple[ToolCall, ToolCallDecision]:
- return call, ToolCallDecision.proceed()
+def _spill_events() -> EventBus:
+ async def spill(event: ToolCallFinished) -> None:
+ await BuiltinImpl.after_tool_call(None, event.call, event.result, event.state) # type: ignore[arg-type]
- async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict[str, Any]) -> None:
- await BuiltinImpl.after_tool_call(self, call, result, state) # type: ignore[arg-type]
+ events = EventBus()
+ events.on(ToolCallFinished, spill)
+ return events
def _spill_executor() -> ToolExecutor:
- return ToolExecutor(hooks=_SpillHooks()) # type: ignore[arg-type]
+ return ToolExecutor(_spill_events())
def _spill_context(*, tape: Tape, run_id: str | None = None, code_mode: bool = False) -> ToolContext:
@@ -65,7 +68,7 @@ def _page_field(page: str, name: str) -> str:
def _root_tape(tmp_path: Path, store: TapeStore, *, threshold: int = 1) -> Tape:
spill = SpillStore(SpillSettings(threshold=threshold))
- return Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context(), sidecars=(spill,)).scoped("session")
+ return Tape(AsyncTapeStoreAdapter(store), default_tape_context(), sidecars=(spill,)).scoped("session")
async def _read_page(
@@ -76,7 +79,7 @@ async def _read_page(
count: int = 1,
from_end: bool = False,
) -> str:
- execution = await _spill_executor().execute_async(
+ execution = await _spill_executor().execute_many(
[(spill_read, {"handle": handle, "cursor": cursor, "count": count, "from_end": from_end})],
context=context,
)
@@ -94,7 +97,7 @@ async def test_oversized_result_is_bounded_and_readable_across_merge(tmp_path: P
async with root.fork_tape() as tape:
context = _spill_context(tape=tape, run_id="run-1")
tool = Tool(name="large", handler=lambda: output)
- execution = await _spill_executor().execute_async([(tool, {})], context=context)
+ execution = await _spill_executor().execute_many([(tool, {})], context=context)
ref = execution.tool_results[0]
assert isinstance(ref, str)
@@ -121,11 +124,11 @@ async def test_oversized_result_is_bounded_and_readable_across_merge(tmp_path: P
system_prompt=None,
new_messages=[],
response_text=None,
- tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "large", "arguments": "{}"}}],
+ tool_calls=[{"id": "call-1", "name": "large", "arguments": "{}"}],
tool_results=execution.tool_results,
)
request_messages = await tape.read_messages()
- request_body = json.dumps(request_messages, ensure_ascii=False)
+ request_body = json.dumps([message.to_dict() for message in request_messages], ensure_ascii=False)
assert handle in request_body
assert output not in request_body
@@ -142,7 +145,7 @@ async def test_spill_configuration_preserves_results_that_should_not_be_spilled(
async with root.fork_tape() as tape:
context = _spill_context(tape=tape, run_id="run-1")
small_results = ["tiny", {"value": "tiny"}, ["tiny"]]
- small = await _spill_executor().execute_async(
+ small = await _spill_executor().execute_many(
[
(Tool(name=f"small-{index}", handler=lambda result=result: result), {})
for index, result in enumerate(small_results)
@@ -155,13 +158,13 @@ async def test_spill_configuration_preserves_results_that_should_not_be_spilled(
def fail() -> None:
raise RuntimeError("small failure")
- small_error = await _spill_executor().execute_async([(Tool(name="failing", handler=fail), {})], context=context)
+ small_error = await _spill_executor().execute_many([(Tool(name="failing", handler=fail), {})], context=context)
assert small_error.error is not None
assert small_error.tool_results == [small_error.error.as_dict()]
disabled = _root_tape(tmp_path, parent, threshold=0).scoped("disabled")
async with disabled.fork_tape() as tape:
- execution = await _spill_executor().execute_async(
+ execution = await _spill_executor().execute_many(
[(Tool(name="large", handler=lambda: "x" * 20_000), {})],
context=_spill_context(tape=tape, run_id="run-2"),
)
@@ -175,7 +178,7 @@ async def test_oversized_structured_result_is_spilled_as_json(tmp_path: Path, ou
async with root.fork_tape() as tape:
context = _spill_context(tape=tape, run_id="run-1")
- execution = await _spill_executor().execute_async(
+ execution = await _spill_executor().execute_many(
[(Tool(name="structured", handler=lambda: output), {})], context=context
)
@@ -199,11 +202,11 @@ def after_tool_call(self, result: ToolCallResult) -> None:
plugin_manager.add_hookspecs(BubHookSpecs)
plugin_manager.register(BuiltinImpl(None), name="builtin") # type: ignore[arg-type]
plugin_manager.register(ExpandResult(), name="expand-result")
- executor = ToolExecutor(hooks=AgentHooks(HookRuntime(plugin_manager)))
+ executor = ToolExecutor(PluginAgentHooks(HookRuntime(plugin_manager)).subscribe())
root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100)
async with root.fork_tape() as tape:
- execution = await executor.execute_async(
+ execution = await executor.execute_many(
[(Tool(name="expanded", handler=lambda: "small"), {})],
context=_spill_context(tape=tape, run_id="run-1"),
)
@@ -235,7 +238,7 @@ def after_tool_call(self, result: ToolCallResult) -> None:
plugin_manager.add_hookspecs(BubHookSpecs)
plugin_manager.register(BuiltinImpl(None), name="builtin") # type: ignore[arg-type]
plugin_manager.register(ReplaceFailure(), name="replace-failure")
- executor = ToolExecutor(hooks=AgentHooks(HookRuntime(plugin_manager)))
+ executor = ToolExecutor(PluginAgentHooks(HookRuntime(plugin_manager)).subscribe())
root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100)
def fail() -> None:
@@ -243,7 +246,8 @@ def fail() -> None:
async with root.fork_tape() as tape:
context = _spill_context(tape=tape, run_id="run-1")
- execution = await executor.execute_async([(Tool(name="bash", handler=fail), {})], context=context)
+ context.state["_runtime_tool_names"] = ("bash", SPILL_READ_MODEL_NAME)
+ execution = await executor.execute_many([(Tool(name="bash", handler=fail), {})], context=context)
assert execution.error is not None
result = execution.tool_results[0]
@@ -266,7 +270,7 @@ def fail() -> None:
async with root.fork_tape() as tape:
context = _spill_context(tape=tape, run_id="run-1")
- execution = await _spill_executor().execute_async([(Tool(name="failing", handler=fail), {})], context=context)
+ execution = await _spill_executor().execute_many([(Tool(name="failing", handler=fail), {})], context=context)
assert execution.error is not None
ref = execution.tool_results[0]
@@ -286,7 +290,7 @@ async def test_temporary_fork_discards_spilled_content(tmp_path: Path) -> None:
async with root.fork_tape(merge_back=False) as tape:
context = _spill_context(tape=tape, run_id="run-1")
- execution = await _spill_executor().execute_async(
+ execution = await _spill_executor().execute_many(
[(Tool(name="large", handler=lambda: "x" * 20_000), {})], context=context
)
handle = _handle_from_ref(execution.tool_results[0])
@@ -321,7 +325,7 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp
await root.ensure_bootstrap_anchor()
async with root.fork_tape() as tape:
- execution = await _spill_executor().execute_async(
+ execution = await _spill_executor().execute_many(
[(Tool(name="large", handler=lambda: "archived output\n" * 5000), {})],
context=_spill_context(tape=tape, run_id="run-1"),
)
@@ -331,13 +335,13 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp
await tape.record_chat(
run_id="run-1",
system_prompt=None,
- new_messages=[{"role": "user", "content": "archive this"}],
+ new_messages=[republic.user("archive this")],
response_text=None,
- tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "large", "arguments": "{}"}}],
+ tool_calls=[{"id": "call-1", "name": "large", "arguments": "{}"}],
tool_results=execution.tool_results,
)
- result = await root.reset(archive=True)
+ result = await reset_tape(root, archive_to=tmp_path)
main_archive = Path(result.removeprefix("Archived: "))
spill_archives = list(tmp_path.glob(f"{sidecar}.jsonl.*.bak"))
@@ -346,6 +350,11 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp
assert handle in main_archive.read_text(encoding="utf-8")
assert handle in spill_archives[0].read_text(encoding="utf-8")
assert "no spilled tool result" in await _read_page(_spill_context(tape=root), handle)
+ events = [entry.payload for entry in await root.store.fetch_all(root.query().kinds("event"))]
+ assert [(event["name"], event["data"]["status"]) for event in events if event["name"].startswith("sidecar.")] == [
+ ("sidecar.reset", "ok"),
+ ("sidecar.archive", "ok"),
+ ]
@pytest.mark.asyncio
@@ -355,8 +364,8 @@ async def test_code_mode_results_stay_structured_and_are_not_spilled(tmp_path: P
async with root.fork_tape() as tape:
context = _spill_context(tape=tape, run_id="run-1", code_mode=True)
- executor = ToolExecutor(hooks=_SpillHooks(), render=False) # type: ignore[arg-type]
- execution = await executor.execute_async(
+ executor = ToolExecutor(_spill_events(), render=False)
+ execution = await executor.execute_many(
[(Tool(name="structured", handler=lambda: output), {})], context=context
)
@@ -366,7 +375,7 @@ async def test_code_mode_results_stay_structured_and_are_not_spilled(tmp_path: P
@pytest.mark.parametrize("fails", [False, True])
@pytest.mark.asyncio
async def test_result_spill_requires_reader_in_current_model_tools(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fails: bool
+ tmp_path: Path, provider_transport: ProviderService, fails: bool
) -> None:
class SpillPlugin:
@hookimpl(trylast=True)
@@ -376,9 +385,9 @@ async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: d
plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE)
plugin_manager.add_hookspecs(BubHookSpecs)
plugin_manager.register(SpillPlugin())
- runner = ModelRunner(
- AgentSettings.model_construct(model="test-model", model_timeout_seconds=None),
- hooks=AgentHooks(HookRuntime(plugin_manager)),
+ runner = StepRunner(
+ AgentSettings(model="openai:test", client_args={"api_format": "chat"}),
+ hooks=PluginAgentHooks(HookRuntime(plugin_manager)),
)
output = "large output " * 2000
@@ -387,32 +396,8 @@ def large() -> str:
raise RuntimeError(output)
return output
- async def complete(**kwargs: Any) -> ChatCompletion:
- return ChatCompletion.model_validate({
- "id": "completion-1",
- "object": "chat.completion",
- "created": 0,
- "model": "test-model",
- "choices": [
- {
- "index": 0,
- "finish_reason": "tool_calls",
- "message": {
- "role": "assistant",
- "content": None,
- "tool_calls": [
- {
- "id": "call-large",
- "type": "function",
- "function": {"name": "large", "arguments": "{}"},
- }
- ],
- },
- }
- ],
- })
-
- monkeypatch.setattr(runner, "completion_response", complete)
+ for _ in range(3):
+ provider_transport.reply_chat(calls=[{"id": "call-large", "name": "large", "arguments": "{}"}])
root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100)
async with root.fork_tape() as tape:
for reader_available in (False, True, False):
@@ -422,7 +407,7 @@ async def complete(**kwargs: Any) -> ChatCompletion:
events = [
event
async for event in runner.run(
- tape=tape, model="test-model", tools=model_tools(tools), system_prompt=None, prompt="Run large."
+ tape=tape, model="openai:test", tools=model_tools(tools), system_prompt=None, prompt="Run large."
)
]
result = next(event.data["tool_results"][0] for event in events if event.kind == "tool_result")
@@ -441,9 +426,29 @@ async def complete(**kwargs: Any) -> ChatCompletion:
async def test_result_spill_without_tool_availability_keeps_full_output(tmp_path: Path) -> None:
root = _root_tape(tmp_path, InMemoryTapeStore())
output = "x" * 20_000
- execution = await _spill_executor().execute_async(
+ execution = await _spill_executor().execute_many(
[(Tool(name="large", handler=lambda: output), {})], context=ToolContext(tape=root)
)
assert execution.tool_results == [output]
assert list(await root.store.fetch_all(root.scoped(root.sidecar_tape_name(SPILL_SIDECAR_NAME)).query())) == []
+
+
+@pytest.mark.asyncio
+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
+
+ image = republic.Image("image/png", data=b"png")
+ root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100)
+
+ async with root.fork_tape() as tape:
+ execution = await _spill_executor().execute_many(
+ [(Tool(name="run_code", handler=lambda: content_result(["x" * 20_000, image])), {})],
+ context=_spill_context(tape=tape, run_id="run-1"),
+ )
+
+ text, media = result_content(execution.tool_results[0])
+ assert "tool output spilled" in text
+ assert media == image
diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py
index 1826de24..f3e44d0a 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.tools import REGISTRY, tool
from bub.streaming import AsyncStreamEvents, StreamEvent
-from bub.tools import REGISTRY, tool
class FakeContext:
diff --git a/tests/test_tape.py b/tests/test_tape.py
index 242b4433..e0874722 100644
--- a/tests/test_tape.py
+++ b/tests/test_tape.py
@@ -3,14 +3,15 @@
from pathlib import Path
import pytest
+import republic
-from bub.builtin.context import default_tape_context
-from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore
-from bub.tape import Tape, TapeContext, TapeEntry
+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
def test_tape_reexports_legacy_store_objects() -> None:
- from bub import store, tape
+ from bub.core import store, tape
expected_exports = {
"AsyncTapeStore",
@@ -31,32 +32,54 @@ def test_tape_reexports_legacy_store_objects() -> None:
@pytest.mark.asyncio
async def test_legacy_tool_call_without_content_replays_with_its_result(tmp_path: Path) -> None:
store = InMemoryTapeStore()
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape")
+ tape = Tape(AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape")
await tape.ensure_bootstrap_anchor()
- calls = [{"id": "call-1", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}]
+ calls = [{"id": "call-1", "name": "inspect", "arguments": "{}"}]
store.append("test-tape", TapeEntry(id=0, kind="tool_call", payload={"calls": calls}))
store.append("test-tape", TapeEntry.tool_result(["files found"]))
+ call = republic.ToolCall("call-1", "inspect", "{}")
assert await tape.read_messages() == [
- {"role": "assistant", "content": "", "tool_calls": calls},
- {"role": "tool", "content": "files found", "tool_call_id": "call-1", "name": "inspect"},
+ republic.assistant("", tool_calls=[call]),
+ republic.tool(call, "files found"),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_chat_completions_tool_calls_from_older_tapes_replay_with_their_results(tmp_path: Path) -> None:
+ store = InMemoryTapeStore()
+ tape = Tape(AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape")
+ await tape.ensure_bootstrap_anchor()
+ calls = [
+ {"id": "call-1", "type": "function", "function": {"name": "fs_read", "arguments": '{"path": "a.txt"}'}},
+ {"id": "call-2", "type": "function", "function": {"name": "tape_info", "arguments": {}}},
+ ]
+ store.append("test-tape", TapeEntry.tool_call(calls, content="Let me look."))
+ store.append("test-tape", TapeEntry.tool_result(["hello", {"entries": 3}]))
+
+ read = republic.ToolCall("call-1", "fs_read", '{"path": "a.txt"}')
+ info = republic.ToolCall("call-2", "tape_info", "{}")
+ assert await tape.read_messages() == [
+ republic.assistant("Let me look.", tool_calls=[read, info]),
+ republic.tool(read, "hello"),
+ republic.tool(info, '{"entries": 3}'),
]
@pytest.mark.asyncio
@pytest.mark.parametrize("content", ["", "done"])
async def test_text_only_response_remains_a_standalone_assistant_message(tmp_path: Path, content: str) -> None:
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape")
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape")
await tape.ensure_bootstrap_anchor()
await tape.record_chat(run_id="run-1", system_prompt=None, new_messages=[], response_text=content)
- assert await tape.read_messages() == [{"role": "assistant", "content": content}]
+ assert await tape.read_messages() == [republic.assistant(content)]
@pytest.mark.asyncio
async def test_tape_fork_binds_temporary_fork_store_to_scoped_tape(tmp_path: Path) -> None:
parent = InMemoryTapeStore()
- root = Tape(tmp_path, AsyncTapeStoreAdapter(parent), TapeContext()).scoped("test-tape")
+ root = Tape(AsyncTapeStoreAdapter(parent), TapeContext()).scoped("test-tape")
async with root.fork_tape(merge_back=True) as forked:
first_store = forked.store
@@ -80,17 +103,17 @@ async def test_tape_fork_binds_temporary_fork_store_to_scoped_tape(tmp_path: Pat
@pytest.mark.asyncio
async def test_tape_info_reports_last_token_cache_hit_rate(tmp_path: Path) -> None:
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
await tape.record_chat(
run_id="run-1",
system_prompt=None,
new_messages=[],
response_text=None,
usage={
- "prompt_tokens": 80,
- "completion_tokens": 20,
+ "input_tokens": 80,
+ "output_tokens": 20,
"total_tokens": 100,
- "prompt_tokens_details": {"cached_tokens": 60},
+ "cached_tokens": 60,
},
)
@@ -102,13 +125,13 @@ async def test_tape_info_reports_last_token_cache_hit_rate(tmp_path: Path) -> No
@pytest.mark.asyncio
async def test_tape_info_omits_cache_hit_rate_when_usage_has_no_cache_details(tmp_path: Path) -> None:
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
await tape.record_chat(
run_id="run-1",
system_prompt=None,
new_messages=[],
response_text=None,
- usage={"prompt_tokens": 80, "completion_tokens": 20, "total_tokens": 100},
+ usage={"input_tokens": 80, "output_tokens": 20, "total_tokens": 100},
)
info = await tape.info()
@@ -126,7 +149,6 @@ def select_events(entries, _context):
]
tape = Tape(
- tmp_path,
AsyncTapeStoreAdapter(InMemoryTapeStore()),
TapeContext(anchor=None, select=select_events),
).scoped("test-tape")
@@ -134,3 +156,45 @@ def select_events(entries, _context):
await tape.append_event("hidden", {}, context=False)
assert await tape.read_messages() == [{"role": "assistant", "content": "visible"}]
+
+
+@pytest.mark.asyncio
+async def test_new_messages_round_trip_through_the_tape(tmp_path: Path) -> None:
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape")
+ await tape.ensure_bootstrap_anchor()
+ prompt = republic.user("Describe this.", republic.image(b"png-bytes", media_type="image/png"))
+ await tape.record_chat(run_id="run-1", system_prompt=None, new_messages=[prompt], response_text="A picture.")
+
+ assert await tape.read_messages() == [prompt, republic.assistant("A picture.")]
+
+
+def test_legacy_payloads_convert_to_republic_messages() -> None:
+ call = republic.ToolCall("call-1", "inspect", "{}")
+ payloads = [
+ {
+ "role": "user",
+ "content": [{"type": "image", "url": "data:image/png;base64,cG5n", "media_type": "image/png"}],
+ },
+ {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [{"id": "call-1", "name": "inspect", "arguments": "{}"}],
+ "reasoning": "Look first.",
+ "provider_data": [{"api_format": "responses", "payload": {"type": "reasoning"}}],
+ },
+ {"role": "tool", "tool_call_id": "call-1", "content": {"files": 2}},
+ ]
+
+ assert to_messages(payloads) == [
+ republic.user(republic.Image("image/png", data=b"png")),
+ republic.Message(
+ "assistant",
+ (
+ republic.Text(""),
+ republic.Reasoning("Look first."),
+ republic.ProviderData("responses", {"type": "reasoning"}),
+ ),
+ tool_calls=(call,),
+ ),
+ republic.tool(call, '{"files": 2}'),
+ ]
diff --git a/tests/test_tape_search_output.py b/tests/test_tape_search_output.py
index f30e2403..c16fc9f1 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.tools import ToolContext
+from bub.core.tools import ToolContext
@dataclass(frozen=True)
diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py
index 2ef62e4c..e0658693 100644
--- a/tests/test_tool_loading.py
+++ b/tests/test_tool_loading.py
@@ -5,31 +5,17 @@
from typing import Any
import pytest
-from any_llm.types.completion import ChatCompletion
-from bub.builtin.agent import Agent
+from bub.builtin.agent import BubAgent
+from bub.core.tools import Tool
from bub.framework import BubFramework
-from bub.tools import Tool
-
-
-def _completion(message: dict[str, Any]) -> ChatCompletion:
- return ChatCompletion.model_validate({
- "id": "reply",
- "model": "test-model",
- "created": 0,
- "object": "chat.completion",
- "choices": [
- {"index": 0, "finish_reason": "tool_calls" if "tool_calls" in message else "stop", "message": message}
- ],
- })
+from tests.model_fakes import ProviderService
def _tool_call(call_id: str, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
return {
"role": "assistant",
- "tool_calls": [
- {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments)}}
- ],
+ "tool_calls": [{"id": call_id, "name": name, "arguments": json.dumps(arguments)}],
}
@@ -44,29 +30,22 @@ def _system_prompt(request: dict[str, Any]) -> str:
@pytest.fixture
def framework(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> BubFramework:
monkeypatch.setenv("BUB_HOME", str(tmp_path))
+ monkeypatch.setenv("BUB_CLIENT_ARGS", '{"api_format":"chat"}')
framework = BubFramework(config_file=tmp_path / "config.yml")
framework.workspace = tmp_path
framework.load_builtin_hooks()
return framework
-def _scripted_provider(monkeypatch: pytest.MonkeyPatch, replies: list[dict[str, Any]]) -> list[dict[str, Any]]:
- requests: list[dict[str, Any]] = []
-
- class Provider:
- SUPPORTS_COMPLETION_STREAMING = False
-
- async def acompletion(self, **kwargs: Any) -> ChatCompletion:
- requests.append(kwargs)
- return _completion(replies[len(requests) - 1])
-
- monkeypatch.setattr("bub.builtin.model_runner.AnyLLM.create", lambda *args, **kwargs: Provider())
- return requests
+def _scripted_provider(provider: ProviderService, replies: list[dict[str, Any]]) -> ProviderService:
+ for message in replies:
+ provider.reply_chat(message.get("content", ""), message.get("tool_calls"))
+ return provider
@pytest.mark.asyncio
async def test_deferred_tool_is_loaded_through_tool_describe_and_persists_on_tape(
- framework: BubFramework, monkeypatch: pytest.MonkeyPatch
+ framework: BubFramework, provider_transport: ProviderService
) -> None:
calls: list[str] = []
@@ -78,7 +57,7 @@ def lookup(name: str) -> str:
supplied = Tool.from_callable(lookup, name="provider.lookup", deferred=True)
other = Tool.from_callable(lambda: "other", name="other", deferred=True)
requests = _scripted_provider(
- monkeypatch,
+ provider_transport,
[
_tool_call("describe", "tool_describe", {"names": ["provider_lookup"]}),
_tool_call("lookup", "provider_lookup", {"name": "Ada"}),
@@ -86,43 +65,43 @@ def lookup(name: str) -> str:
{"role": "assistant", "content": "Again"},
],
)
- agent = Agent(framework, tools=[direct, supplied, other], skill_dirs=[])
+ agent = BubAgent(framework, tools=[direct, supplied, other], skill_dirs=[])
stream = await agent.run_stream(session_id="loading", prompt="Greet Ada.", model="openrouter:test-model")
events = [event async for event in stream]
assert any(event.data.get("text") == "Hello Ada" for event in events if event.kind == "final")
assert calls == ["Ada"]
- assert _tool_names(requests[0]) == ["direct", "tool_describe"]
- assert _tool_names(requests[1]) == ["direct", "tool_describe", "provider_lookup"]
- describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool")
+ assert _tool_names(requests.body(0)) == ["direct", "tool_describe"]
+ assert _tool_names(requests.body(1)) == ["direct", "tool_describe", "provider_lookup"]
+ describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool")
assert json.loads(describe_result["content"])["tools"][0]["name"] == "provider_lookup"
- prompt = _system_prompt(requests[0])
+ prompt = _system_prompt(requests.body(0))
assert "" in prompt
assert "- provider_lookup" in prompt
assert "- other" in prompt
stream = await agent.run_stream(session_id="loading", prompt="Again.", model="openrouter:test-model")
_ = [event async for event in stream]
- assert _tool_names(requests[3]) == ["direct", "tool_describe", "provider_lookup"]
+ assert _tool_names(requests.body(3)) == ["direct", "tool_describe", "provider_lookup"]
@pytest.mark.asyncio
async def test_allowed_tools_filter_covers_direct_and_deferred_tools(
- framework: BubFramework, monkeypatch: pytest.MonkeyPatch
+ framework: BubFramework, provider_transport: ProviderService
) -> None:
direct = Tool.from_callable(lambda: "direct", name="direct")
denied = Tool.from_callable(lambda: "denied", name="denied")
allowed_deferred = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True)
denied_deferred = Tool.from_callable(lambda: "secret", name="secret", deferred=True)
requests = _scripted_provider(
- monkeypatch,
+ provider_transport,
[
_tool_call("describe", "tool_describe", {"names": ["provider_lookup", "secret"]}),
{"role": "assistant", "content": "done"},
],
)
- agent = Agent(framework, tools=[direct, denied, allowed_deferred, denied_deferred], skill_dirs=[])
+ agent = BubAgent(framework, tools=[direct, denied, allowed_deferred, denied_deferred], skill_dirs=[])
stream = await agent.run_stream(
session_id="allowed",
@@ -132,75 +111,75 @@ async def test_allowed_tools_filter_covers_direct_and_deferred_tools(
)
_ = [event async for event in stream]
- assert _tool_names(requests[0]) == ["direct", "tool_describe"]
- assert _tool_names(requests[1]) == ["direct", "tool_describe", "provider_lookup"]
- prompt = _system_prompt(requests[0])
+ assert _tool_names(requests.body(0)) == ["direct", "tool_describe"]
+ assert _tool_names(requests.body(1)) == ["direct", "tool_describe", "provider_lookup"]
+ prompt = _system_prompt(requests.body(0))
assert "- provider_lookup" in prompt
assert "secret" not in prompt
- describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool")
+ describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool")
assert json.loads(describe_result["content"])["unknown"] == ["secret"]
@pytest.mark.asyncio
async def test_tool_describe_is_hidden_without_deferred_tools(
- framework: BubFramework, monkeypatch: pytest.MonkeyPatch
+ framework: BubFramework, provider_transport: ProviderService
) -> None:
from bub.builtin.tools import tool_describe
direct = Tool.from_callable(lambda: "direct", name="direct")
- requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}])
- agent = Agent(framework, tools=[direct, tool_describe], skill_dirs=[])
+ requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}])
+ agent = BubAgent(framework, tools=[direct, tool_describe], skill_dirs=[])
stream = await agent.run_stream(session_id="plain", prompt="Hi.", model="openrouter:test-model")
_ = [event async for event in stream]
- assert _tool_names(requests[0]) == ["direct"]
- assert "" not in _system_prompt(requests[0])
+ assert _tool_names(requests.body(0)) == ["direct"]
+ assert "" not in _system_prompt(requests.body(0))
@pytest.mark.asyncio
async def test_calling_unloaded_deferred_tool_returns_load_guidance(
- framework: BubFramework, monkeypatch: pytest.MonkeyPatch
+ framework: BubFramework, provider_transport: ProviderService
) -> None:
calls: list[str] = []
supplied = Tool.from_callable(lambda: calls.append("lookup") or "ok", name="provider.lookup", deferred=True)
requests = _scripted_provider(
- monkeypatch,
+ provider_transport,
[_tool_call("lookup", "provider_lookup", {}), {"role": "assistant", "content": "done"}],
)
- agent = Agent(framework, tools=[supplied], skill_dirs=[])
+ agent = BubAgent(framework, tools=[supplied], skill_dirs=[])
stream = await agent.run_stream(session_id="unloaded", prompt="Look up.", model="openrouter:test-model")
_ = [event async for event in stream]
assert calls == []
- result = next(message for message in requests[1]["messages"] if message["role"] == "tool")
+ result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool")
assert result["content"] == "Tool `provider_lookup` is not loaded. Call `tool_describe` with its name first."
@pytest.mark.asyncio
async def test_code_mode_exposes_deferred_tools_to_code_without_loading(
- framework: BubFramework, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+ framework: BubFramework, provider_transport: ProviderService, tmp_path: Path
) -> None:
from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, run_code
direct = Tool.from_callable(lambda: "direct", name="direct")
supplied = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True)
- requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}])
- agent = Agent(framework, tools=[direct, supplied, run_code], skill_dirs=[])
+ requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}])
+ agent = BubAgent(framework, tools=[direct, supplied, run_code], skill_dirs=[])
state: dict[str, Any] = {"code_mode": True, "_runtime_workspace": str(tmp_path)}
stream = await agent.run_stream(session_id="code", prompt="Hi.", model="openrouter:test-model", state=state)
_ = [event async for event in stream]
- assert _tool_names(requests[0]) == ["run_code"]
+ assert _tool_names(requests.body(0)) == ["run_code"]
assert [tool.name for tool in state[CODE_TOOLS_STATE_KEY]] == ["direct", "provider_lookup"]
- assert "" not in _system_prompt(requests[0])
+ assert "" not in _system_prompt(requests.body(0))
@pytest.mark.asyncio
async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode(
- framework: BubFramework, monkeypatch: pytest.MonkeyPatch
+ framework: BubFramework, provider_transport: ProviderService
) -> None:
from bub.builtin.tools import tool_describe
@@ -208,55 +187,55 @@ async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode(
code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code")
deferred_code_tool = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True, exposure="code")
requests = _scripted_provider(
- monkeypatch,
+ provider_transport,
[_tool_call("secret", "secret", {}), {"role": "assistant", "content": "done"}],
)
- agent = Agent(framework, tools=[direct, code_tool, deferred_code_tool, tool_describe], skill_dirs=[])
+ agent = BubAgent(framework, tools=[direct, code_tool, deferred_code_tool, tool_describe], skill_dirs=[])
stream = await agent.run_stream(session_id="plain", prompt="Hi.", model="openrouter:test-model")
_ = [event async for event in stream]
- assert _tool_names(requests[0]) == ["direct"]
- assert "" not in _system_prompt(requests[0])
- result = next(message for message in requests[1]["messages"] if message["role"] == "tool")
+ assert _tool_names(requests.body(0)) == ["direct"]
+ assert "" not in _system_prompt(requests.body(0))
+ result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool")
assert result["content"].startswith("Tool `secret` does not exist.")
@pytest.mark.asyncio
async def test_code_exposure_tools_are_exposed_to_code_in_code_mode(
- framework: BubFramework, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+ framework: BubFramework, provider_transport: ProviderService, tmp_path: Path
) -> None:
from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, run_code
direct = Tool.from_callable(lambda: "direct", name="direct")
code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code")
- requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}])
- agent = Agent(framework, tools=[direct, code_tool, run_code], skill_dirs=[])
+ requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}])
+ agent = BubAgent(framework, tools=[direct, code_tool, run_code], skill_dirs=[])
state: dict[str, Any] = {"code_mode": True, "_runtime_workspace": str(tmp_path)}
stream = await agent.run_stream(session_id="code", prompt="Hi.", model="openrouter:test-model", state=state)
_ = [event async for event in stream]
- assert _tool_names(requests[0]) == ["run_code"]
+ assert _tool_names(requests.body(0)) == ["run_code"]
assert [tool.name for tool in state[CODE_TOOLS_STATE_KEY]] == ["direct", "secret"]
@pytest.mark.asyncio
async def test_tool_describe_does_not_describe_code_exposure_tools(
- framework: BubFramework, monkeypatch: pytest.MonkeyPatch
+ framework: BubFramework, provider_transport: ProviderService
) -> None:
from bub.builtin.tools import tool_describe
deferred = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True)
code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code")
requests = _scripted_provider(
- monkeypatch,
+ provider_transport,
[_tool_call("describe", "tool_describe", {"names": ["secret"]}), {"role": "assistant", "content": "done"}],
)
- agent = Agent(framework, tools=[deferred, code_tool, tool_describe], skill_dirs=[])
+ agent = BubAgent(framework, tools=[deferred, code_tool, tool_describe], skill_dirs=[])
stream = await agent.run_stream(session_id="describe", prompt="Hi.", model="openrouter:test-model")
_ = [event async for event in stream]
- describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool")
+ describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool")
assert json.loads(describe_result["content"]) == {"tools": [], "unknown": ["secret"]}
diff --git a/tests/test_tools.py b/tests/test_tools.py
index ebf30e45..08441506 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -1,15 +1,17 @@
from __future__ import annotations
+from dataclasses import replace
from typing import Any
import pytest
from loguru import logger
from pydantic import BaseModel
-from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult
-from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore
-from bub.tape import Tape, TapeContext
-from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool, tool_call_reporter
+from bub.core.events import EventBus, ToolCallFinished, ToolCallStarted
+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.errors import BubError
class EchoInput(BaseModel):
@@ -30,12 +32,9 @@ def test_tool_builds_completion_payload() -> None:
)
assert sample_tool.to_schema() == {
- "type": "function",
- "function": {
- "name": "tests_sample_tool",
- "description": "Sample tool",
- "parameters": parameters,
- },
+ "name": "tests_sample_tool",
+ "description": "Sample tool",
+ "parameters": parameters,
}
@@ -96,87 +95,68 @@ def internal_tool(value: str) -> str:
@pytest.mark.asyncio
-async def test_tool_wrapper_logs_and_omits_context_from_log_payload(monkeypatch: pytest.MonkeyPatch) -> None:
- tool_name = "tests.async_tool"
- REGISTRY.pop(tool_name, None)
+async def test_tool_executor_logs_calls_without_context(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
messages: list[str] = []
def record(message: str, *args: Any, **kwargs: Any) -> None:
messages.append(message.format(*args, **kwargs))
monkeypatch.setattr(logger, "info", record)
+ echo = Tool.from_callable(lambda value, context: value, name="tests.logged_tool", context=True)
- @tool(name=tool_name, description="Async test tool", context=True)
- async def async_tool(value: str, context: object) -> str:
- return f"{value}:{context}"
-
- result = await async_tool.run("hello", context="ctx")
+ execution = await ToolExecutor().execute_many([(echo, {"value": "hello"})], context=_context(tmp_path))
- assert result == "hello:ctx"
- assert REGISTRY[tool_name] is async_tool
+ assert execution.tool_results == ["hello"]
assert len(messages) == 2
- assert messages[0] == 'tool.call.start name=tests.async_tool { "hello" }'
- assert messages[1].startswith("tool.call.success name=tests.async_tool elapsed_time=")
+ assert messages[0] == 'tool.call.start name=tests.logged_tool { value="hello" }'
+ assert messages[1].startswith("tool.call.success name=tests.logged_tool elapsed_time=")
@pytest.mark.asyncio
-async def test_tool_wrapper_logs_failures_before_reraising(monkeypatch: pytest.MonkeyPatch) -> None:
- tool_name = "tests.failing_tool"
- REGISTRY.pop(tool_name, None)
- errors: list[str] = []
+async def test_tool_executor_logs_failures(monkeypatch: pytest.MonkeyPatch) -> None:
+ errors: list[tuple[str, BaseException | None]] = []
- def record_exception(message: str, *args: Any, **kwargs: Any) -> None:
- errors.append(message.format(*args, **kwargs))
+ class Recorder:
+ def __init__(self, exception: BaseException | None = None) -> None:
+ self.exception = exception
- monkeypatch.setattr(logger, "exception", record_exception)
+ def opt(self, *, exception: BaseException | None = None) -> Recorder:
+ return Recorder(exception)
- @tool(name=tool_name)
- def failing_tool() -> str:
- raise RuntimeError("boom")
-
- with pytest.raises(RuntimeError, match="boom"):
- await failing_tool.run()
+ def info(self, message: str, *args: Any) -> None:
+ pass
- assert len(errors) == 1
- assert errors[0].startswith("tool.call.error name=tests.failing_tool elapsed_time=")
+ def error(self, message: str, *args: Any) -> None:
+ errors.append((message.format(*args), self.exception))
+ monkeypatch.setattr("bub.core.tools.logger", Recorder())
-@pytest.mark.asyncio
-async def test_tool_wrapper_uses_reporter_instead_of_logs(monkeypatch: pytest.MonkeyPatch) -> None:
- tool_name = "tests.reported_tool"
- REGISTRY.pop(tool_name, None)
- logged: list[str] = []
- reported: list[tuple[str, str, Any]] = []
-
- def record_log(message: str, *args: Any, **kwargs: Any) -> None:
- logged.append(message.format(*args, **kwargs))
+ def failing_tool() -> str:
+ raise RuntimeError("boom")
- class Reporter:
- def start(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
- reported.append(("start", name, {"args": args, "kwargs": kwargs}))
+ execution = await ToolExecutor().execute_many([(Tool.from_callable(failing_tool), {})])
- def success(self, name: str, result: Any, elapsed_ms: float) -> None:
- reported.append(("success", name, {"result": result, "elapsed_ms": elapsed_ms}))
+ assert execution.error is not None
+ ((message, exception),) = errors
+ assert message.startswith("tool.call.error name=failing_tool elapsed_time=")
+ assert exception is execution.error
+ assert isinstance(exception.__cause__, RuntimeError)
- def error(self, name: str, error: BaseException, elapsed_ms: float) -> None:
- reported.append(("error", name, {"error": error, "elapsed_ms": elapsed_ms}))
- monkeypatch.setattr(logger, "info", record_log)
- monkeypatch.setattr(logger, "exception", record_log)
+@pytest.mark.asyncio
+async def test_tool_executor_logs_arguments_changed_by_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
+ messages: list[str] = []
+ monkeypatch.setattr(logger, "info", lambda message, *args: messages.append(message.format(*args)))
+ events = EventBus()
- @tool(name=tool_name)
- def reported_tool(value: str) -> str:
- return value.upper()
+ @events.on(ToolCallStarted)
+ def rewrite(event: ToolCallStarted) -> None:
+ event.call = replace(event.call, arguments={"value": "rewritten"})
- with tool_call_reporter(Reporter()):
- result = await reported_tool.run("hello")
+ echo = Tool.from_callable(lambda value: value, name="tests.echo")
+ await ToolExecutor(events).execute_many([(echo, {"value": "original"})])
- assert result == "HELLO"
- assert logged == []
- assert reported[0] == ("start", tool_name, {"args": ("hello",), "kwargs": {}})
- assert reported[1][0] == "success"
- assert reported[1][1] == tool_name
- assert reported[1][2]["result"] == "HELLO"
+ assert messages[0] == 'tool.call.start name=tests.echo { value="rewritten" }'
@pytest.mark.asyncio
@@ -194,7 +174,7 @@ def direct_call(value: str) -> str:
def _context(tmp_path, *, code_mode: bool = False) -> ToolContext:
- tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
+ tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape")
return ToolContext(tape=tape, run_id="run-1", code_mode=code_mode)
@@ -240,7 +220,7 @@ async def test_executor_render_option_decides_result_format(
renderer=lambda result: f"count={result['count']}",
)
- execution = await ToolExecutor(render=render).execute_async(
+ execution = await ToolExecutor(render=render).execute_many(
[(structured, {})], context=_context(tmp_path, code_mode=code_mode)
)
@@ -252,19 +232,15 @@ async def test_executor_render_option_decides_result_format(
async def test_executor_passes_tool_context_to_context_tools_and_hooks(tmp_path) -> None:
seen: dict[str, Any] = {}
- class RecordingHooks:
- async def before_tool_call(self, call: ToolCall, state: dict[str, Any]) -> tuple[ToolCall, ToolCallDecision]:
- seen["before"] = call.context
- return call, ToolCallDecision.proceed()
-
- async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict[str, Any]) -> None:
- seen["after"] = (call.context, result.result)
+ events = EventBus()
+ events.on(ToolCallStarted, lambda event: seen.update(before=event.call.context))
+ events.on(ToolCallFinished, lambda event: seen.update(after=(event.call.context, event.result.result)))
def mode(*, context: ToolContext) -> dict[str, bool]:
return {"code_mode": context.code_mode}
context = _context(tmp_path, code_mode=True)
- execution = await ToolExecutor(hooks=RecordingHooks(), render=False).execute_async( # type: ignore[arg-type]
+ execution = await ToolExecutor(events, render=False).execute_many(
[(Tool.from_callable(mode, context=True), {})], context=context
)
@@ -273,6 +249,26 @@ def mode(*, context: ToolContext) -> dict[str, bool]:
assert seen["after"] == (context, {"code_mode": True})
+@pytest.mark.asyncio
+async def test_executor_execute_returns_the_result_or_raises_the_tool_error() -> None:
+ events = EventBus()
+ finished: list[ToolCallFinished] = []
+ events.on(ToolCallFinished, finished.append)
+ executor = ToolExecutor(events, render=False)
+
+ def failing_tool() -> str:
+ raise RuntimeError("boom")
+
+ assert await executor.execute(Tool.from_callable(lambda value: {"value": value}, name="echo"), {"value": 1}) == {
+ "value": 1
+ }
+ with pytest.raises(BubError, match="Tool 'failing_tool' execution failed") as raised:
+ await executor.execute(Tool.from_callable(failing_tool), {})
+
+ assert isinstance(raised.value.__cause__, RuntimeError)
+ assert [event.call.tool for event in finished] == ["echo", "failing_tool"]
+
+
@pytest.mark.asyncio
async def test_executor_reports_renderer_failures_as_tool_errors(tmp_path) -> None:
def broken_renderer(_result: object) -> str:
@@ -280,7 +276,7 @@ def broken_renderer(_result: object) -> str:
broken = Tool(name="tests.broken_renderer", handler=lambda: {}, renderer=broken_renderer)
- execution = await ToolExecutor().execute_async([(broken, {})], context=_context(tmp_path))
+ execution = await ToolExecutor().execute_many([(broken, {})], context=_context(tmp_path))
assert execution.error is not None
assert execution.tool_results[0]["message"] == "Tool 'tests.broken_renderer' execution failed."
diff --git a/tests/test_tracing.py b/tests/test_tracing.py
index 1f3a9a3c..ff820122 100644
--- a/tests/test_tracing.py
+++ b/tests/test_tracing.py
@@ -10,22 +10,20 @@
from typing import Any
import pytest
-from any_llm.constants import LLMProvider
-from any_llm.types.completion import ChatCompletion, ChatCompletionChunk
from bub import tracing
-from bub.builtin.agent import Agent
-from bub.builtin.context import default_tape_context
-from bub.builtin.model_runner import ModelRunner
-from bub.builtin.settings import AgentSettings, ModelCandidate
+from bub.builtin.agent import BubAgent
+from bub.builtin.settings import AgentSettings
+from bub.core.context import default_tape_context
+from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore
+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.store import AsyncTapeStoreAdapter, InMemoryTapeStore
+from bub.hooks.interception import LLMCallDecision, ToolCallDecision, ToolCallResult
from bub.streaming import AsyncStreamEvents, StreamEvent
-from bub.tape import Tape
-from bub.tools import Tool, ToolContext, ToolExecutor
from bub.utils import workspace_from_state
+from tests.model_fakes import ProviderService, chat_events, sse
@pytest.fixture
@@ -43,42 +41,25 @@ def spans(monkeypatch: pytest.MonkeyPatch) -> Iterator[Any]:
@pytest.fixture
-def agent(tmp_path: Path) -> Agent:
+def agent(tmp_path: Path, provider_transport: ProviderService) -> BubAgent:
framework = BubFramework(config_file=tmp_path / "config.yml")
framework.load_builtin_hooks()
- agent = Agent(framework)
- agent.settings = AgentSettings.model_construct(model="openai:test", api_key="unused", api_base=None)
- agent.model_runner = ModelRunner(agent.settings, hooks=framework.get_agent_hooks())
- agent.__dict__["tape"] = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context())
+ agent = BubAgent(framework)
+ agent.settings = AgentSettings(model="openai:test", api_key="unused", client_args={"api_format": "chat"})
+ agent.__dict__["_tapes"] = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context())
return agent
-def completion(text: str = "done", calls: list[dict[str, Any]] | None = None) -> ChatCompletion:
- return ChatCompletion.model_validate({
- "id": "response-1",
- "object": "chat.completion",
- "created": 0,
- "model": "actual-model",
- "choices": [
- {
- "index": 0,
- "finish_reason": "tool_calls" if calls else "stop",
- "message": {"role": "assistant", "content": text, "tool_calls": calls},
- }
- ],
- "usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14},
- })
-
-
def call(name: str, call_id: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
- return {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments or {})}}
+ return {"id": call_id, "name": name, "arguments": json.dumps(arguments or {})}
@pytest.mark.asyncio
async def test_agent_trajectory_has_parallel_tools_messages_and_tape_links(
spans: Any,
- agent: Agent,
+ agent: BubAgent,
monkeypatch: pytest.MonkeyPatch,
+ provider_transport: ProviderService,
) -> None:
started = 0
both_started = asyncio.Event()
@@ -92,12 +73,10 @@ async def handler() -> dict[str, str]:
return {"value": "result"}
monkeypatch.setitem(agent.tools, "trace_tool", Tool(name="trace_tool", handler=handler))
- replies = iter([completion("checking", [call("trace_tool", "call-1"), call("trace_tool", "call-2")]), completion()])
-
- async def respond(**kwargs: Any) -> ChatCompletion:
- return next(replies)
-
- monkeypatch.setattr(agent.model_runner, "completion_response", respond)
+ provider_transport.reply_chat(
+ "checking", [call("trace_tool", "call-1"), call("trace_tool", "call-2")], input_tokens=10, output_tokens=4
+ )
+ provider_transport.reply_chat(input_tokens=10, output_tokens=4)
events = await agent.run_stream(session_id="trace-test", prompt="hello", state={}, allowed_tools=["trace_tool"])
async for _ in events:
assert tracing.current_span() is None
@@ -126,22 +105,19 @@ async def respond(**kwargs: Any) -> ChatCompletion:
assert [m["role"] for m in messages] == ["assistant", "tool", "tool", "assistant"]
assert messages[-1]["parts"] == [{"type": "text", "content": "done"}]
assert any(e.name == "bub.loop.step" for e in root.events)
- entries = await agent.tape.store.fetch_all(agent.tape.session_tape("trace-test", workspace_from_state({})).query())
+ entries = await agent.tape(session_tape_name("trace-test", workspace_from_state({}))).store.fetch_all(
+ agent.tape(session_tape_name("trace-test", workspace_from_state({}))).query()
+ )
assert any(e.meta.get("trace_id") == f"{root.context.trace_id:032x}" for e in entries)
@pytest.mark.asyncio
-async def test_subagent_is_nested_under_its_tool(spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch) -> None:
- replies = iter([
- completion("", [call("subagent", "child", {"prompt": "child task"})]),
- completion("child done"),
- completion(),
- ])
-
- async def respond(**kwargs: Any) -> ChatCompletion:
- return next(replies)
-
- monkeypatch.setattr(agent.model_runner, "completion_response", respond)
+async def test_subagent_is_nested_under_its_tool(
+ spans: Any, agent: BubAgent, provider_transport: ProviderService
+) -> None:
+ provider_transport.reply_chat(calls=[call("subagent", "child", {"prompt": "child task"})])
+ provider_transport.reply_chat("child done")
+ provider_transport.reply_chat()
events = await agent.run_stream(
session_id="parent", prompt="delegate", state={"_runtime_agent": agent}, allowed_tools=["subagent"]
)
@@ -162,31 +138,25 @@ async def respond(**kwargs: Any) -> ChatCompletion:
@pytest.mark.parametrize("cancel", [False, True])
async def test_stream_close_and_cancel_finish_spans_and_provider(
spans: Any,
- agent: Agent,
- monkeypatch: pytest.MonkeyPatch,
+ agent: BubAgent,
+ provider_transport: ProviderService,
cancel: bool,
) -> None:
closed = asyncio.Event()
waiting = asyncio.Event()
- async def chunks() -> AsyncIterator[ChatCompletionChunk]:
- try:
- yield ChatCompletionChunk.model_validate({
- "id": "chunk-1",
- "object": "chat.completion.chunk",
- "created": 0,
- "model": "actual",
- "choices": [{"index": 0, "delta": {"content": "first"}}],
- })
+ import httpx2
+
+ class Body(httpx2.AsyncByteStream):
+ async def __aiter__(self):
+ yield b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n'
waiting.set()
await asyncio.Event().wait()
- finally:
- closed.set()
- async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]:
- return chunks()
+ async def aclose(self):
+ closed.set()
- monkeypatch.setattr(agent.model_runner, "completion_response", respond)
+ provider_transport.reply(httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=Body()))
events = await agent.run_stream(session_id="cancel-test", prompt="hello", state={}, allowed_tools=[])
assert (await anext(events)).kind == "text"
assert tracing.current_span() is None
@@ -209,50 +179,50 @@ async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]:
@pytest.mark.asyncio
-async def test_close_before_first_iteration_releases_tape(spans: Any, agent: Agent) -> None:
+async def test_close_before_first_iteration_releases_tape(spans: Any, agent: BubAgent) -> None:
events = await agent.run_stream(session_id="unused", prompt="hello", state={}, allowed_tools=[])
await events.aclose()
assert len(spans.get_finished_spans()) == 1
# Fork contents were merged by the close callback even though the generator never started.
- entries = await agent.tape.store.fetch_all(agent.tape.session_tape("unused", workspace_from_state({})).query())
+ entries = await agent.tape(session_tape_name("unused", workspace_from_state({}))).store.fetch_all(
+ agent.tape(session_tape_name("unused", workspace_from_state({}))).query()
+ )
assert any(e.payload.get("name") == "loop.start" for e in entries)
@pytest.mark.asyncio
async def test_setup_failure_releases_tape_and_finishes_agent_span(
- spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch
+ spans: Any, agent: BubAgent, monkeypatch: pytest.MonkeyPatch
) -> None:
- async def fail_setup(*, tape: Tape, **kwargs: Any) -> AsyncStreamEvents:
+ async def fail_setup(tape: Tape, prompt: Any, **kwargs: Any) -> AsyncStreamEvents:
await tape.append_event("setup.started", {})
raise ValueError("setup failed")
- monkeypatch.setattr(agent, "_agent_loop", fail_setup)
+ monkeypatch.setattr(agent, "run_on", fail_setup)
with pytest.raises(ValueError, match="setup failed"):
await agent.run_stream(session_id="setup-failure", prompt="hello", state={})
(span,) = spans.get_finished_spans()
assert span.status.status_code.name == "ERROR"
assert span.attributes["error.type"] == "ValueError"
- entries = await agent.tape.store.fetch_all(
- agent.tape.session_tape("setup-failure", workspace_from_state({})).query()
+ entries = await agent.tape(session_tape_name("setup-failure", workspace_from_state({}))).store.fetch_all(
+ agent.tape(session_tape_name("setup-failure", workspace_from_state({}))).query()
)
assert any(e.payload.get("name") == "setup.started" for e in entries)
assert tracing.current_span() is None
@pytest.mark.asyncio
-async def test_state_loading_failure_finishes_agent_span(
- spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch
+async def test_state_loading_failure_happens_before_the_agent_span(
+ spans: Any, agent: BubAgent, monkeypatch: pytest.MonkeyPatch
) -> None:
async def fail_state(*args: Any, **kwargs: Any) -> None:
- assert tracing.current_span() is not None
+ assert tracing.current_span() is None
raise ValueError("state loading failed")
monkeypatch.setattr(agent.framework, "build_state", fail_state)
with pytest.raises(ValueError, match="state loading failed"):
await agent.run_stream(session_id="state-failure", prompt="hello")
- (span,) = spans.get_finished_spans()
- assert span.status.status_code.name == "ERROR"
- assert span.attributes["error.type"] == "ValueError"
+ assert spans.get_finished_spans() == ()
assert tracing.current_span() is None
assert not tracing.otel.get_current_span().get_span_context().is_valid
@@ -283,11 +253,11 @@ async def cleanup() -> None:
@pytest.mark.asyncio
-async def test_llm_short_circuit_has_no_phantom_model_span(spans: Any, agent: Agent) -> None:
+async def test_llm_short_circuit_has_no_phantom_model_span(spans: Any, agent: BubAgent) -> None:
class Finish:
@hookimpl
- def before_llm_call(self) -> LlmCallDecision:
- return LlmCallDecision.finish("stopped by policy")
+ def before_llm_call(self) -> LLMCallDecision:
+ return LLMCallDecision.finish("stopped by policy")
agent.framework.plugin_manager.register(Finish())
events = await agent.run_stream(session_id="policy", prompt="hello", state={}, allowed_tools=[])
@@ -296,7 +266,7 @@ def before_llm_call(self) -> LlmCallDecision:
@pytest.mark.asyncio
-async def test_failed_tool_records_effective_result_and_original_failure(spans: Any, agent: Agent) -> None:
+async def test_failed_tool_records_effective_result_and_original_failure(spans: Any, agent: BubAgent) -> None:
class Policy:
@hookimpl(tryfirst=True)
def before_tool_call(self) -> ToolCallDecision:
@@ -307,9 +277,9 @@ def after_tool_call(self, result: ToolCallResult) -> None:
result.result = "bounded failure"
agent.framework.plugin_manager.register(Policy())
- execution = await ToolExecutor(agent.framework.get_agent_hooks()).execute_async(
+ execution = await ToolExecutor(agent.framework.get_agent_hooks().subscribe()).execute_many(
[(Tool(name="denied", handler=lambda: pytest.fail("must not run")), {})],
- context=ToolContext(agent.tape, "run-1"),
+ context=ToolContext(agent.tape("tools"), "run-1"),
call_ids=["denied-1"],
)
assert execution.error is not None
@@ -321,24 +291,19 @@ def after_tool_call(self, result: ToolCallResult) -> None:
@pytest.mark.asyncio
async def test_provider_fallback_records_actual_model(
- spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch
+ spans: Any, agent: BubAgent, provider_service: ProviderService
) -> None:
- class Client:
- SUPPORTS_COMPLETION_STREAMING = False
-
- async def acompletion(self, **kwargs: Any) -> ChatCompletion:
- if kwargs["model"] == "unavailable":
- raise RuntimeError("try next")
- return completion()
-
- candidates = [
- ModelCandidate(name=f"openai:{name}", provider=LLMProvider.OPENAI, model_id=name)
- for name in ["unavailable", "fallback"]
- ]
- monkeypatch.setattr(agent.model_runner, "iter_llm_clients", lambda model: iter((c, Client()) for c in candidates))
- events = await agent.run_stream(session_id="fallback", prompt="hello", state={}, allowed_tools=[])
- async for _ in events:
- pass
+ import httpx2
+
+ provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}}))
+ provider_service.reply(sse(chat_events()))
+ async with provider_service.client() as client:
+ agent.settings.fallback_models = ["openai:fallback"]
+ agent.settings.model = "openai:unavailable"
+ agent.settings.client_args = {"http_client": client, "api_format": "chat", "max_retries": 0}
+ events = await agent.run_stream(session_id="fallback", prompt="hello", state={}, allowed_tools=[])
+ async for _ in events:
+ pass
model = next(s for s in spans.get_finished_spans() if s.name.startswith("chat "))
assert model.attributes["gen_ai.request.model"] == "fallback"
assert model.attributes["gen_ai.provider.name"] == "openai"
@@ -356,14 +321,14 @@ def find_spec(self, fullname, path=None, target=None):
raise ModuleNotFoundError(fullname)
sys.meta_path.insert(0, BlockTelemetry())
from bub import tracing
-from bub.builtin.agent import Agent
-from bub.tools import Tool, ToolExecutor
+from bub.builtin.agent import BubAgent
+from bub.core.tools import Tool, ToolExecutor
assert tracing.otel is None
async def main():
span = tracing.Span('noop')
assert not span.recording
with span.activate():
- result = await ToolExecutor().execute_async([(Tool(name='echo', handler=lambda: 'ok'), {})])
+ result = await ToolExecutor().execute_many([(Tool(name='echo', handler=lambda: 'ok'), {})])
assert result.tool_results == ['ok']
span.end()
assert tracing.correlation() == {}
@@ -374,28 +339,10 @@ async def main():
@pytest.mark.asyncio
-async def test_streaming_usage_arrives_on_last_chunk(spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch) -> None:
- async def chunks() -> AsyncIterator[ChatCompletionChunk]:
- yield ChatCompletionChunk.model_validate({
- "id": "stream",
- "object": "chat.completion.chunk",
- "created": 0,
- "model": "stream-model",
- "choices": [{"index": 0, "delta": {"content": "done"}, "finish_reason": "stop"}],
- })
- yield ChatCompletionChunk.model_validate({
- "id": "stream",
- "object": "chat.completion.chunk",
- "created": 0,
- "model": "stream-model",
- "choices": [],
- "usage": {"prompt_tokens": 19, "completion_tokens": 3, "total_tokens": 22},
- })
-
- async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]:
- return chunks()
-
- monkeypatch.setattr(agent.model_runner, "completion_response", respond)
+async def test_streaming_usage_arrives_on_last_chunk(
+ spans: Any, agent: BubAgent, provider_transport: ProviderService
+) -> None:
+ provider_transport.reply_chat(model="stream-model", input_tokens=19, output_tokens=3)
events = await agent.run_stream(session_id="stream", prompt="hello", state={}, allowed_tools=[])
assert (await anext(events)).data == {"delta": "done"}
assert not spans.get_finished_spans()
@@ -410,15 +357,17 @@ async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]:
@pytest.mark.asyncio
async def test_timeout_marks_model_and_agent_as_failed(
- spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch
+ spans: Any, agent: BubAgent, provider_transport: ProviderService
) -> None:
- agent.settings.model_timeout_seconds = 0
+ import httpx2
- async def respond(**kwargs: Any) -> ChatCompletion:
- await asyncio.Event().wait()
- return completion()
+ class Body(httpx2.AsyncByteStream):
+ async def __aiter__(self):
+ await asyncio.Event().wait()
+ yield b""
- monkeypatch.setattr(agent.model_runner, "completion_response", respond)
+ agent.settings.model_timeout_seconds = 0.01
+ provider_transport.reply(httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=Body()))
events = await agent.run_stream(session_id="timeout", prompt="hello", state={}, allowed_tools=[])
with pytest.raises(TimeoutError):
async for _ in events:
@@ -431,13 +380,10 @@ async def respond(**kwargs: Any) -> ChatCompletion:
@pytest.mark.asyncio
async def test_concurrent_sessions_have_independent_traces(
- spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch
+ spans: Any, agent: BubAgent, provider_transport: ProviderService
) -> None:
- async def respond(**kwargs: Any) -> ChatCompletion:
- await asyncio.sleep(0)
- return completion()
-
- monkeypatch.setattr(agent.model_runner, "completion_response", respond)
+ provider_transport.reply_chat()
+ provider_transport.reply_chat()
async def run(session: str) -> None:
events = await agent.run_stream(session_id=session, prompt="hello", state={}, allowed_tools=[])
diff --git a/tests/test_types.py b/tests/test_types.py
deleted file mode 100644
index 84554a74..00000000
--- a/tests/test_types.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import importlib
-import sys
-
-import pytest
-
-from bub.channels.contracts import MessageHandler
-from bub.envelope import Envelope
-from bub.turn import TurnState
-
-
-def test_deprecated_types_reexports_compatibility_aliases() -> None:
- sys.modules.pop("bub.types", None)
-
- with pytest.warns(DeprecationWarning, match=r"`bub\.types` is deprecated"):
- legacy_types = importlib.import_module("bub.types")
-
- assert legacy_types.__all__ == ["Envelope", "MessageHandler", "State"]
- assert legacy_types.Envelope is Envelope
- assert legacy_types.MessageHandler is MessageHandler
- assert legacy_types.State is TurnState
diff --git a/uv.lock b/uv.lock
index 57cb7973..fa9d5ef3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -146,43 +146,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
-[[package]]
-name = "anthropic"
-version = "0.94.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "distro" },
- { name = "docstring-parser" },
- { name = "httpx" },
- { name = "jiter" },
- { name = "pydantic" },
- { name = "sniffio" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/60/d7/11a649b986da06aaeb81334632f1843d70e3797f54ca4a9c5d604b7987d0/anthropic-0.94.0.tar.gz", hash = "sha256:dde8c57de73538c5136c1bca9b16da92e75446b53a73562cc911574e4934435c", size = 654236, upload-time = "2026-04-10T22:27:59.853Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/ac/7185750e8688f6ff79b0e3d6a61372c88b81ba81fcda8798c70598e18aca/anthropic-0.94.0-py3-none-any.whl", hash = "sha256:42550b401eed8fcd7f6654234560f99c428306301bca726d32bca4bfb6feb748", size = 627519, upload-time = "2026-04-10T22:27:57.541Z" },
-]
-
-[[package]]
-name = "any-llm-sdk"
-version = "1.22.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anthropic" },
- { name = "httpx" },
- { name = "openai" },
- { name = "openresponses-types" },
- { name = "pydantic" },
- { name = "rich" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/bf/de/a29738e1c8702a79a514f9521430fe77686fb9f9122980f4899a7baf58b5/any_llm_sdk-1.22.1.tar.gz", hash = "sha256:b07c6fcef6d7fc13e0f0419bc1464f9316b17e663c34382fb7722a906241f635", size = 164931, upload-time = "2026-07-22T15:43:29.12Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/03/5a/85c558cc9fb6fd8a7fd3e7523c9c20545316190d22372026ddbf53cdc0c9/any_llm_sdk-1.22.1-py3-none-any.whl", hash = "sha256:950339b68ca1d99fb123c523d246140e1c947b0ff26e3fb9bc37194a49f3b509", size = 217209, upload-time = "2026-07-22T15:43:27.664Z" },
-]
-
[[package]]
name = "anyio"
version = "4.14.2"
@@ -207,45 +170,57 @@ wheels = [
[[package]]
name = "authlib"
-version = "1.7.2"
+version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "joserfc" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" },
]
[[package]]
name = "bub"
source = { editable = "." }
dependencies = [
+ { name = "loguru" },
+ { name = "pydantic" },
+ { name = "republic" },
+ { name = "typing-extensions" },
+]
+
+[package.optional-dependencies]
+app = [
{ name = "aiohttp" },
- { name = "any-llm-sdk" },
- { name = "authlib" },
{ name = "httpx", extra = ["socks"] },
{ name = "inquirer-textual" },
- { name = "loguru" },
{ name = "pluggy" },
{ name = "prompt-toolkit" },
- { name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-telegram-bot" },
{ name = "pyyaml" },
{ name = "rapidfuzz" },
{ name = "rich" },
{ name = "typer" },
- { name = "typing-extensions" },
]
-
-[package.optional-dependencies]
logfire = [
+ { name = "aiohttp" },
+ { name = "httpx", extra = ["socks"] },
+ { name = "inquirer-textual" },
{ name = "logfire" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" },
+ { name = "pluggy" },
+ { name = "prompt-toolkit" },
+ { name = "pydantic-settings" },
+ { name = "python-telegram-bot" },
+ { name = "pyyaml" },
+ { name = "rapidfuzz" },
+ { name = "rich" },
+ { name = "typer" },
]
trace = [
{ name = "opentelemetry-api" },
@@ -255,6 +230,7 @@ trace = [
[package.dev-dependencies]
dev = [
+ { name = "bub", extra = ["app"] },
{ name = "mypy" },
{ name = "prek" },
{ name = "pytest" },
@@ -266,11 +242,12 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "aiohttp", specifier = ">=3.14.3" },
- { name = "any-llm-sdk", extras = ["anthropic"], specifier = ">=1.22.1" },
- { name = "authlib", specifier = ">=1.7.2" },
- { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
- { name = "inquirer-textual", specifier = ">=0.8.0" },
+ { name = "aiohttp", marker = "extra == 'app'", specifier = ">=3.14.3" },
+ { name = "aiohttp", marker = "extra == 'logfire'", specifier = ">=3.14.3" },
+ { name = "httpx", extras = ["socks"], marker = "extra == 'app'", specifier = ">=0.28.1" },
+ { name = "httpx", extras = ["socks"], marker = "extra == 'logfire'", specifier = ">=0.28.1" },
+ { name = "inquirer-textual", marker = "extra == 'app'", specifier = ">=0.8.0" },
+ { name = "inquirer-textual", marker = "extra == 'logfire'", 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" },
@@ -279,21 +256,31 @@ requires-dist = [
{ 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 = "pluggy", marker = "extra == 'app'", specifier = ">=1.6.0" },
+ { name = "pluggy", marker = "extra == 'logfire'", specifier = ">=1.6.0" },
+ { name = "prompt-toolkit", marker = "extra == 'app'", specifier = ">=3.0.52" },
+ { name = "prompt-toolkit", marker = "extra == 'logfire'", specifier = ">=3.0.52" },
{ name = "pydantic", specifier = ">=2.12.5" },
- { name = "pydantic-settings", specifier = ">=2.14.2" },
- { name = "python-telegram-bot", specifier = ">=22.7" },
- { name = "pyyaml", specifier = ">=6.0.3" },
- { name = "rapidfuzz", specifier = ">=3.14.5" },
- { name = "rich", specifier = ">=14.3.4" },
- { name = "typer", specifier = ">=0.24.1" },
+ { name = "pydantic-settings", marker = "extra == 'app'", specifier = ">=2.14.2" },
+ { name = "pydantic-settings", marker = "extra == 'logfire'", specifier = ">=2.14.2" },
+ { name = "python-telegram-bot", marker = "extra == 'app'", specifier = ">=22.7" },
+ { name = "python-telegram-bot", marker = "extra == 'logfire'", specifier = ">=22.7" },
+ { name = "pyyaml", marker = "extra == 'app'", specifier = ">=6.0.3" },
+ { name = "pyyaml", marker = "extra == 'logfire'", specifier = ">=6.0.3" },
+ { name = "rapidfuzz", marker = "extra == 'app'", specifier = ">=3.14.5" },
+ { name = "rapidfuzz", marker = "extra == 'logfire'", specifier = ">=3.14.5" },
+ { name = "republic", specifier = ">=0.6.0a1" },
+ { name = "rich", marker = "extra == 'app'", specifier = ">=14.3.4" },
+ { name = "rich", marker = "extra == 'logfire'", specifier = ">=14.3.4" },
+ { name = "typer", marker = "extra == 'app'", specifier = ">=0.24.1" },
+ { name = "typer", marker = "extra == 'logfire'", specifier = ">=0.24.1" },
{ name = "typing-extensions", specifier = ">=4.15.0" },
]
-provides-extras = ["logfire", "trace"]
+provides-extras = ["app", "logfire", "trace"]
[package.metadata.requires-dev]
dev = [
+ { name = "bub", extras = ["app"] },
{ name = "mypy", specifier = ">=1.20.0" },
{ name = "prek", specifier = ">=0.3.8" },
{ name = "pytest", specifier = ">=9.0.3" },
@@ -531,24 +518,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
-[[package]]
-name = "distro"
-version = "1.9.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
-]
-
-[[package]]
-name = "docstring-parser"
-version = "0.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
-]
-
[[package]]
name = "executing"
version = "2.2.1"
@@ -560,11 +529,11 @@ wheels = [
[[package]]
name = "filelock"
-version = "3.25.2"
+version = "4.0.12"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
]
[[package]]
@@ -690,6 +659,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
+[[package]]
+name = "httpcore2"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+ { name = "truststore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
+]
+
[[package]]
name = "httpx"
version = "0.28.1"
@@ -710,13 +692,39 @@ socks = [
{ name = "socksio" },
]
+[[package]]
+name = "httpx2"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "sys_platform != 'emscripten'" },
+ { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
+ { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
+ { name = "idna" },
+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
+]
+
+[[package]]
+name = "httpx2-jsfetch"
+version = "1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
+]
+
[[package]]
name = "idna"
-version = "3.15"
+version = "3.20"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
+ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
@@ -752,78 +760,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/05/f990751e8577b5b3c902acea8a3b03fe8909eada8a5372ada050a169ad7d/inquirer_textual-0.8.0-py3-none-any.whl", hash = "sha256:b2d2afa7fa619c24baad23b3a1bc4dc55cb6b10ad3d4bad09bc747c93ae569fb", size = 36436, upload-time = "2026-09-25T12:49:17.425Z" },
]
-[[package]]
-name = "jiter"
-version = "0.14.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" },
- { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" },
- { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" },
- { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" },
- { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" },
- { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" },
- { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" },
- { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" },
- { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" },
- { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" },
- { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" },
- { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" },
- { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" },
- { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" },
- { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" },
- { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" },
- { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" },
- { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" },
- { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" },
- { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" },
- { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" },
- { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" },
- { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" },
- { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" },
- { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" },
- { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" },
- { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" },
- { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" },
- { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" },
- { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" },
- { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" },
- { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" },
- { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" },
- { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" },
- { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" },
- { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" },
- { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" },
- { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" },
- { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" },
- { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" },
- { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" },
- { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" },
- { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" },
- { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" },
- { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" },
- { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" },
- { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" },
- { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" },
- { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" },
- { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" },
- { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" },
- { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" },
- { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" },
- { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" },
- { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" },
- { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" },
- { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" },
- { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" },
- { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" },
- { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" },
- { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" },
- { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" },
- { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" },
- { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" },
-]
-
[[package]]
name = "joserfc"
version = "1.7.2"
@@ -1128,37 +1064,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
-[[package]]
-name = "openai"
-version = "2.31.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "distro" },
- { name = "httpx" },
- { name = "jiter" },
- { name = "pydantic" },
- { name = "sniffio" },
- { name = "tqdm" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/94/fe/64b3d035780b3188f86c4f6f1bc202e7bb74757ef028802112273b9dcacf/openai-2.31.0.tar.gz", hash = "sha256:43ca59a88fc973ad1848d86b98d7fac207e265ebbd1828b5e4bdfc85f79427a5", size = 684772, upload-time = "2026-04-08T21:01:41.797Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/66/bc/a8f7c3aa03452fedbb9af8be83e959adba96a6b4a35e416faffcc959c568/openai-2.31.0-py3-none-any.whl", hash = "sha256:44e1344d87e56a493d649b17e2fac519d1368cbb0745f59f1957c4c26de50a0a", size = 1153479, upload-time = "2026-04-08T21:01:39.217Z" },
-]
-
-[[package]]
-name = "openresponses-types"
-version = "2.3.0.post1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pydantic" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d9/26/b612c3215f5599714fa94d63eb5ee59b4eb66dbdeeaf86bb4d848359484d/openresponses_types-2.3.0.post1.tar.gz", hash = "sha256:11b8896d3621d2ac2439f6ff106f34ddcb1bbd517c317a6c852a9df2e98a0753", size = 19254, upload-time = "2026-01-22T20:02:03.933Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b2/5f/e16dad89ed24f586da5b01b9b206d3adbf21fe1af8e4dc55d5b93158fde6/openresponses_types-2.3.0.post1-py3-none-any.whl", hash = "sha256:88f6abcef9cad839203abff420dd080978bf6eb33cc06ddc5d78da4ccdba7613", size = 13847, upload-time = "2026-01-22T20:02:02.582Z" },
-]
-
[[package]]
name = "opentelemetry-api"
version = "1.39.1"
@@ -1438,7 +1343,7 @@ wheels = [
[[package]]
name = "pydantic"
-version = "2.12.5"
+version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -1446,80 +1351,115 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" },
]
[[package]]
name = "pydantic-core"
-version = "2.41.5"
+version = "2.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
- { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
- { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
- { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
- { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
- { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
- { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
- { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
- { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
- { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
- { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
- { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
- { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
- { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
- { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
- { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
- { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
- { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
- { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
- { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
- { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
- { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
- { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
- { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
- { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
- { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
- { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
- { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
- { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
- { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
- { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
- { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
- { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
- { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
- { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
- { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
- { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
- { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
- { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
- { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
- { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
- { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
- { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
- { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
- { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
- { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
- { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
- { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
- { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
- { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
- { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
- { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
- { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
- { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
- { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
- { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
- { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
- { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" },
+ { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" },
+ { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" },
+ { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" },
+ { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" },
+ { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" },
+ { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" },
+ { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" },
+ { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" },
+ { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" },
+ { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" },
+ { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" },
+ { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" },
+ { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" },
+ { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" },
+ { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" },
+ { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" },
+ { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" },
+ { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" },
+ { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" },
+ { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" },
+ { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" },
+ { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" },
+ { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" },
+ { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" },
+ { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" },
+ { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" },
+ { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" },
+ { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" },
+ { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" },
+ { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" },
+ { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" },
+ { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" },
+ { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" },
+ { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" },
+ { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" },
+ { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" },
+ { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" },
]
[[package]]
@@ -1588,15 +1528,14 @@ wheels = [
[[package]]
name = "python-discovery"
-version = "1.2.2"
+version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
- { name = "platformdirs" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
+ { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" },
]
[[package]]
@@ -1730,6 +1669,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" },
]
+[[package]]
+name = "republic"
+version = "0.6.0a1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "authlib" },
+ { name = "filelock" },
+ { name = "httpx2" },
+ { name = "pydantic" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/a2/4c36a93722932d29095c6aca053829d77a5ae1d9a5b2e989e61544d528af/republic-0.6.0a1.tar.gz", hash = "sha256:3eb72270bb8f0e52c3fbe66aed37e88edf7b8f31bb3926db508aeb8f71306a93", size = 227363, upload-time = "2026-10-10T03:59:28.606Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/1e/c70546f7ab7947ccb44f305e297fb42924db6fc82ec23ecd9d58ecebd692/republic-0.6.0a1-py3-none-any.whl", hash = "sha256:7970fd013890e7c8b121bdc91f3d7cf93b2fa30605cf4540afb5b790a4a86516", size = 74563, upload-time = "2026-10-10T03:59:29.577Z" },
+]
+
[[package]]
name = "requests"
version = "2.33.1"
@@ -1792,15 +1746,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
-]
-
[[package]]
name = "socksio"
version = "1.0.0"
@@ -1883,15 +1828,12 @@ wheels = [
]
[[package]]
-name = "tqdm"
-version = "4.67.3"
+name = "truststore"
+version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
@@ -1920,23 +1862,23 @@ wheels = [
[[package]]
name = "typing-extensions"
-version = "4.15.0"
+version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
-version = "0.4.2"
+version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
@@ -1985,17 +1927,18 @@ wheels = [
[[package]]
name = "virtualenv"
-version = "21.2.1"
+version = "21.14.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
+ { name = "packaging" },
{ name = "platformdirs" },
{ name = "python-discovery" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/97/c5/aff062c66b42e2183201a7ace10c6b2e959a9a16525c8e8ca8e59410d27a/virtualenv-21.2.1.tar.gz", hash = "sha256:b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b537fa78", size = 5844770, upload-time = "2026-04-09T18:47:11.482Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/0e/f083a76cb590e60dff3868779558eefefb8dfb7c9ed020babc7aa014ccbf/virtualenv-21.2.1-py3-none-any.whl", hash = "sha256:bd16b49c53562b28cf1a3ad2f36edb805ad71301dee70ddc449e5c88a9f919a2", size = 5828326, upload-time = "2026-04-09T18:47:09.331Z" },
+ { url = "https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da", size = 5487482, upload-time = "2026-10-02T17:42:23.38Z" },
]
[[package]]
diff --git a/website/public/install.ps1 b/website/public/install.ps1
index bfbfed39..4923d935 100644
--- a/website/public/install.ps1
+++ b/website/public/install.ps1
@@ -3,7 +3,7 @@ Set-StrictMode -Version Latest
$UvInstallUrl = "https://astral.sh/uv/install.ps1"
$DefaultPresetsUrl = "https://bub.build/presets.json"
-$BubPackage = "bub"
+$BubPackage = "bub[app]"
$BubPython = "3.12"
$InquirerPackage = "inquirer-textual==0.8.0"
$script:UvPath = $null
diff --git a/website/public/install.sh b/website/public/install.sh
index 3357f4dd..4b9479b3 100755
--- a/website/public/install.sh
+++ b/website/public/install.sh
@@ -4,7 +4,7 @@ set -euo pipefail
readonly UV_INSTALL_URL="https://astral.sh/uv/install.sh"
readonly DEFAULT_PRESETS_URL="https://bub.build/presets.json"
-readonly BUB_PACKAGE="bub"
+readonly BUB_PACKAGE="bub[app]"
readonly BUB_PYTHON="3.12"
readonly INQUIRER_PACKAGE="inquirer-textual==0.8.0"
diff --git a/website/src/content/docs/docs/build/distribution.mdx b/website/src/content/docs/docs/build/distribution.mdx
index 9f33d11d..1f47f5ed 100644
--- a/website/src/content/docs/docs/build/distribution.mdx
+++ b/website/src/content/docs/docs/build/distribution.mdx
@@ -55,15 +55,15 @@ def main() -> None:
workspace.mkdir(parents=True, exist_ok=True)
os.chdir(workspace)
- from bub.__main__ import app
+ from bub.__main__ import main as bub_main
- app()
+ bub_main()
```
Two points worth calling out:
- The product resolves a sticky workspace before importing Bub, then `chdir`s into it. [`BubFramework`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py) snapshots `Path.cwd()` as its workspace at construction time, so the `chdir` must happen first.
-- After that, the product hands off to `bub.__main__.app` — the same Typer app the bare `bub` command uses. Every plugin contributing `register_cli_commands` lights up automatically.
+- After that, the product hands off to `bub.__main__.main` — the same entry point as the bare `bub` command, which builds the Typer app with `create_cli_app()` and reports a missing `bub[app]` extra. Every plugin contributing `register_cli_commands` lights up automatically.
Wire the CLI to a script entry point:
diff --git a/website/src/content/docs/docs/build/hooks.mdx b/website/src/content/docs/docs/build/hooks.mdx
index d986a300..ec37d386 100644
--- a/website/src/content/docs/docs/build/hooks.mdx
+++ b/website/src/content/docs/docs/build/hooks.mdx
@@ -249,12 +249,12 @@ Route models, cap spend, and redact — `before_llm_call`:
from dataclasses import replace
from bub import hookimpl
-from bub.hooks.interception import LlmCallDecision, LlmCallRequest
+from bub.hooks.interception import LLMCallDecision, LLMCallRequest
class RouteAndBudget:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state):
+ def before_llm_call(self, request: LLMCallRequest, state):
# `state` persists for the session (it is not reset per turn), so
# this is a session-wide budget. For a per-turn budget, reset the
# counter yourself at a turn boundary (e.g. in build_prompt).
@@ -262,7 +262,7 @@ class RouteAndBudget:
state["session_llm_calls"] = calls + 1
if calls >= 10:
# Skip the provider entirely; this text becomes the final output.
- return LlmCallDecision.finish("LLM call budget exhausted for this session.")
+ return LLMCallDecision.finish("LLM call budget exhausted for this session.")
# Rewrites are honored end-to-end: the provider receives this model
# and max_tokens, and the tape records the effective model.
return replace(request, model="openai:gpt-4o-mini", max_tokens=512)
diff --git a/website/src/content/docs/docs/build/plugins.mdx b/website/src/content/docs/docs/build/plugins.mdx
index 834b1f98..69d290f7 100644
--- a/website/src/content/docs/docs/build/plugins.mdx
+++ b/website/src/content/docs/docs/build/plugins.mdx
@@ -41,7 +41,7 @@ Bub discovers plugins through Python entry points in the `bub` group. Add the en
[project]
name = "bub-myplugin"
version = "0.1.0"
-dependencies = ["bub>=0.3"]
+dependencies = ["bub[app]>=0.3"]
[project.entry-points."bub"]
myplugin = "bub_myplugin.plugin"
@@ -127,6 +127,8 @@ class MyPlugin:
Visual-base's [`EyeImpl`](https://github.com/oilbeater/visual-base/blob/main/src/bub_eye/plugin.py) uses the callable form for exactly this reason: it captures the framework so `provide_channels` can read the post-CLI workspace.
+`framework.events` publishes command, channel work, and prompt changes through `CommandChanged`, `WorkChanged`, and `PromptChanged` from `bub.application`. Subscribe with `framework.events.on(EventType, handler)` and unsubscribe with `framework.events.off(EventType, handler)`. Agent-loop events remain on `agent.events`.
+
:::note
Builtin is registered first; entry-point plugins are registered after. Inside the [`HookRuntime`](https://github.com/bubbuild/bub/blob/main/src/bub/hooks/runtime.py), later registrations have higher precedence — so your plugin's `firstresult` hooks override the builtin.
:::
diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md
index 8b27e0db..f8b7c614 100644
--- a/website/src/content/docs/docs/build/sdk.md
+++ b/website/src/content/docs/docs/build/sdk.md
@@ -1,14 +1,18 @@
---
title: Python SDK
-description: Embed Bub in a Python application with custom tools, prompts, skills, and session storage.
+description: Embed Bub's agent loop in a Python application with your own model, tools, prompts, and session storage.
sidebar:
order: 0
---
-Use `bub.builtin.Agent` to run the builtin agent loop inside your application.
-`BubFramework` supplies configuration and hooks; `Agent` owns execution with your tools and store.
+`bub.core` is Bub's agent kernel. 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.
-## Install and configure
+Bub's own runtime is built on the same kernel: `bub.builtin.BubAgent` adds settings, plugin hooks, skills,
+commands, and steering.
+
+## Install
Install Bub in your Python 3.12+ project:
@@ -16,8 +20,8 @@ Install Bub in your Python 3.12+ project:
uv add bub
```
-Configure a model and its credentials through `BUB_MODEL`, `BUB_API_KEY`, and optionally `BUB_API_BASE`,
-or through a configuration file. See [Configuration](/docs/reference/settings/).
+This installs only the kernel. Add the `app` extra (`bub[app]`) when you also need the `bub` command, channels, or plugins.
+
The API below describes this source checkout; use a release containing these interfaces or install the checkout.
## Create an agent
@@ -28,21 +32,11 @@ Save the following as `sdk_example.py`:
import asyncio
from pathlib import Path
-from bub import BubFramework, hookimpl
-from bub.builtin import Agent
-from bub.builtin.hook_impl import BuiltinImpl
-from bub.builtin.tools import skill_describe
-from bub.store import FileTapeStore
-from bub.tools import Tool
-
-
-class SystemPrompts:
- def __init__(self, prompts: list[str]) -> None:
- self.prompts = tuple(prompts)
+import republic
- @hookimpl
- def system_prompt(self, prompt, state) -> str:
- return "\n\n".join(self.prompts)
+from bub.core import Agent
+from bub.core.store import FileTapeStore
+from bub.core.tools import Tool
async def lookup_order(order_id: str) -> dict[str, str]:
@@ -51,38 +45,30 @@ async def lookup_order(order_id: str) -> dict[str, str]:
return {"order_id": order_id, "status": "shipped"}
-def create_agent() -> tuple[BubFramework, Agent]:
+def create_agent() -> Agent:
root = Path(__file__).resolve().parent
- framework = BubFramework(config_file=root / "config.yml")
- framework.workspace = root
- framework.plugin_manager.register(
- SystemPrompts([
+ return Agent(
+ store=FileTapeStore(root / "sessions"),
+ tools=[Tool.from_callable(lookup_order)],
+ system_prompt=[
"You are an order assistant. Reply directly to the user.",
"Use lookup_order to check order status before answering.",
- ]),
- name="system_prompts",
+ ],
)
- agent = Agent(
- framework,
- tools=[Tool.from_callable(lookup_order), skill_describe],
- skill_dirs=[root / "skills"],
- tape_store=FileTapeStore(root / "sessions"),
- )
- return framework, agent
async def main() -> None:
- framework, agent = create_agent()
- async with framework.running():
- stream = await agent.run_stream(
- session_id="customer-42",
- prompt="Where is order A123?",
- )
- async for event in stream:
- if event.kind == "text":
- print(event.data.get("delta", ""), end="", flush=True)
- elif event.kind == "error":
- raise RuntimeError(str(event.data.get("message", "Agent failed")))
+ agent = create_agent()
+ # Any Republic "provider:model" spec. The key comes from REPUBLIC_OPENAI_API_KEY.
+ async with republic.get_model("openai:gpt-5-mini") as model:
+ async with agent.run(
+ "Where is order A123?", model=model, options={"max_tokens": 4096}, tape="customer-42"
+ ) as stream:
+ async for event in stream:
+ if event.kind == "text":
+ print(event.data.get("delta", ""), end="", flush=True)
+ elif event.kind == "error":
+ raise RuntimeError(str(event.data.get("message", "Agent failed")))
print()
@@ -90,165 +76,296 @@ if __name__ == "__main__":
asyncio.run(main())
```
-Run it with `uv run python sdk_example.py` after configuring your model.
-An absent `config.yml` is allowed; environment settings still apply.
+Run it with `REPUBLIC_OPENAI_API_KEY=... uv run python sdk_example.py`. Running it again with the same `tape`
+continues the conversation stored under `sessions/`.
+
+| `Agent(...)` parameter | Behavior |
+| ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `model` | Default model of runs not given one; see [Models](#models). `None` (the default) requires each run to give one. |
+| `store` | A `TapeStore` or `AsyncTapeStore`. Defaults to `InMemoryTapeStore()`. |
+| `tools` | `Tool` objects the model may call, kept by name in `agent.tools`, and Republic built-in tools such as `republic.tools.WebSearch()` that the provider runs itself, kept in `agent.builtin_tools` and sent with every model call. |
+| `system_prompt` | One system prompt section or a list of them; see [System prompt](#system-prompt). |
+| `context` | A `TapeContext` choosing which tape entries become model context. Defaults to `default_tape_context()`. |
+| `sidecars` | `TapeSidecar` capabilities mounted on every tape. |
+| `max_steps` | Maximum model calls in one run. |
+| `max_tool_concurrency` | Maximum tool calls from one model response that run at once. `None` (the default) runs them all concurrently; `1` runs them one at a time, in order. |
+| `name` | Agent name reported in `invoke_agent` traces. Defaults to `"agent"`. |
+| `command_prefix` | Prefix marking command prompts, such as `","`; see [Commands](#commands). `None` (the default) disables commands. |
+
+## Models
+
+A run calls the model it is given, or else the agent's `model`, so one agent can serve runs with different models;
+a run that calls a model without either raises `ValueError`. Pass a Republic chat model from `republic.get_model()`;
+its lifecycle belongs to you, so open it with `async with` (or close it) around the runs that use it. Credentials and
+endpoints are Republic client options:
+
+```python
+model = republic.get_model("openai:gpt-5-mini", api_key="...", api_base="https://example.test/v1")
+async with agent.run(prompt, model=model, options={"max_tokens": 4096, "temperature": 0.2}, tape="t") as stream:
+ ...
+
+agent = Agent(model=model) # runs not given a model call this one
+async with agent.run(prompt, tape="t") as stream:
+ ...
+```
+
+`options` are `republic.ChatOptions` sent with every model call of the run; the loop adds the step's tools.
+A `LLMCallStarted` handler may redirect a call to another `provider:model` spec: a model of the same provider reuses
+the run model's client, and another provider gets a client configured from Republic's environment.
-`SystemPrompts` replaces the builtin system-prompt method while keeping the other builtin hooks.
-Register this instance once: do not also call `load_builtin_hooks()` or `load_hooks()` in this example.
-System-prompt hooks are additive, so registering an extra prompt hook alongside the standard builtin implementation
-would retain its channel instructions and workspace `AGENTS.md` content.
+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
+`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.
-For standard Bub defaults instead, create a framework and call `framework.load_builtin_hooks()`.
-Use `load_hooks()` when you also want installed plugins from the `bub` entry-point group.
+## Tools
-## Tools, skills, and sessions
+`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.tools` builds the same `Tool` and also adds it to Bub's global
+registry, which Bub's own agent snapshots.
-| Parameter | Behavior |
-| ------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
-| `tools=[...]` | Accepts `Tool` objects. `Tool.from_callable()` derives a schema from annotations and a description from the docstring. |
-| `tools=None` | Copies the global tool registry at construction time. `tools=[]` disables tools. |
-| `skill_dirs=[Path(...)]` | Searches only these roots, in order; the first skill of a given name wins. |
-| `skill_dirs=None` | Searches project, user, and builtin roots. `skill_dirs=[]` disables discovery. |
-| `tape_store=...` | Uses the supplied `TapeStore` or `AsyncTapeStore`. `FileTapeStore(path)` persists session tapes in that directory. |
-| `tape_store=None` | Uses the framework's active store, or an instance-local memory store if no store is active. |
-| `command_prefix="::"` | Overrides the command prefix for this Agent. `None` uses `BUB_COMMAND_PREFIX` / YAML `command_prefix`, defaulting to `,`. |
+A tool created with `context=True` receives a `ToolContext` with the run's `tape`, turn `state`, `run_id`, and event
+bus:
-Unlike `@tool`, `Tool.from_callable()` does not register the tool globally.
-Include `skill_describe` in the tool set when the model needs to load skill bodies on demand.
-Skills supply instructions; they do not automatically grant shell or filesystem tools.
+```python
+from bub.core.tools import ToolContext
-Each skill lives in its own directory:
-```text
-skills/
-└── order-policy/
- └── SKILL.md
-```
+def remember(note: str, *, context: ToolContext) -> str:
+ """Remember a note for this conversation."""
+ context.state.setdefault("notes", []).append(note)
+ return "noted"
-```markdown
----
-name: order-policy
-description: Rules for answering order-status questions.
----
-Check the order service before giving a delivery status.
+
+agent.tools["remember"] = Tool.from_callable(remember, context=True)
```
-Reuse a `session_id` with the same workspace and store to continue a conversation.
-Choose a new ID for a new conversation. IDs starting with `temp/` run on a fork that is not merged back.
-Archive and builtin sidecar paths still follow Bub configuration, independently of `FileTapeStore`'s directory.
+Tool errors are returned to the model as tool results rather than ending the run. Tool names containing `.` are
+offered to the model with `_` instead (for example, `fs.read` becomes `fs_read`).
+
+## System prompt
-Per-turn options narrow the instance's capabilities and override persisted model settings:
+The system prompt is an ordered list of sections, rendered for every model step and joined by blank lines.
+A section is text, or a sync or async function of a `PromptContext` returning text; an empty result or `None`
+skips the section. `agent.system_prompt.add(section)` appends a section and returns it, so it also works as a
+decorator; `agent.system_prompt.remove(section)` removes it.
```python
-stream = await agent.run_stream(
- session_id="customer-42",
- prompt="Use $order-policy to check order A123.",
- allowed_tools=["lookup_order", "skill"],
- allowed_skills=["order-policy"],
- model="provider:model-id", # Replace with your configured provider/model.
- reasoning_effort="high", # Use a value supported by that model.
-)
+from bub.core import PromptContext
+
+
+@agent.system_prompt.add
+def tool_list(context: PromptContext) -> str:
+ names = ", ".join(tool.name for tool in context.tools)
+ return f"Tools available in this step: {names}."
+
+
+@agent.system_prompt.add
+async def customer(context: PromptContext) -> str | None:
+ if (user := context.state.get("user")) is None:
+ return None
+ return f"You are talking to {user}."
```
-Always consume the returned stream. `allowed_tools` accepts runtime names and model aliases
-(for example, `fs.read` and `fs_read`). Explicit `model` and `reasoning_effort` take precedence over saved settings.
-Normally omit `state` to load session state automatically; passing a state dictionary skips that loading and mutates it.
+The context carries the step's `prompt` (empty when the step continues after tool calls), the model-facing `tools`
+of the step, the `provider:model` spec of the step's `model`, the run's `tape` and `state`, and the `agent` (with an
+`available_tools` shortcut).
-String prompts starting with the agent's command prefix (`,` by default) after trimming whitespace are commands.
-Prefixes must be non-empty and contain no whitespace. Exactly one prefix is removed; a bare prefix raises `ValueError`.
-After overriding the prefix, text using the old prefix is ordinary input. Multimodal prompt lists are not commands.
-Known commands call instance tools directly; unknown commands run through the instance's `bash` tool when available.
-Direct commands bypass model/tool interception hooks and loop-level `allowed_tools` filtering.
+## Sessions and tapes
-## Streams and lifecycle
+Each run works on the tape named by `tape=` in the agent's store. Reuse a name to continue a conversation; choose
+a new one for a new conversation. The run writes to a fork of the tape that merges back when the run's stream
+closes; pass `merge_back=False` to discard it.
-`run_stream()` is awaited first, then its result is iterated. Events include `text`, `reasoning`, `tool_call`,
-`tool_result`, `usage`, `error`, and `final`. A `final` event finishes a model step; exhaust the iterator to finish
-the whole turn. Check error events and handle exceptions from iteration. The stream also exposes `error` and `usage`.
+`state=` is the run's mutable turn state: a dictionary shared with system prompt sections, tools, and event
+handlers. The agent does not persist it; keep anything that must survive in your application or on the tape.
-For early exit from an iteration that has started, close its iterator explicitly:
+```python
+async with agent.run("Any news on A123?", model=model, tape="customer-42", state={"user": "Ada"}) as stream:
+ ...
+
+tape = agent.tape("customer-42")
+messages = await tape.read_messages() # The conversation as Republic messages.
+await tape.handoff(name="summary", state={"summary": "Ada asked about order A123."})
+```
+
+`agent.tape(name)` returns a conversation's tape for use outside runs; it writes to the store directly. By default the
+model context is the chat recorded since the tape's last anchor, so a `handoff()` starts a shorter context. When a
+call fails because the context is too long, the run hands off automatically once and retries the prompt.
+
+## Commands
+
+With `command_prefix` set, a text prompt starting with the prefix runs as a command instead of the agent loop: no
+model is called. `name args...` calls the tool `name`, with positional arguments, which take the tool's parameters
+in order, followed by `key=value` arguments; any other command line runs through the agent's `bash` tool when it has one. The output is the run's
+text and `final` event, and a `CommandFinished` event reports the command, which the tape records as a `command`
+event.
```python
-from contextlib import aclosing
+agent = Agent(tools=[Tool.from_callable(lookup_order)], command_prefix=",")
-stream = await agent.run_stream(session_id="customer-42", prompt="Check order A123")
-async with aclosing(stream.__aiter__()) as events:
- async for event in events:
- print(event.kind, event.data)
+async with agent.run(",lookup_order order_id=A123", model=model, tape="customer-42") as stream:
+ async for event in stream:
+ print(event.kind, event.data) # text, then final, with the tool's rendered result.
```
-Keep `framework.running()` open until all turns finish. Accessing the agent's cached `tape` before entering that
-lifespan can bind it to an in-memory fallback. An explicitly injected store's lifecycle belongs to your application.
-`Agent` itself is not an async context manager and has no `run()` convenience method.
+Leading and trailing whitespace is ignored and exactly one prefix is removed; a bare prefix raises `ValueError`.
+Prefixes must be non-empty and contain no whitespace. Prompts with media are never commands. Command tool calls emit
+`ToolCallStarted` and `ToolCallFinished` like model tool calls, so tool handlers apply to them; their `ToolContext` has
+`command` set. `agent.run_command(tape, line)` runs a command on an open tape directly.
-## Embed in FastAPI
+## Events
-Install the server dependencies with `uv add fastapi uvicorn`, then save this as `app.py` next to `sdk_example.py`:
+The loop reports its progress as events from `bub.core.events`; register handlers to observe or steer it.
```python
-import asyncio
-from contextlib import asynccontextmanager
+from bub.core.events import ToolCallFinished, ToolCallStarted, StepFinished, ToolCallDecision
-from fastapi import FastAPI, HTTPException, Request
-from pydantic import BaseModel
-from sdk_example import create_agent
+@agent.events.on(ToolCallStarted)
+def guard(event: ToolCallStarted) -> None:
+ if event.call.tool == "lookup_order" and not event.call.arguments.get("order_id"):
+ event.decision = ToolCallDecision.deny("order_id is required")
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- framework, agent = create_agent()
- async with framework.running():
- app.state.agent = agent
- app.state.turn_lock = asyncio.Lock()
- yield
+@agent.events.on(ToolCallFinished)
+async def audit(event: ToolCallFinished) -> None:
+ print("tool", event.call.tool, event.result.duration_ms, "ms")
-app = FastAPI(lifespan=lifespan)
+agent.events.on(StepFinished, lambda event: print("step", event.step, event.status))
+```
+
+| Event | When | Handlers may |
+| ------------------------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `RunStarted` | A run begins | observe the model spec, prompt, and run metadata |
+| `RunFinished` | The run's stream closes | observe the status (`ok`, `error`, or `cancelled`), step count, elapsed time, and error |
+| `StepStarted` / `StepFinished` | A model step begins / ends with a status | observe |
+| `LLMCallStarted` | Before each model call | replace `event.request` (model, messages, `max_tokens`), or set `event.decision = LLMCallDecision.finish(text)` to skip the call |
+| `LLMCallFinished` | After each completed or failed model call | observe text, tool calls, usage, and errors |
+| `ToolCallStarted` | Before each tool call | replace `event.call` to change arguments, or set `ToolCallDecision.replace(result)` / `deny(message)` |
+| `ToolCallFinished` | After each tool call, including denied and replaced ones | assign `event.result.result` to change what the model sees |
+| `ChatFinished` | A step's exchange is ready to record | observe |
+| `CommandFinished` | A command prompt ran (see [Commands](#commands)) | observe the command, status, and output |
+
+`agent.events.on(EventType, handler)` registers a sync or async handler and returns it; `@agent.events.on(EventType)`
+does the same as a decorator, and `agent.events.off(EventType, handler)` removes it. A handler for `AgentEvent` receives every event.
+Handlers run in registration order. Setting a decision on a `Before*` event stops delivery to later handlers.
+Handler exceptions propagate to the run.
+
+Every event carries the run's `state`; loop events also carry its `tape`. Recording is itself a handler: a
+`TapeRecorder`, registered first, writes the conversation and `loop.*` events to the run's tape.
+
+### Custom events
+
+Define your own events to connect tools, handlers, and your application through the same bus. An event is a
+keyword-only dataclass subclassing `AgentEvent` (or `TapeEvent`, which also carries the run's `tape`). Emit it with
+`await events.emit(event)`, which delivers it to the handlers registered for its type or a base type and returns it.
+Inside a tool, the run's bus is `context.events`:
+
+```python
+from dataclasses import dataclass
+
+from bub.core.events import AgentEvent
+from bub.core.tools import ToolContext
+
+
+@dataclass(kw_only=True)
+class OrderLookedUp(AgentEvent):
+ order_id: str
+ status: str
+
+
+@dataclass(kw_only=True)
+class RefundRequested(AgentEvent):
+ order_id: str
+ approved: bool | None = None
+
+ @property
+ def stopped(self) -> bool:
+ # Once a handler decides, later handlers are skipped.
+ return self.approved is not None
+
+
+async def lookup_order(order_id: str, *, context: ToolContext) -> dict[str, str]:
+ """Look up the status of an order and whether it can be refunded."""
+ result = {"order_id": order_id, "status": "shipped"}
+ if context.events is not None: # None when the tool runs outside an agent.
+ await context.events.emit(OrderLookedUp(state=context.state, **result))
+ request = await context.events.emit(RefundRequested(state=context.state, order_id=order_id))
+ result["refundable"] = str(bool(request.approved))
+ return result
+
+
+agent.tools["lookup_order"] = Tool.from_callable(lookup_order, context=True)
-class Task(BaseModel):
- session_id: str
- prompt: str
+@agent.events.on(OrderLookedUp)
+async def notify(event: OrderLookedUp) -> None:
+ print("order", event.order_id, "is", event.status, "for", event.state.get("user"))
-@app.post("/tasks")
-async def run_task(task: Task, request: Request):
- async with request.app.state.turn_lock:
- stream = await request.app.state.agent.run_stream(
- session_id=task.session_id,
- prompt=task.prompt,
- )
- parts: list[str] = []
- error: str | None = None
- async for event in stream:
- if event.kind == "text":
- parts.append(str(event.data.get("delta", "")))
- elif event.kind == "error":
- error = str(event.data.get("message", "Agent failed"))
- if error is not None:
- raise HTTPException(status_code=502, detail=error)
- return {"session_id": task.session_id, "output": "".join(parts)}
+@agent.events.on(RefundRequested)
+def approve(event: RefundRequested) -> None:
+ event.approved = event.state.get("user") == "Ada"
```
-Start with `uv run uvicorn app:app`. This example serializes all turns in one process.
-For concurrent independent sessions, use application-owned per-session locking with a cleanup policy.
-Multiple worker processes sharing a store need coordination across workers; a file write lock does not serialize a turn.
+Every event carries the `state` of the run that emits it, so pass `context.state` (or `event.state` from another
+handler). Handlers may modify a mutable event, and the emitter reads the result from the returned event; override
+`stopped` to end delivery once a handler has decided. Your application can also emit events directly with
+`await agent.events.emit(...)`. Custom events are not written to the tape; to record one, append it from a handler,
+for example `await context.tape.append_event("order.looked_up", {...})` in the tool, or use a `TapeEvent` and call
+`event.tape.append_event(...)` in its handler.
-## Agent versus the full message pipeline
+## Streams and lifecycle
+
+`agent.run()` is an async context manager: entering it starts the run, and the stream it yields drives the loop as you
+iterate. Events include `text`, `reasoning`, `tool_call`, `tool_result`, `usage`, `error`, and `final`. A `final`
+event finishes a model step; the run ends when the stream is exhausted. Check error events and handle exceptions from
+iteration. The stream also exposes `error` and `usage`.
+
+Leaving the block closes the stream, even after an early `break` or an exception: the model stream stops, the tape
+fork merges back, and the trace span ends.
+
+```python
+async with agent.run("Check order A123", model=model, tape="customer-42") as stream:
+ async for event in stream:
+ if event.kind == "tool_call":
+ break # The run stops here; what it recorded so far is kept.
+```
+
+To hand the stream to other code, such as a framework that consumes it later, use `await agent.start(...)` with the
+same arguments. It returns the stream without closing it; whoever consumes it must exhaust it or call `aclose()`.
-For ordinary prompts, `Agent.run_stream()` uses the builtin loop and its model/tool interception hooks.
-It does not run `build_prompt`, `save_state`, outbound rendering, or channel dispatch, and does not select a plugin's
-replacement `run_model` implementation. Your application consumes the result.
+An empty prompt yields an `error: empty prompt` result without opening the tape. Runs are traced as
+`invoke_agent {name}` spans when tracing is configured; see [Observability](/docs/tutorials/observability/).
-Use `BubFramework.process_inbound()` for the complete message pipeline and model-runner plugins.
-It returns a `TurnResult` with `model_output`, `state`, and `outbounds`; `stream_output=True` routes streaming output
-through a bound channel router but still returns the completed result.
-See [Hooks](/docs/build/hooks/) for those extension points.
+To drive the loop on a tape you manage yourself, open it with `agent.session(name, state=...)` and call
+`agent.run_on(tape, prompt, model=model)`, or `agent.run_once(prompt, model=model, tape=tape)` for a single model
+step. Both also take `options`.
-Custom channels must set `ChannelMessage.kind="command"` before passing commands to the channel manager to bypass
-buffering and active-window filtering. The buffer no longer infers commands from a leading comma.
-When using the builtin agent, preserve the configured prefix in the message content for execution.
+## Customize the loop
+
+Subclass `Agent` to change how each step is prepared. Each method receives the run's tape, whose `context.state` is
+the turn state:
+
+| Method | Default | Use |
+| -------------------------------------- | --------- | --------------------------------------------------------- |
+| `prepare_tools(tape, prompt)` | all tools | Choose the model-facing tools for the next step. |
+| `pending_messages(tape)` | none | Return user messages that arrived during the run. |
+| `has_pending_messages(tape)` | `False` | Continue the run for another step while messages wait. |
+| `continue_prompt(tape, prompt, state)` | `None` | Return a user message for the next step after tool calls. |
+
+```python
+from bub.core.tape import Tape
+
+
+class TriageAgent(Agent):
+ async def prepare_tools(self, tape: Tape, prompt):
+ tools = await super().prepare_tools(tape, prompt)
+ if tape.context.state.get("read_only"):
+ tools = [tool for tool in tools if tool.name == "lookup_order"]
+ return tools
+```
-Configuration loading remains process-wide. Configure once at application startup;
-separate tool/skill/store instances do not imply isolated configuration files or environment variables.
+To serve an agent over HTTP, follow [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/).
diff --git a/website/src/content/docs/docs/build/tools.mdx b/website/src/content/docs/docs/build/tools.mdx
index 3d0c5cbb..80a145a8 100644
--- a/website/src/content/docs/docs/build/tools.mdx
+++ b/website/src/content/docs/docs/build/tools.mdx
@@ -14,7 +14,7 @@ This guide shows how to define a Bub **tool** — a Python function the model ca
## Define a tool
-Decorate any function with `@tool`. The decorator builds a [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py) object, wraps it with timing logs, and inserts it into the central `REGISTRY`:
+Decorate any function with `@tool`. The decorator builds a [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/core/tools.py) object, wraps it with timing logs, and inserts it into the central `REGISTRY`:
```python
from bub import tool
@@ -59,6 +59,8 @@ Code mode lets the model call tools from Python instead of one tool call at a ti
- Every other allowed tool is an async function inside `run_code`, named after its model-facing name (`tape.info` becomes `await tools.tape_info()`). Top-level `await` is allowed and `asyncio.gather` runs calls concurrently. Calls take keyword arguments, go through the usual `before_tool_call`/`after_tool_call` hooks, return structured results, and raise on failure.
- Bub writes a Python stub with one function per allowed `auto` or `code` tool — parameter and return types plus docstring — under `~/.bub/codemode/` and puts its path in the system prompt. The path stays the same for a session as long as its tool set does not change.
- `run_code(code, timeout_seconds=120)` returns what the code writes to stdout. An uncaught exception becomes a tool error whose details carry the printed output and the traceback.
+- The code also gets a `republic` module (preset as a global and importable), documented at the end of the stub. It has the media classes `republic.Image`, `Audio` and `Video`. `republic.get_model(spec=None)` returns a chat model — without a spec, the model running the session — with `chat()` and `stream()` (`async with model.stream(...) as stream: async for event in stream`, events from `republic.events`), and `republic.get_embedding_model(spec)` and `republic.get_decision_model(spec)` return embedding and decision models. These are proxies: requests are made by Bub with its provider settings and credentials, which never reach the code.
+- The code-only tools `tools.attach_image(image=...)`, `tools.attach_audio(audio=...)` and `tools.attach_video(video=...)` attach media to the `run_code` result, so the model can look at a chart or screenshot the code produced. Besides a `republic` media object, they accept a file path in the environment (relative to its workspace), an http(s)/gs URL or a data URL, and load it themselves, reading files with the environment's `read_bytes`. A result with media is recorded as `{"type": "tool_content", "content": [...]}` (build one with `bub.core.tools.content_result`) and sent to the model as a multimodal tool message; spilling bounds only its text.
If `run_code` is not allowed (for example, a subagent restricted with `allowed_tools`), that turn falls back to direct tool calls.
@@ -75,6 +77,30 @@ Nested calls run with `ToolContext.code_mode` set to `True` (hooks see it as `ca
`run_code` hands the code to the session [environment](#run-tools-in-an-environment)'s `run_code`, together with a `call_tool` callback. Tool calls always run on the host through that callback, so hooks still see every call. `timeout_seconds` cancels the environment call. The builtin `LocalEnvironment` starts a fresh Python process for every call (`sys.executable`, working directory is the workspace) and forwards tool calls as JSON lines over its stdin and stdout, so arguments and results must be JSON-serializable. When the code finishes, fails or times out, it kills the process and everything it started. That process runs on the host with the same permissions as `bash`, so enable code mode only where `bash` would be acceptable. The return annotation of a tool function (or `Tool.output_schema`, for tools built by hand) determines the result type shown in the stub.
+#### Code mode in your own agent
+
+Code mode lives in the kernel (`bub.core.codemode`), so an agent built on `bub.core.Agent` can use it without the `app` extra. A `CodeMode` holds the tools callable from code and provides the `run_code` tool and a system prompt section:
+
+```python
+from bub.core import Agent
+from bub.core.codemode import CodeMode
+from bub.environment import LocalEnvironment
+
+code_mode = CodeMode(
+ [search, fetch], # tools callable as tools.(...) inside run_code
+ environment=LocalEnvironment("/work"), # where the code runs; defaults to the current directory
+ models="openai:gpt-5", # what republic.get_model() in the code resolves to
+)
+agent = Agent(model=model, tools=[code_mode.run_code], system_prompt=["You are ...", code_mode.prompt])
+```
+
+- `tools` can also be a function of the turn state returning the tools for that run, or `None` to turn code mode off; `environment` can likewise be a function of the state.
+- `models` is a default `provider:model` spec, or a resolver `(spec, context) -> (republic_spec, client_kwargs)` that keeps credentials on the host. Without it the code must pass explicit specs and clients read credentials from the environment.
+- The stub goes into the system prompt itself; pass `stub_dir=` to write it to a file and reference the path instead, for agents that can read files.
+- `media_tools=True` (default) adds `attach_image`, `attach_audio` and `attach_video`.
+
+Calls made from code go through the agent's `ToolCallStarted`/`ToolCallFinished` events with `context.code_mode` set, so handlers registered with `agent.events.on` see them. Bub's own agents use the same class (`bub.builtin.codemode.CODE_MODE`), wired to session settings, environments and model settings.
+
### Load tools on demand
Mark a tool `deferred=True` (on `@tool`, `Tool.from_callable`, or with `dataclasses.replace(tool, deferred=True)`) to keep its definition out of model requests until it is needed. Tools without the flag are loaded directly.
@@ -90,23 +116,23 @@ Mark a tool `deferred=True` (on `@tool`, `Tool.from_callable`, or with `dataclas
`bash`, `bash.output`, `bash.kill` and `fs.*` do not touch the host directly: they run on the session's `Environment` (from [`bub.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/environment.py)), which spawns processes, reads and writes text files, and runs code for `run_code`. Bub keeps the tool behavior — background shells, timeouts, rendering, hooks — on the host, so an environment only needs to implement a few operations:
- `spawn(command, *, cwd=None, env=None)` returns a `Process`: a string runs through the shell, a sequence runs as an argument vector. The process exposes `stdout`/`stderr` streams, `write_stdin`, `wait`, and `signal(kill=...)`, which must reach the whole process tree.
-- `read_text(path)` and `write_text(path, content)` access files inside the environment.
+- `read_text(path)` and `write_text(path, content)` access files inside the environment. `read_bytes(path)` reads binary files; its default runs the environment's `python` to print the file, so override it when the environment has direct file access.
- `workspace` is the working directory inside the environment; `resolve_path` resolves relative paths against it.
-- `run_code(code, *, tools, call_tool, write)` runs Python code in which `await tools.(**kwargs)` calls `call_tool(name, kwargs)`. It passes the code's stdout to `write` as it arrives, raises `CodeFailed` when the code raises, and stops the code when cancelled. How it runs is up to the environment. An environment that has a Python interpreter can reuse `bub.builtin.codemode.code_runner.run_code_in_subprocess`, which runs the code in a process started with `spawn`.
+- `run_code(code, *, tools, call_tool, write)` runs Python code in which `await tools.(**kwargs)` calls `call_tool(name, kwargs)`. The `republic` module calls `call_tool` with names prefixed `republic.`, and Republic values travel as JSON objects with a `"$republic"` key; the subprocess runner provides it. It passes the code's stdout to `write` as it arrives, raises `CodeFailed` when the code raises, and stops the code when cancelled. How it runs is up to the environment. An environment that has a Python interpreter can reuse `bub.core.codemode.runner.run_code_in_subprocess`, which runs the code in a process started with `spawn`.
- `close()` releases the environment.
-Provide one per session with the `provide_environment(session_id, workspace)` hook. Bub calls it on the session's first turn, puts the result in `state["_runtime_environment"]`, and closes it when `framework.running()` exits. Bub's builtin hooks provide `LocalEnvironment` (in [`bub.builtin.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/environment.py)), which runs everything on the host; a plugin's implementation takes precedence over it. Your own tools can use the same environment through `bub.builtin.environment.environment_from_state(context.state)`.
+Provide one per session with the `provide_environment(session_id, workspace)` hook. Bub calls it on the session's first turn, puts the result in `state["_runtime_environment"]`, and closes it when `framework.running()` exits. Bub's builtin hooks provide `LocalEnvironment` (in [`bub.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/environment.py)), which runs everything on the host; a plugin's implementation takes precedence over it. Your own tools can use the same environment through `bub.builtin.environment.environment_from_state(context.state)`.
## How tools become available
-The `REGISTRY` lives in [`bub.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py):
+The `REGISTRY` lives in [`bub.core.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/core/tools.py):
```python
-# from src/bub/tools.py
+# from src/bub/core/tools.py
REGISTRY: dict[str, Tool] = {}
```
-Every `@tool` call mutates this dict at **import time**. `Agent(tools=None)` snapshots it when created; later imports do not update existing agents. Add tools to an instance's `Agent.tools`, and mark them `deferred=True` to [load them on demand](#load-tools-on-demand).
+Every `@tool` call mutates this dict at **import time**. `BubAgent(tools=None)` snapshots it when created; later imports do not update existing agents. Add tools to an instance's `BubAgent.tools`, and mark them `deferred=True` to [load them on demand](#load-tools-on-demand).
## Import the tools module from your plugin
@@ -141,7 +167,7 @@ See [Surfaces](/docs/concepts/surfaces/) for the full split between operator and
Tools do not appear in `bub hooks`, but you can confirm registration with a Python one-liner:
```bash
-uv run python -c "import bub_myplugin.plugin; from bub.tools import REGISTRY; print(sorted(REGISTRY))"
+uv run python -c "import bub_myplugin.plugin; from bub.core.tools import REGISTRY; print(sorted(REGISTRY))"
```
The output should include your tool's name. From there, run a turn that asks the model to call it.
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 8fc73204..63d7d2ca 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 `FileTapeStore` rooted at `~/.bub/tapes/`. 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 `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).
### Spill sidecar
@@ -54,7 +54,7 @@ The builtin spill plugin mounts a `SpillStore` through `provide_tape_sidecar`, r
The main tape keeps a bounded preview and an opaque handle instead of the complete result. The `spill.read` tool reads a bounded page by handle and cursor, including pages counted from the end. Content explicitly returned by `spill.read` is recorded as a normal bounded tool result. `spill.read` has `exposure="direct"`, so it returns plain text and stays directly callable in code mode instead of being routed through `run_code`.
-The sidecar follows the session tape through forks, merges, archive, and reset, but remains a separate tape and is never scanned while constructing the main context. It can also be archived or reset independently with `Tape.archive_sidecar("spill")` and `Tape.reset_sidecar("spill")`. When reset requests an archive, Bub preserves the sidecar if that archive fails.
+The sidecar follows the session tape through forks, merges, archive, and reset, but remains a separate tape and is never scanned while constructing the main context. It can also be reset independently with `Tape.reset_sidecar("spill")`, or archived with `bub.builtin.archive.archive_sidecar(tape, "spill", directory)`. When `tape.reset` requests an archive, Bub preserves the sidecar if that archive fails.
Spill configuration belongs to the sidecar plugin: use `spill.threshold` in `config.yml` or `BUB_SPILL_THRESHOLD` in the environment. Setting it to `0` stops new writes without unmounting the sidecar, so existing handles remain readable and tape lifecycle operations still include it.
@@ -64,16 +64,18 @@ Spill writes are recorded as `spill.write`. Framework-managed lifecycle outcomes
Before the first turn on a tape, `TapeService.ensure_bootstrap_anchor` checks for an anchor entry. If none exists, it writes a `session/start` handoff with `state={"owner": "human"}`. This guarantees that context reconstruction has a starting anchor on every tape.
-### default_tape_context: entries → OpenAI messages
+### default_tape_context: entries → model messages
-`default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and emits OpenAI-compatible messages. The mapping is:
+`default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and returns a list of `republic.Message` objects. The mapping is:
- `anchor` → an `assistant` message of the form `[Anchor created: ]: `.
-- `message` → the entry payload, used as a chat message dict.
+- `message` → the entry payload, converted with `bub.core.tape.to_message`. New user messages are stored as `republic.Message.to_dict()`; older Bub payloads (media URLs, `tool_call_id`, `reasoning`, `provider_data`) are still read.
- `tool_call` → one `assistant` message with the saved text (or empty content for calls without text, including older entries) and a `tool_calls` array.
-- `tool_result` → one `tool` role message per result; when a preceding `tool_call` is pending, Bub copies the call's `id` into `tool_call_id` by result position.
+- `tool_result` → one `republic.tool(...)` message per result, paired with the preceding `tool_call` entry's calls by position. Results without a recorded call are skipped.
-The context selector is a hook (`build_tape_context`), so plugins can replace it with a different strategy — compaction, summarization, retrieval — without touching the rest of the pipeline.
+Saved reasoning and provider data are always included; Republic sends them back only to API formats that accept them.
+
+The context selector is a hook (`build_tape_context`), so plugins can replace it with a different strategy — compaction, summarization, retrieval — without touching the rest of the pipeline. A custom `select` must return Republic messages (or an awaitable of them); `bub.core.tape.to_messages` converts stored message payloads.
Entries marked `context=False` are removed before the selector runs. This is how operational spill events remain queryable on the tape without appearing in either the default context or a plugin-defined context.
diff --git a/website/src/content/docs/docs/concepts/turn-pipeline.mdx b/website/src/content/docs/docs/concepts/turn-pipeline.mdx
index 80f10f82..1c14cd82 100644
--- a/website/src/content/docs/docs/concepts/turn-pipeline.mdx
+++ b/website/src/content/docs/docs/concepts/turn-pipeline.mdx
@@ -84,9 +84,9 @@ The default observer (`BuiltinImpl.on_error`) sends an error envelope through `d
`BuiltinImpl` (registered as `builtin`) implements every hook above with sensible defaults:
- `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`.
-- `build_prompt` — handles comma commands (`,help`, `,skill`, ...), wraps text with timestamp and context, and emits multimodal parts when media is attached.
-- `run_model` / `run_model_stream` — delegate to `Agent.run` / `Agent.run_stream`, the [Republic](https://github.com/bubbuild/republic)-backed loop with tool use and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)).
+- `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.
- `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/getting-started/first-plugin.mdx b/website/src/content/docs/docs/getting-started/first-plugin.mdx
index aed48413..9cc76662 100644
--- a/website/src/content/docs/docs/getting-started/first-plugin.mdx
+++ b/website/src/content/docs/docs/getting-started/first-plugin.mdx
@@ -38,7 +38,7 @@ build-backend = "hatchling.build"
name = "bub-echo-plugin"
version = "0.1.0"
requires-python = ">=3.12"
-dependencies = ["bub>=0.1"]
+dependencies = ["bub[app]>=0.1"]
[project.entry-points."bub"]
echo = "bub_echo_plugin.plugin:echo_plugin"
diff --git a/website/src/content/docs/docs/getting-started/first-turn.mdx b/website/src/content/docs/docs/getting-started/first-turn.mdx
index 67501223..4a17ac42 100644
--- a/website/src/content/docs/docs/getting-started/first-turn.mdx
+++ b/website/src/content/docs/docs/getting-started/first-turn.mdx
@@ -12,7 +12,7 @@ This tutorial walks through one full turn — the framework's name for one inbou
You should have:
- [Bub installed](/docs/getting-started/install/) and `bub hooks` reporting the `builtin` plugin
-- one model access path: an API key exported as `BUB_API_KEY` or a provider-specific `BUB__API_KEY`; alternatively, OpenAI Codex OAuth via `bub login openai` with `BUB_MODEL=openai:`
+- one model access path: an API key exported as `BUB_API_KEY` or a provider-specific `BUB__API_KEY`; alternatively, OpenAI Codex OAuth via `bub login codex` with `BUB_MODEL=codex:`
## 1. Create a workspace
@@ -80,15 +80,15 @@ For interactive use, start a chat session instead:
bub chat
```
-## 5. Optional: log in with OpenAI OAuth
+## 5. Optional: use your ChatGPT plan
-If you want to use the OpenAI Codex OAuth path instead of an API key, log in with OAuth:
+Install the Codex CLI, then log in to use your ChatGPT plan:
```bash
-bub login openai
+bub login codex
```
-Bub stores the credential locally. Use it by setting `BUB_MODEL=openai:` for a model available to your Codex account, leaving `BUB_API_KEY` unset, and not setting a custom `BUB_API_BASE`.
+The Codex CLI stores the credential in its local file store; Republic reads and refreshes it. Use it by setting `BUB_MODEL=codex:` for a model available to your Codex account, leaving `BUB_API_KEY` unset, and not setting a custom `BUB_API_BASE`.
## Next steps
diff --git a/website/src/content/docs/docs/getting-started/index.mdx b/website/src/content/docs/docs/getting-started/index.mdx
index f30b93b8..30f2177c 100644
--- a/website/src/content/docs/docs/getting-started/index.mdx
+++ b/website/src/content/docs/docs/getting-started/index.mdx
@@ -17,7 +17,7 @@ You should have:
- a network connection that can reach `bub.build`, `astral.sh`, and PyPI
- one model access path for the model-backed step:
- an API key exported as `BUB_API_KEY` or as a provider-specific `BUB__API_KEY`, or
- - OpenAI Codex OAuth via `bub login openai` with `BUB_MODEL=openai:`
+ - OpenAI Codex OAuth via `bub login codex` with `BUB_MODEL=codex:`
If you only want to inspect Bub without running a model turn, the API key is optional — comma commands such as `,help` work without one.
diff --git a/website/src/content/docs/docs/getting-started/install.mdx b/website/src/content/docs/docs/getting-started/install.mdx
index 2c636fd3..8e1eddda 100644
--- a/website/src/content/docs/docs/getting-started/install.mdx
+++ b/website/src/content/docs/docs/getting-started/install.mdx
@@ -76,16 +76,18 @@ You can inspect the scripts and the versioned preset catalog before running them
If `uv` is already available, the equivalent user-level installation is:
```bash
-uv tool install "bub@latest"
+uv tool install "bub[app]@latest"
uv tool update-shell
```
If you prefer `pip`, the equivalent is:
```bash
-pip install bub
+pip install "bub[app]"
```
+The `app` extra brings the `bub` command, channels, plugins and the builtin agent. A plain `bub` install contains only the agent kernel `bub.core` for [embedding in Python](/docs/build/sdk/); its `bub` command asks for the extra.
+
## 3. Install from source
Use this path if you want to contribute to the framework or follow `main`:
diff --git a/website/src/content/docs/docs/operate/configure.mdx b/website/src/content/docs/docs/operate/configure.mdx
index 1208fb89..1656f4af 100644
--- a/website/src/content/docs/docs/operate/configure.mdx
+++ b/website/src/content/docs/docs/operate/configure.mdx
@@ -13,16 +13,16 @@ Bub reads its own configuration from these places, in order of precedence (highe
2. `.env` values loaded during CLI startup or by settings classes that explicitly declare `env_file=".env"`.
3. `~/.bub/config.yml` (created by `bub onboard`).
-Downstream model SDKs may still read their own provider variables, such as `OPENROUTER_API_KEY`, when no explicit `BUB_API_KEY` is supplied. Bub's settings layer only maps `BUB_*` variables.
+When no explicit Bub key is supplied, Bub reads `_API_KEY` and `_API_BASE`, using Republic provider names with hyphens replaced by underscores. Examples include `OPENROUTER_API_KEY`, `GOOGLE_API_KEY`, and `AZURE_OPENAI_API_KEY`. Explicit `BUB_*` settings take precedence.
## Before you begin
- Bub is installed (`bub --help` works).
-- You have one model provider's API key, or you have run `bub login openai` for OAuth.
+- You have one model provider's API key, or you have run `bub login codex` for OAuth.
## 1. Run the interactive setup
-`bub onboard` guides you through provider selection, API key and URL (when needed), connection checking, searchable model selection, channels, and streaming settings. Hosted providers use their default URL; choose **OpenAI-compatible** for a custom OpenAI-compatible server. Leave the API key blank to use environment credentials. If the model list is unavailable, you can edit the connection, retry, or enter a model ID manually. The connection check fetches models without generating a completion. An existing OpenAI OAuth login skips model discovery and uses manual model entry. The collected configuration replaces `~/.bub/config.yml`; existing file contents are not merged into the onboarding prompts. Plugins may add prompts through the `onboard_config` hook.
+`bub onboard` guides you through provider selection, API key and URL (when needed), connection checking, searchable model selection, channels, and streaming settings. Hosted providers use their default URL; choose **OpenAI-compatible** for a custom OpenAI-compatible server. Leave the API key blank to use environment credentials. If the model list is unavailable, you can edit the connection, retry, or enter a model ID manually. The connection check fetches models without generating a completion. Selecting Codex skips model discovery and uses manual model entry. The collected configuration replaces `~/.bub/config.yml`; existing values supply prompt defaults and are preserved unless updated. Plugins may add prompts through the `onboard_config` hook.
```bash
bub onboard
@@ -55,14 +55,14 @@ Every YAML key has a matching environment variable. The pattern is `BUB_`. |
-| `BUB_API_KEY` | API key for the active provider. Leave unset when using `bub login openai`. |
-| `BUB_API_BASE` | Override the provider's base URL. Leave unset for Codex OAuth so Bub can route through the Codex backend. |
+| `BUB_MODEL` | Model identifier, e.g. `openrouter:openrouter/free` or `codex:`. |
+| `BUB_API_KEY` | API key for the active provider. Leave unset when using `bub login codex`. |
+| `BUB_API_BASE` | Override the provider's base URL. Leave unset to use the selected provider's default endpoint. |
| `BUB_HOME` | Bub's runtime data directory (history, tapes, managed plugin project). Default `~/.bub`; does not move the default config file. |
| `BUB__API_KEY` | Per-provider key, e.g. `BUB_OPENROUTER_API_KEY`. |
| `BUB__API_BASE` | Per-provider base URL. |
-When you use `bub login openai`, Bub reads Codex OAuth credentials from `CODEX_HOME` or `~/.codex` and refreshes them automatically. Use a model id under the `openai:` provider and keep both `BUB_API_KEY` and `BUB_API_BASE` unset for that path.
+Republic reads and refreshes Codex file credentials from `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Install the Codex CLI for a new login, then select `codex:` and leave `BUB_API_KEY` unset. Use `openai:` with API credentials for OpenAI API access.
To reach an endpoint that speaks a supported API under a name of its own (a company proxy, a relay, a second account), declare it under `providers` and use that name as the model prefix; see [Custom providers](/docs/reference/settings/#custom-providers).
diff --git a/website/src/content/docs/docs/operate/deploy.mdx b/website/src/content/docs/docs/operate/deploy.mdx
index 74869b63..00a1b54b 100644
--- a/website/src/content/docs/docs/operate/deploy.mdx
+++ b/website/src/content/docs/docs/operate/deploy.mdx
@@ -56,7 +56,7 @@ docker compose up -d --build
docker compose logs -f app
```
-The first run takes a few minutes — the image installs system tools (git, node, jq, etc.) and runs `uv sync --no-dev --no-editable`.
+The first run takes a few minutes — the image installs system tools (git, node, jq, etc.) and runs `uv sync --no-dev --no-editable --extra app`.
## 4. What `entrypoint.sh` does
diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx
index 81fe0aa8..4f853ff6 100644
--- a/website/src/content/docs/docs/reference/cli.mdx
+++ b/website/src/content/docs/docs/reference/cli.mdx
@@ -18,6 +18,8 @@ bub [OPTIONS] COMMAND [ARGS]...
The framework is created in `bub/__main__.py`, which calls `BubFramework().load_hooks()` and then `create_cli_app()`. The active framework is stored on `ctx.obj`.
+Terminals that support [OSC 7501](https://www.superlogical.com/rex/docs/build/program-status) can show when Bub is working, waiting for input or approval, finished, or failed. Cancelling a command reports `idle`. `chat` and `gateway` report `idle` once listening, and remain busy until all accepted messages finish. Other terminals display normal command output. Redirected output contains no status sequences.
+
## `bub run`
Run one inbound `ChannelMessage` through `BubFramework.process_inbound` and print every outbound to stdout.
@@ -109,13 +111,13 @@ Omit the name to run all onboarding hooks.
Behavior notes:
- All providers follow the same flow: select a provider, enter connection details, check the connection, choose a model, then configure channels and streaming.
-- Provider labels distinguish **OpenAI (official API)** from **OpenAI-compatible (custom URL / local server)**. Compatible services are saved as `openai:` with your API base URL.
-- Hosted providers use their default endpoint and normally ask only for an API key. Compatible services, Azure, Ollama, other providers, and custom endpoints supplied by preceding hooks also prompt for a URL. Blank API keys retain a key supplied in this run for the same endpoint or use environment credentials, including `OPENAI_API_KEY`, `BUB_API_KEY`, and `BUB_OPENAI_API_KEY`. Environment keys are not copied into the saved configuration. A compatible server gets the SDK's placeholder key only when no key is supplied or available from the environment.
-- Bub fetches the provider's model list with a 10-second asynchronous timeout, then offers searchable model selection and manual entry. Blocking SDK calls may take longer. This checks access to the models endpoint; it does not test a model completion. If discovery fails, edit the URL/key, retry, or enter a model ID manually. Providers that do not support model discovery, including Azure in the current SDK, explain the limitation and proceed directly to manual entry without offering retries.
-- When the effective connection uses OpenAI OAuth, model discovery is skipped and you enter a model ID manually. Displayed default URLs are not written to the configuration. Leave the URL blank to preserve the default and OAuth routing; entering a URL explicitly saves it, even if it matches the displayed default.
+- Provider labels distinguish **OpenAI (official API)** from **OpenAI-compatible (custom URL / local server)**. Compatible services are saved as `openai:` with your API base URL and `client_args.api_format: chat`.
+- Hosted providers use their default endpoint and normally ask only for an API key. Compatible services, Azure OpenAI, Ollama, other providers, and custom endpoints supplied by preceding hooks also prompt for a URL. Blank API keys retain a key supplied in this run for the same endpoint or use environment credentials, including `OPENAI_API_KEY`, `BUB_API_KEY`, and `BUB_OPENAI_API_KEY`. Environment keys are not copied into the saved configuration.
+- Bub fetches the provider's model list with a 10-second asynchronous timeout, then offers searchable model selection and manual entry. This checks access to the models endpoint; it does not test a model completion. If discovery fails, edit the URL/key, retry, or enter a model ID manually. Providers that do not support model discovery, including Codex, explain the limitation and proceed directly to manual entry without offering retries.
+- When you select the Codex provider, model discovery is skipped and you enter a model ID manually. Displayed default URLs are not written to the configuration. Leave the URL blank to preserve the default endpoint; entering a URL explicitly saves it, even if it matches the displayed default.
- Each plugin's `onboard_config` hook receives a copy of the loaded configuration plus updates from earlier hooks. Existing fields are preserved unless a hook overrides them; non-dict returns abort with `TypeError`.
- The merged dict is validated through `configure.validate` before being written.
-- `onboard` is interactive. The provider labels and prompt order have changed, and connection results determine subsequent prompts. Scripts that supply a fixed sequence of answers must be updated; for unattended setup, write the configuration file directly. The bundled installers launch `onboard` only in interactive mode.
+- `onboard` is interactive, and connection results determine subsequent prompts. For unattended setup, write the configuration file directly. The bundled installers launch `onboard` only in interactive mode.
- After saving a configuration with at least one channel on Linux or Windows, onboarding asks whether to install the gateway as a per-user background service. The default is no. Installation failure leaves the saved configuration intact and exits with code `1`.
Exit codes:
@@ -189,25 +191,25 @@ bub login [OPTIONS] COMMAND [ARGS]...
| --- | --- | --- | --- |
| `--help` | flag | — | Show help and exit. |
-### `bub login openai`
+### `bub login codex`
-Run the Codex OAuth flow to obtain OpenAI credentials, then save them under the resolved Codex home.
+Run the installed Codex CLI with file credential storage.
```text
-bub login openai [OPTIONS]
+bub login codex [OPTIONS]
```
| Option | Type | Default | Description |
| --- | --- | --- | --- |
-| `--codex-home` | PATH | `$CODEX_HOME` or `~/.codex` | Directory to store the resulting `auth.json`. |
-| `--browser` / `--no-browser` | flag | `--browser` | Open the OAuth authorize URL in the default browser. |
-| `--manual` | flag | off | Skip the local callback server and prompt for the callback URL or code. |
-| `--timeout` | FLOAT | `300.0` | OAuth wait timeout in seconds. |
+| `--executable` | TEXT | `codex` | Codex CLI executable. |
+| `--device-auth` | flag | off | Use device authorization for a terminal without a browser. |
+| `--codex-home` | PATH | `$CODEX_HOME` or `~/.codex` | Credential directory for the login subprocess. |
Behavior notes:
-- On success prints `login: ok`, the account id, the auth file path, and a usage hint to set `BUB_MODEL=openai:` and omit `BUB_API_KEY`.
-- On `CodexOAuthLoginError` exits with code `1`.
+- On success prints `login: ok`, the account id, and a usage hint to set `BUB_MODEL=codex:` and omit `BUB_API_KEY`.
+- Authentication failure exits with code `1`. Missing executables produce an installation hint.
+- Credentials are stored in `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Use `--codex-home` to select a directory and `--device-auth` for headless login.
## `bub hooks`
diff --git a/website/src/content/docs/docs/reference/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx
index a44ab197..e0be40b0 100644
--- a/website/src/content/docs/docs/reference/hooks.mdx
+++ b/website/src/content/docs/docs/reference/hooks.mdx
@@ -17,7 +17,7 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi
| --- | --- | --- | --- | --- | --- |
| `resolve_session` | firstresult | `(message: Envelope) -> str` | session id | `BubFramework.process_inbound` | Falls back to `"{channel}:{chat_id}"` when no impl returns a value. |
| `load_state` | broadcast | `(message: Envelope, session_id: str) -> TurnState` | dict to merge | `BubFramework.process_inbound` | Framework reverses results, then merges in order so high-priority keys overwrite low-priority. |
-| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> str \| list[dict]` | prompt or content parts | `BubFramework.process_inbound` | Falls back to `content_of(message)` when no impl returns a non-`None` value, or when the selected firstresult is falsy. A falsy selected value does not cause lower-priority impls to run. |
+| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> list[UserContent]` | prompt content | `BubFramework.process_inbound` | Falls back to `content_of(message)` when no impl returns a non-`None` value, or when the selected firstresult is falsy. A falsy selected value does not cause lower-priority impls to run. |
| `run_model` | firstresult | `(prompt, session_id, state) -> str` | model text | `HookRuntime.run_model` | Legacy text path. Implement either `run_model` or `run_model_stream`, not both. |
| `run_model_stream` | firstresult | `(prompt, session_id, state) -> AsyncStreamEvents` | async stream | `HookRuntime.run_model_stream` | Preferred. Falls back to wrapping a `run_model` result in a one-chunk stream when no streaming impl exists. |
| `continue_prompt` | firstresult | `(prompt: str \| list[dict] \| None, tape: Tape, state: StreamState) -> str \| None` | optional next-step user message | `Agent._stream_events_with_auto_handoff` | Runs before each continued agent-loop step. `prompt` is the prompt used by the completed step, or `None` if none was added. Implementations may be sync or async. If no hook returns a string, the next model call uses the existing conversation without adding a user message. |
@@ -36,10 +36,16 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi
| `provide_channels` | sync-only consumer (deduped) | `(message_handler: MessageHandler) -> list[Channel]` | channels | `BubFramework.get_channels` (`call_many_sync`) | Channels are deduplicated by `Channel.name`; the first channel seen in hook priority order wins. |
| `build_tape_context` | firstresult | `() -> TapeContext` | tape context | `BubFramework.build_tape_context` (`call_first_sync`) | Sync-only; awaitable returns are skipped. |
| `admit_message` | firstresult | `(session_id, message, turn) -> AdmitDecision \| None` | turn admission decision | `ChannelManager` | Runs before channel scheduling. `None` keeps default concurrent scheduling; decision types are listed in [Core contracts](/docs/reference/types/). |
-| `before_llm_call` | chained | `(request: LlmCallRequest, state: TurnState) -> LlmCallRequest \| LlmCallDecision \| None` | modified request or finish decision | `ModelRunner.run` via `AgentHooks` | Impls chain in LIFO order; each sees the previous impl's request. `LlmCallDecision.finish(text)` skips the provider call. Raising impls are logged and skipped. |
-| `after_llm_call` | observer | `(request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None` | none | `ModelRunner.run` via `AgentHooks` | Fires exactly once per completed call: success or `Exception` failure. Cancellation / consumer `aclose()` is not observed. `result.error` is the original exception. |
-| `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` via `AgentHooks` | Per tool invocation. `proceed(arguments=…)` folds argument changes; `replace(result)` / `deny(message)` short-circuit. Veto only via the decision object — exceptions are logged and skipped. |
-| `after_tool_call` | terminal handler | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | none | `ToolExecutor` via `AgentHooks` | Fires for success, failure and deny/replace. Cancellation is not observed. Assigning `result.result` replaces the model-facing value; `result.error` remains the original `BubError` for failures. |
+| `before_llm_call` | chained | `(request: LLMCallRequest, state: TurnState) -> LLMCallRequest \| LLMCallDecision \| None` | modified request or finish decision | core `Agent` via `PluginAgentHooks` | Impls chain in LIFO order; each sees the previous impl's request. `LLMCallDecision.finish(text)` skips the provider call. Raising impls are logged and skipped. |
+| `after_llm_call` | observer | `(request: LLMCallRequest, result: LLMCallResult, state: TurnState) -> None` | none | core `Agent` via `PluginAgentHooks` | Fires exactly once per completed call: success or `Exception` failure. Cancellation / consumer `aclose()` is not observed. `result.error` is the original exception. |
+| `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` via `PluginAgentHooks` | Per tool invocation. `proceed(arguments=…)` folds argument changes; `replace(result)` / `deny(message)` short-circuit. Veto only via the decision object — exceptions are logged and skipped. |
+| `after_tool_call` | terminal handler | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | none | `ToolExecutor` via `PluginAgentHooks` | Fires for success, failure and deny/replace. Cancellation is not observed. Assigning `result.result` replaces the model-facing value; `result.error` remains the original `BubError` for failures. |
+
+### 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.
+
+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.
## How hooks are invoked
@@ -120,15 +126,15 @@ Each `provide_tape_sidecar` implementation contributes one named capability back
`notify_error` and `notify_error_sync` wrap each impl in a `try`/`except`; observer failures are logged (`hook.on_error_failed stage=… adapter=…`) but never propagate. This guarantees one broken observer does not block the others, and prevents an `on_error` from masking the original exception.
-### Agent-loop hooks (`AgentHooks`)
+### Agent-loop hooks (`PluginAgentHooks`)
-The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `AgentHooks` facade (`src/bub/hooks/interception.py`), 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 `LLMCallStarted`/`LLMCallFinished`/`ToolCallStarted`/`ToolCallFinished` events (`bub.core.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.
+- **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 beside those semantics in `src/bub/hooks/interception.py`. 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 `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.
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/settings.mdx b/website/src/content/docs/docs/reference/settings.mdx
index 2f14762c..59f1ad01 100644
--- a/website/src/content/docs/docs/reference/settings.mdx
+++ b/website/src/content/docs/docs/reference/settings.mdx
@@ -65,23 +65,33 @@ Loaded under the YAML root section.
| `BUB_API_BASE` | unset | `api_base` | Default API base URL or per-provider mapping. |
| `BUB__API_KEY` | unset | — | Provider-scoped API key, e.g. `BUB_OPENAI_API_KEY`. |
| `BUB__API_BASE` | unset | — | Provider-scoped API base URL, e.g. `BUB_OPENROUTER_API_BASE`. |
-| `BUB_PROVIDERS` | `{}` | `providers` | Named endpoints, each speaking one any-llm provider's API (`type`) with its own `api_base` and `api_key`. See [Custom providers](#custom-providers). |
+| `BUB_PROVIDERS` | `{}` | `providers` | Named endpoints, each speaking one Republic provider's API (`type`) with its own `api_base` and `api_key`. See [Custom providers](#custom-providers). |
| `BUB_MAX_STEPS` | unlimited | `max_steps` | Maximum agent loop iterations per turn. Must be a positive integer when set. |
-| `BUB_MAX_TOKENS` | `16384` | `max_tokens` | Maximum tokens per model call. |
+| `BUB_MAX_TOKENS` | `16384` | `max_tokens` | Maximum tokens per model call; omitted for Codex. |
| `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | Per-call timeout in seconds. |
| `BUB_CLIENT_ARGS` | `{}` | `client_args` | Extra kwargs passed to the underlying model client (JSON / dict). |
-| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | Extra kwargs passed to each completion call, e.g. `{"reasoning_effort":"high"}`. Bub-managed arguments take precedence. |
+| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | Extra kwargs passed to each completion call, e.g. `{"reasoning_effort":"high"}`. Bub supplies the tools and token limit; see option precedence below. |
| `BUB_VERBOSE` | `0` | `verbose` | Logging verbosity level (`0`–`2`). |
For example, `command_prefix: "!"` enables `!help` in builtin channels; `,help` becomes ordinary text.
CLI controls (`quit`, `exit`, `thinking`), completion, and shell mode use the same prefix.
Telegram keeps its existing native slash-command filtering; with `/` as the prefix, use `/bub /help`.
-Provider-specific defaults are gathered at startup by scanning `os.environ` for `^BUB_(.+)_(API_KEY|API_BASE)$` and lowercasing the captured provider name.
+Provider-specific defaults are gathered at startup by scanning `os.environ` for `^BUB_(.+)_(API_KEY|API_BASE)$` and resolving the captured environment prefix to the Republic provider name (for example, `AZURE_OPENAI` → `azure-openai`).
+
+Bub uses Republic for model requests. Use `codex:` for ChatGPT plan access and `openai:` for OpenAI API access. A stored Codex login never changes the `openai:` route. Provider identifiers follow Republic, including `google:` and `azure-openai:`.
+
+`client_args` accepts Republic provider options such as `headers`, `api_format`, `timeout`, and `max_retries`. Bub supplies the resolved connection settings (`api_key`, `api_base`); other options pass through unchanged. Protocol defaults follow Republic. Set `api_format: chat` explicitly for a Chat Completions endpoint. An injected HTTP client must be an `httpx2.AsyncClient` and stays caller-owned.
+
+`completion_args` accepts Republic chat options. Bub supplies `tools` and the runtime token limit; a session's `reasoning_effort` overrides the configured chat option. The runtime token limit is omitted for Codex because that endpoint does not support it. Republic handles wire-format conversion and Anthropic requests enable prompt caching.
+
+Provider-specific fields go in `extra_body`, which is accepted in both `client_args` and `completion_args`. Republic deep-merges provider defaults with request extras, with request values taking precedence, then merges them over the encoded request body. These explicit wire fields can override chat options, including reasoning and token limits; Bub passes them through unchanged.
+
+Provider names and available protocols follow [Republic's provider directory](https://getrepublic.org/providers/). Custom provider implementations must be registered with Republic.
### Custom providers
-The model prefix normally names an any-llm provider, and `api_key` / `api_base` hold one value per provider. Use `providers` when an endpoint speaks an API any-llm already supports but needs a name of its own: a company proxy, a relay or local gateway, a self-hosted server, or a second account with the same vendor. Each name becomes a model prefix, and `type` is the any-llm provider whose API it speaks:
+The model prefix normally names a Republic provider, and `api_key` / `api_base` hold one value per provider. Use `providers` when an endpoint speaks an API Republic already supports but needs a name of its own: a company proxy, a relay or local gateway, a self-hosted server, or a second account with the same vendor. Each name becomes a model prefix, and `type` is the Republic provider whose API it speaks:
```yaml
model: proxy:gpt-5.5
@@ -99,9 +109,7 @@ providers:
api_key: sk-relay-...
```
-Here `proxy:gpt-5.5` and `openai:gpt-5.5` both use the OpenAI API but go to different endpoints with different keys; `openai:` keeps its own `api_key` / `api_base`, or Codex OAuth. A field left out of an entry falls back to `api_key` / `api_base` and `BUB__API_KEY` / `BUB__API_BASE`. An unknown `type` is rejected when settings load.
-
-OpenAI Codex OAuth does not need a separate request-format setting. After `bub login openai`, Bub detects the stored Codex OAuth token when the selected model uses the `openai:` provider and no custom API base is set.
+Here `proxy:gpt-5.5` and `openai:gpt-5.5` both use the OpenAI API but go to different endpoints with different keys; `openai:` keeps its own `api_key` / `api_base`. A field left out of an entry falls back to `api_key` / `api_base` and `BUB__API_KEY` / `BUB__API_BASE`. An unknown `type` is rejected when settings load.
## Spill sidecar — `SpillSettings`
@@ -173,11 +181,11 @@ See [Operate › Channels › Telegram](/docs/operate/channels/telegram/) for de
## Login — third-party env vars
-`bub login openai` reads one non-`BUB_*` env var:
+`bub login codex` reads one non-`BUB_*` env var:
| Env var | Default | Read by | Description |
| --- | --- | --- | --- |
-| `CODEX_HOME` | `~/.codex` | `bub login openai` (`src/bub/builtin/auth.py`) | Directory to store Codex OAuth `auth.json`. Overridden by `--codex-home`. |
+| `CODEX_HOME` | `~/.codex` | `bub login codex` (`src/bub/builtin/auth.py`) | Directory to store Codex OAuth `auth.json`. Overridden by `--codex-home`. |
## Plugin-specific settings
diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx
index eb3b0e8c..54eb23cd 100644
--- a/website/src/content/docs/docs/reference/types.mdx
+++ b/website/src/content/docs/docs/reference/types.mdx
@@ -6,7 +6,7 @@ description: Bub's subsystem-owned contracts for turns, models, streaming, hooks
Bub keeps contracts beside the subsystem that owns their behavior. Import a contract from its defining module; the top-level `bub` package intentionally exposes only the common framework entry points.
:::caution
-`bub.types` temporarily re-exports only `Envelope`, `State`, and `MessageHandler` for compatibility and emits a `DeprecationWarning` when imported. New code should use `bub.envelope.Envelope`, `bub.turn.TurnState`, and `bub.channels.contracts.MessageHandler` directly.
+The former `bub.types` compatibility module has been removed. Use `bub.envelope.Envelope`, `bub.turn.TurnState`, and `bub.channels.contracts.MessageHandler` directly.
:::
## Kernel vocabulary
@@ -31,7 +31,7 @@ type TurnState = dict[str, Any]
@dataclass(frozen=True)
class TurnResult:
session_id: str
- prompt: str | list[dict[str, Any]]
+ prompt: list[UserContent]
model_output: str
outbounds: list[Envelope] = field(default_factory=list)
state: TurnState = field(default_factory=dict)
@@ -67,6 +67,10 @@ from bub.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.
+Builtin `tool_call` events use Republic fields: `id`, `name`, `arguments`, and `metadata`. Usage dictionaries contain `input_tokens`, `output_tokens`, `reasoning_tokens`, `cached_tokens`, `cache_write_tokens`, and `total_tokens`.
+
+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.errors`
```python
@@ -82,16 +86,16 @@ Hook declarations and markers live in `bub.hooks`; agent-loop interception paylo
```python
from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl, hookspec
from bub.hooks.interception import (
- AgentHooks,
- LlmCallDecision,
- LlmCallRequest,
- LlmCallResult,
+ LLMCallDecision,
+ LLMCallRequest,
+ LLMCallResult,
+ PluginAgentHooks,
ToolCall,
ToolCallDecision,
ToolCallResult,
)
from bub.hooks.runtime import HookRuntime
-from bub.sidecars import TapeSidecar, sidecar_tape_name
+from bub.core.sidecars import TapeSidecar, sidecar_tape_name
```
Plugin authors normally need only `hookimpl` plus the payload types used by their hooks. `TapeSidecar` is the minimal named contract returned by `provide_tape_sidecar`; `sidecar_tape_name` resolves its sibling tape name. See the [Hook reference](/docs/reference/hooks/) for dispatch and fault-isolation semantics.
diff --git a/website/src/content/docs/docs/tutorials/fastapi.mdx b/website/src/content/docs/docs/tutorials/fastapi.mdx
new file mode 100644
index 00000000..e068ed67
--- /dev/null
+++ b/website/src/content/docs/docs/tutorials/fastapi.mdx
@@ -0,0 +1,127 @@
+---
+title: Serve a Bub agent with FastAPI
+description: Expose a bub.core 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
+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
+[Republic](https://github.com/bubbuild/republic); any Republic `provider:model` spec works.
+
+## Install
+
+```bash
+uv init bub-api && cd bub-api
+uv add bub fastapi uvicorn
+```
+
+## Write the app
+
+Save this as `app.py`:
+
+```python
+import asyncio
+from collections import defaultdict
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+import republic
+from fastapi import FastAPI, HTTPException, Request
+from pydantic import BaseModel
+
+from bub.core import Agent
+from bub.core.store import FileTapeStore
+from bub.core.tools import Tool
+
+
+async def lookup_order(order_id: str) -> dict[str, str]:
+ """Look up the status of an order."""
+ # Replace this demonstration response with your application's order service.
+ return {"order_id": order_id, "status": "shipped"}
+
+
+def create_agent() -> Agent:
+ return Agent(
+ store=FileTapeStore(Path(__file__).resolve().parent / "sessions"),
+ tools=[Tool.from_callable(lookup_order)],
+ system_prompt=[
+ "You are an order assistant. Reply directly to the user.",
+ "Use lookup_order to check order status before answering.",
+ ],
+ )
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # The key comes from REPUBLIC_OPENAI_API_KEY.
+ async with republic.get_model("openai:gpt-5-mini") as model:
+ app.state.agent = create_agent()
+ app.state.model = model
+ app.state.session_locks = defaultdict(asyncio.Lock)
+ yield
+
+
+app = FastAPI(lifespan=lifespan)
+
+
+class Task(BaseModel):
+ session_id: str
+ prompt: str
+
+
+@app.post("/tasks")
+async def run_task(task: Task, request: Request):
+ state = request.app.state
+ # Runs on one session's tape must not overlap; different sessions run concurrently.
+ async with state.session_locks[task.session_id]:
+ parts: list[str] = []
+ error: str | None = None
+ async with state.agent.run([task.prompt], model=state.model, tape=task.session_id) as stream:
+ async for event in stream:
+ if event.kind == "text":
+ parts.append(str(event.data.get("delta", "")))
+ elif event.kind == "error":
+ error = str(event.data.get("message", "Agent failed"))
+ if error is not None:
+ raise HTTPException(status_code=502, detail=error)
+ return {"session_id": task.session_id, "output": "".join(parts)}
+```
+
+The lifespan opens the Republic model once and closes it on shutdown. The agent keeps no per-run state, so one
+instance serves every request; each run works on a fork of the session's tape that merges back when the
+`async with agent.run(...)` block exits, even if the run fails.
+
+## Run it
+
+```bash
+REPUBLIC_OPENAI_API_KEY=... uv run uvicorn app:app
+```
+
+In another terminal, start a conversation and continue it with the same `session_id`:
+
+```bash
+curl -s localhost:8000/tasks -H 'content-type: application/json' \
+ -d '{"session_id": "customer-42", "prompt": "Where is order A123?"}'
+
+curl -s localhost:8000/tasks -H 'content-type: application/json' \
+ -d '{"session_id": "customer-42", "prompt": "And when did it ship?"}'
+```
+
+The second answer can refer to the first because both runs read the `customer-42` tape under `sessions/`.
+
+## Before deploying
+
+- `session_locks` grows with every session; evict idle locks in a long-running service.
+- The locks only serialize runs inside one process. Multiple worker processes sharing a store need coordination
+ across workers, such as a lock in your database; a file write lock does not serialize a run.
+- `FileTapeStore` suits one host. Use a database-backed store, as in
+ [Persist tapes in SQLite with SQLAlchemy](/docs/tutorials/tapestore-sqlalchemy/), when several hosts share
+ sessions.
+
+## Next steps
+
+- Add tools, system prompt sections, and event handlers: read the [Python SDK](/docs/build/sdk/) guide.
+- Trace requests end to end: read [Observe Bub with tapes, Phoenix, and Logfire](/docs/tutorials/observability/).
diff --git a/website/src/content/docs/docs/tutorials/index.mdx b/website/src/content/docs/docs/tutorials/index.mdx
index 0f8ba7f2..d06fdd4a 100644
--- a/website/src/content/docs/docs/tutorials/index.mdx
+++ b/website/src/content/docs/docs/tutorials/index.mdx
@@ -16,6 +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.
## Next steps
diff --git a/website/src/content/docs/docs/tutorials/observability.mdx b/website/src/content/docs/docs/tutorials/observability.mdx
index 21375da5..a89861e9 100644
--- a/website/src/content/docs/docs/tutorials/observability.mdx
+++ b/website/src/content/docs/docs/tutorials/observability.mdx
@@ -22,7 +22,7 @@ You need:
- The `trace` extra installed in Bub's activated virtual environment before starting Bub with Phoenix:
```bash
-uv pip install "bub[trace]"
+uv pip install "bub[app,trace]"
```
The `trace` extra includes the OpenTelemetry API, SDK, and HTTP/protobuf OTLP exporter. The Phoenix walkthrough below requires no Logfire installation or configuration.
@@ -144,7 +144,7 @@ Use both when debugging production behavior: start with `,tape.info` to understa
## Send trajectories to Logfire
-To send trajectories to Logfire, install the `logfire` extra, which includes `bub[trace]` and Logfire. With no explicit OTLP endpoint configured, Bub's CLI configures Logfire at startup; no tracing plugin is required.
+To send trajectories to Logfire, install the `logfire` extra, which includes `bub[app,trace]` and Logfire. With no explicit OTLP endpoint configured, Bub's CLI configures Logfire at startup; no tracing plugin is required.
When switching from Phoenix, clear both OTLP endpoint variables, authenticate with your Logfire project, then run Bub:
diff --git a/website/src/content/docs/zh-cn/docs/build/distribution.mdx b/website/src/content/docs/zh-cn/docs/build/distribution.mdx
index c0ef09e3..47b6e219 100644
--- a/website/src/content/docs/zh-cn/docs/build/distribution.mdx
+++ b/website/src/content/docs/zh-cn/docs/build/distribution.mdx
@@ -55,15 +55,15 @@ def main() -> None:
workspace.mkdir(parents=True, exist_ok=True)
os.chdir(workspace)
- from bub.__main__ import app
+ from bub.__main__ import main as bub_main
- app()
+ bub_main()
```
两点值得指出:
- 产品在导入 Bub 之前先解析出粘性工作区,然后 `chdir` 进去。[`BubFramework`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py) 在构造时把 `Path.cwd()` 快照为工作区,所以 `chdir` 必须先发生。
-- 之后产品把控制权交给 `bub.__main__.app` —— 与裸 `bub` 命令使用的是同一个 Typer 应用。每个贡献 `register_cli_commands` 的插件都会自动接入。
+- 之后产品把控制权交给 `bub.__main__.main` —— 与裸 `bub` 命令使用的是同一个入口,它用 `create_cli_app()` 构建 Typer 应用,并在缺少 `bub[app]` extra 时给出提示。每个贡献 `register_cli_commands` 的插件都会自动接入。
把 CLI 接入脚本入口:
diff --git a/website/src/content/docs/zh-cn/docs/build/hooks.mdx b/website/src/content/docs/zh-cn/docs/build/hooks.mdx
index 55c933cf..d0eac9e0 100644
--- a/website/src/content/docs/zh-cn/docs/build/hooks.mdx
+++ b/website/src/content/docs/zh-cn/docs/build/hooks.mdx
@@ -249,12 +249,12 @@ agent loop 暴露四个拦截钩子(完整契约见 [参考 › 钩子](/zh-cn/d
from dataclasses import replace
from bub import hookimpl
-from bub.hooks.interception import LlmCallDecision, LlmCallRequest
+from bub.hooks.interception import LLMCallDecision, LLMCallRequest
class RouteAndBudget:
@hookimpl
- def before_llm_call(self, request: LlmCallRequest, state):
+ def before_llm_call(self, request: LLMCallRequest, state):
# `state` persists for the session (it is not reset per turn), so
# this is a session-wide budget. For a per-turn budget, reset the
# counter yourself at a turn boundary (e.g. in build_prompt).
@@ -262,7 +262,7 @@ class RouteAndBudget:
state["session_llm_calls"] = calls + 1
if calls >= 10:
# Skip the provider entirely; this text becomes the final output.
- return LlmCallDecision.finish("LLM call budget exhausted for this session.")
+ return LLMCallDecision.finish("LLM call budget exhausted for this session.")
# Rewrites are honored end-to-end: the provider receives this model
# and max_tokens, and the tape records the effective model.
return replace(request, model="openai:gpt-4o-mini", max_tokens=512)
diff --git a/website/src/content/docs/zh-cn/docs/build/plugins.mdx b/website/src/content/docs/zh-cn/docs/build/plugins.mdx
index 9ee35879..17be9e72 100644
--- a/website/src/content/docs/zh-cn/docs/build/plugins.mdx
+++ b/website/src/content/docs/zh-cn/docs/build/plugins.mdx
@@ -41,7 +41,7 @@ Bub 通过 `bub` 组的 Python 入口点发现插件。在 `pyproject.toml` 里
[project]
name = "bub-myplugin"
version = "0.1.0"
-dependencies = ["bub>=0.3"]
+dependencies = ["bub[app]>=0.3"]
[project.entry-points."bub"]
myplugin = "bub_myplugin.plugin"
@@ -127,6 +127,8 @@ class MyPlugin:
visual-base 的 [`EyeImpl`](https://github.com/oilbeater/visual-base/blob/main/src/bub_eye/plugin.py) 正是因为这个原因采用可调用形态:它捕获框架,从而 `provide_channels` 时能读到 CLI 处理后的工作区。
+`framework.events` 通过 `bub.application` 中的 `CommandChanged`、`WorkChanged` 和 `PromptChanged` 发布命令、通道工作和提示的变化。使用 `framework.events.on(EventType, handler)` 订阅,使用 `framework.events.off(EventType, handler)` 取消订阅。Agent 循环事件仍由 `agent.events` 发布。
+
:::note
内置插件最先注册,入口点插件随后注册。在 [`HookRuntime`](https://github.com/bubbuild/bub/blob/main/src/bub/hooks/runtime.py) 中,注册越晚优先级越高 —— 因此你的 `firstresult` 钩子会覆盖内置实现。
:::
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 6a8f062b..8b3fdbb9 100644
--- a/website/src/content/docs/zh-cn/docs/build/sdk.md
+++ b/website/src/content/docs/zh-cn/docs/build/sdk.md
@@ -1,88 +1,73 @@
---
title: Python SDK
-description: 在 Python 应用中嵌入 Bub,自定义工具、提示词、skills 和会话存储。
+description: 在 Python 应用中嵌入 Bub 的 agent loop,使用你自己的模型、工具、提示词和会话存储。
sidebar:
order: 0
---
-使用 `bub.builtin.Agent` 在应用内运行 builtin agent loop。
-`BubFramework` 提供配置和 hooks,`Agent` 使用指定的工具与存储执行任务。
+`bub.core` 是 Bub 的 agent 内核。它的 `Agent` 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答),
+所用的组件都由你提供:工具、system prompt section 和 tape store,以及传给 agent 或每次运行的 Republic 模型。
+它不读取 Bub 的 settings,也不加载插件,因此可以嵌入任何应用。
-## 安装与配置
+Bub 自身的运行时也构建在这个内核之上:`bub.builtin.BubAgent` 在其上加入 settings、插件钩子、skills、命令和 steering。
-在 Python 3.12+ 项目中安装:
+## 安装
+
+在 Python 3.12+ 项目中安装 Bub:
```bash
uv add bub
```
-通过 `BUB_MODEL`、`BUB_API_KEY` 和可选的 `BUB_API_BASE`,或配置文件设置模型与凭据。
-详见[配置参考](/zh-cn/docs/reference/settings/)。本文描述当前源码接口;
-请使用包含这些接口的版本,或直接安装当前源码。
+这只会安装内核。如果还需要 `bub` 命令、channel 或插件,请安装 `app` extra(`bub[app]`)。
+
+下文描述的是当前源码中的 API;请使用包含这些接口的发布版本,或安装当前源码。
## 创建 Agent
-将下面的示例保存为 `sdk_example.py`:
+将以下内容保存为 `sdk_example.py`:
```python
import asyncio
from pathlib import Path
-from bub import BubFramework, hookimpl
-from bub.builtin import Agent
-from bub.builtin.hook_impl import BuiltinImpl
-from bub.builtin.tools import skill_describe
-from bub.store import FileTapeStore
-from bub.tools import Tool
-
-
-class SystemPrompts:
- def __init__(self, prompts: list[str]) -> None:
- self.prompts = tuple(prompts)
+import republic
- @hookimpl
- def system_prompt(self, prompt, state) -> str:
- return "\n\n".join(self.prompts)
+from bub.core import Agent
+from bub.core.store import FileTapeStore
+from bub.core.tools import Tool
async def lookup_order(order_id: str) -> dict[str, str]:
"""Look up the status of an order."""
- # Replace this demonstration response with your application's order service.
+ # 将这个演示返回值替换为你应用中的订单服务。
return {"order_id": order_id, "status": "shipped"}
-def create_agent() -> tuple[BubFramework, Agent]:
+def create_agent() -> Agent:
root = Path(__file__).resolve().parent
- framework = BubFramework(config_file=root / "config.yml")
- framework.workspace = root
- framework.plugin_manager.register(
- SystemPrompts([
+ return Agent(
+ store=FileTapeStore(root / "sessions"),
+ tools=[Tool.from_callable(lookup_order)],
+ system_prompt=[
"You are an order assistant. Reply directly to the user.",
"Use lookup_order to check order status before answering.",
- ]),
- name="system_prompts",
+ ],
)
- agent = Agent(
- framework,
- tools=[Tool.from_callable(lookup_order), skill_describe],
- skill_dirs=[root / "skills"],
- tape_store=FileTapeStore(root / "sessions"),
- )
- return framework, agent
async def main() -> None:
- framework, agent = create_agent()
- async with framework.running():
- stream = await agent.run_stream(
- session_id="customer-42",
- prompt="Where is order A123?",
- )
- async for event in stream:
- if event.kind == "text":
- print(event.data.get("delta", ""), end="", flush=True)
- elif event.kind == "error":
- raise RuntimeError(str(event.data.get("message", "Agent failed")))
+ agent = create_agent()
+ # 任意 Republic "provider:model"。API key 从 REPUBLIC_OPENAI_API_KEY 读取。
+ async with republic.get_model("openai:gpt-5-mini") as model:
+ async with agent.run(
+ "Where is order A123?", model=model, options={"max_tokens": 4096}, tape="customer-42"
+ ) as stream:
+ async for event in stream:
+ if event.kind == "text":
+ print(event.data.get("delta", ""), end="", flush=True)
+ elif event.kind == "error":
+ raise RuntimeError(str(event.data.get("message", "Agent failed")))
print()
@@ -90,168 +75,281 @@ if __name__ == "__main__":
asyncio.run(main())
```
-配置好模型后,运行 `uv run python sdk_example.py`。
-`config.yml` 不存在时也可以运行,环境变量配置仍然生效。
-示例中的订单查询返回演示数据,接入应用时请替换为实际服务。
+用 `REPUBLIC_OPENAI_API_KEY=... uv run python sdk_example.py` 运行。使用同一个 `tape` 再次运行,会继续保存在 `sessions/` 下的对话。
+
+| `Agent(...)` 参数 | 行为 |
+| ----------------- | --------------------------------------------------------------------------------- |
+| `model` | 未传入模型的运行所用的默认模型;见 [模型](#模型)。默认 `None`,即每次运行都需传入。 |
+| `store` | `TapeStore` 或 `AsyncTapeStore`。默认 `InMemoryTapeStore()`。 |
+| `tools` | 模型可调用的 `Tool` 对象,按名字保存在 `agent.tools` 中;也可传入由 provider 自行执行的 Republic 内置工具(如 `republic.tools.WebSearch()`),保存在 `agent.builtin_tools` 中,随每次模型调用发送。 |
+| `system_prompt` | 一个或一组 system prompt section;见 [System prompt](#system-prompt)。 |
+| `context` | 决定哪些 tape 条目进入模型上下文的 `TapeContext`。默认 `default_tape_context()`。 |
+| `sidecars` | 挂载到每条 tape 上的 `TapeSidecar` 能力。 |
+| `max_steps` | 一次运行中最多的模型调用次数。 |
+| `max_tool_concurrency` | 同一次模型回复中可同时执行的工具调用数上限。默认 `None` 即全部并发;`1` 则按顺序逐个执行。 |
+| `name` | 在 `invoke_agent` trace 中上报的 agent 名称。默认 `"agent"`。 |
+| `command_prefix` | 标记命令 prompt 的前缀,例如 `","`;见 [命令](#命令)。默认 `None`,即不启用命令。 |
+
+## 模型
+
+每次运行调用传入的模型,未传入时调用 agent 的 `model`,因此同一个 agent 可以用不同模型服务不同的运行;两者都没有时,
+需要调用模型的运行会抛出 `ValueError`。传入 `republic.get_model()` 得到的 Republic
+chat model 即可;它的生命周期归你管理,请在使用它的运行外层用 `async with` 打开(或自行关闭)。凭据和端点属于 Republic
+的客户端参数:
+
+```python
+model = republic.get_model("openai:gpt-5-mini", api_key="...", api_base="https://example.test/v1")
+async with agent.run(prompt, model=model, options={"max_tokens": 4096, "temperature": 0.2}, tape="t") as stream:
+ ...
+
+agent = Agent(model=model) # 未传入模型的运行调用它
+async with agent.run(prompt, tape="t") as stream:
+ ...
+```
+
+`options` 是随本次运行每次模型调用发送的 `republic.ChatOptions`;循环会加入本步的工具。`LLMCallStarted`
+处理器可以把一次调用重定向到其他 `provider:model`:同一 provider 的模型复用运行模型的客户端,其他 provider
+使用从 Republic 环境变量配置的客户端。
+
+如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 `bub.core.ModelClient`
+的对象:提供 `spec`(默认的 `provider:model`)、默认 `max_tokens`(或 `None`),以及
+`open_stream(messages, spec=..., options=...)`——一个产出 provider 名称和 `republic.Stream` 的异步上下文管理器。
+Bub 自己的 `bub.builtin.model.BubModel` 就是这样的客户端。
-`SystemPrompts` 覆盖 builtin 的系统提示方法,并继承其他 builtin hooks。
-只注册这一个实例即可;这个示例中不要再调用 `load_builtin_hooks()` 或 `load_hooks()`。
-系统提示 hooks 会累加,单独新增一个 prompt hook 会保留默认 builtin 的 channel 指令和工作区 `AGENTS.md` 内容。
+## 工具
-如需 Bub 标准默认行为,创建 framework 后调用 `framework.load_builtin_hooks()`。
-需要自动加载 `bub` entry-point group 下已安装的插件时,使用 `load_hooks()`。
+`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core.tools` 中的
+`@tool` 装饰器生成同样的 `Tool`,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。
-## 工具、Skills 与会话
+以 `context=True` 创建的工具会收到 `ToolContext`,其中包含本次运行的 `tape`、turn `state`、`run_id` 和事件总线:
+
+```python
+from bub.core.tools import ToolContext
-| 参数 | 行为 |
-| ------------------------ | ------------------------------------------------------------------------------------------------ |
-| `tools=[...]` | 接受 `Tool` 对象。`Tool.from_callable()` 从类型注解生成 schema,从 docstring 获取描述。 |
-| `tools=None` | 构造时复制全局工具注册表。`tools=[]` 禁用工具。 |
-| `skill_dirs=[Path(...)]` | 仅搜索这些目录,按顺序处理,同名 skill 使用第一个。 |
-| `skill_dirs=None` | 搜索项目、用户和 builtin 目录。`skill_dirs=[]` 禁用发现。 |
-| `tape_store=...` | 使用传入的 `TapeStore` 或 `AsyncTapeStore`。`FileTapeStore(path)` 将会话 tape 持久化到指定目录。 |
-| `tape_store=None` | 使用 framework 当前的 store;没有活动 store 时使用实例独立的内存存储。 |
-| `command_prefix="::"` | 覆盖当前 Agent 的命令前缀。`None` 使用 `BUB_COMMAND_PREFIX` / YAML `command_prefix`,默认 `,`。 |
-与 `@tool` 不同,`Tool.from_callable()` 不会把工具注册到全局表。
-模型需要按需读取 skill 正文时,将 `skill_describe` 加入工具集合。
-Skills 提供指令,不会自动授予 shell 或文件系统工具。
+def remember(note: str, *, context: ToolContext) -> str:
+ """Remember a note for this conversation."""
+ context.state.setdefault("notes", []).append(note)
+ return "noted"
-每个 skill 单独放在一个目录中:
-```text
-skills/
-└── order-policy/
- └── SKILL.md
+agent.tools["remember"] = Tool.from_callable(remember, context=True)
```
-```markdown
----
-name: order-policy
-description: Rules for answering order-status questions.
----
-Check the order service before giving a delivery status.
+工具出错时,错误会作为工具结果返回给模型,而不会结束运行。名字中带 `.` 的工具会以 `_` 替换后提供给模型
+(例如 `fs.read` 变为 `fs_read`)。
+
+## System prompt
+
+system prompt 是有序的 section 列表,每个模型步骤渲染一次,以空行连接。section 可以是文本,也可以是接收
+`PromptContext` 并返回文本的同步或异步函数;返回空串或 `None` 时跳过该 section。`agent.system_prompt.add(section)`
+追加一个 section 并返回它,因此也可以用作装饰器;`agent.system_prompt.remove(section)` 将其移除。
+
+```python
+from bub.core import PromptContext
+
+
+@agent.system_prompt.add
+def tool_list(context: PromptContext) -> str:
+ names = ", ".join(tool.name for tool in context.tools)
+ return f"Tools available in this step: {names}."
+
+
+@agent.system_prompt.add
+async def customer(context: PromptContext) -> str | None:
+ if (user := context.state.get("user")) is None:
+ return None
+ return f"You are talking to {user}."
```
-在相同 workspace 和 store 中复用 `session_id` 可以继续对话,新对话使用新 ID。
-以 `temp/` 开头的会话运行在 fork 中,不会合并回父 tape。
-归档和 builtin sidecar 的路径仍由 Bub 配置决定,独立于 `FileTapeStore` 的目录。
+context 包含本步的 `prompt`(工具调用后继续时为空)、本步模型可见的 `tools`、本步 `model` 的 `provider:model`、
+本次运行的 `tape` 与 `state`,以及 `agent`(并提供 `available_tools` 快捷属性)。
+
+## 会话与 tape
+
+每次运行使用 agent store 中以 `tape=` 命名的 tape。复用同一个名字即可继续对话;新对话使用新名字。运行写入的是该 tape 的
+fork,运行的流关闭时合并回去;传入 `merge_back=False` 则丢弃。
-每轮调用可以缩小实例能力范围,并覆盖已保存的模型设置:
+`state=` 是本次运行可修改的 turn state:一个由 system prompt section、工具和事件处理器共享的字典。agent 不会持久化它;
+需要保留的内容请放在你的应用中或写到 tape 上。
```python
-stream = await agent.run_stream(
- session_id="customer-42",
- prompt="Use $order-policy to check order A123.",
- allowed_tools=["lookup_order", "skill"],
- allowed_skills=["order-policy"],
- model="provider:model-id", # Replace with your configured provider/model.
- reasoning_effort="high", # Use a value supported by that model.
-)
+async with agent.run("Any news on A123?", model=model, tape="customer-42", state={"user": "Ada"}) as stream:
+ ...
+
+tape = agent.tape("customer-42")
+messages = await tape.read_messages() # 以 Republic 消息表示的对话。
+await tape.handoff(name="summary", state={"summary": "Ada asked about order A123."})
```
-必须消费返回的 stream。`allowed_tools` 支持工具原名和模型别名,例如 `fs.read` 与 `fs_read`。
-显式 `model` 和 `reasoning_effort` 优先于已保存设置;示例中的模型标识需要替换成实际模型,
-reasoning effort 也需使用该模型支持的值。
-通常省略 `state`,由 framework 自动加载;传入字典会跳过加载,并在执行中修改该字典。
+`agent.tape(name)` 返回某个对话的 tape,供运行之外使用;它直接写入 store。默认情况下模型上下文是该 tape
+最近一个 anchor 之后记录的对话,因此 `handoff()` 会开始一个更短的上下文。如果某次调用因上下文过长而失败,运行会自动
+handoff 一次并重试该 prompt。
-字符串 prompt 去掉首尾空白后,以 Agent 命令前缀(默认 `,`)开头时会被解释为命令。
-前缀不能为空或包含空白字符。执行时只去掉一次前缀;只有前缀时抛出 `ValueError`。
-覆盖前缀后,旧前缀开头的文本按普通输入处理;多模态 prompt 列表不会被识别为命令。
-已知命令直接调用实例工具,未知命令交给实例的 `bash` 工具执行(需有该工具)。
-直接命令不经过模型和工具拦截 hooks,也不受 agent loop 的 `allowed_tools` 过滤限制。
+## 命令
-## 流式事件与生命周期
+设置了 `command_prefix` 后,以该前缀开头的文本 prompt 会作为命令执行,而不走 agent loop,也不会调用模型。`name args...`
+调用名为 `name` 的工具,先传位置参数(按顺序对应工具的参数),再传 `key=value` 参数;其他命令在 agent 有 `bash` 工具时交给它执行。输出作为
+本次运行的 text 和 `final` 事件返回;同时发出 `CommandFinished` 事件报告这次命令,tape 会将其记录为 `command` 事件。
-先 `await run_stream()`,再迭代其返回值。事件包括 `text`、`reasoning`、`tool_call`、
-`tool_result`、`usage`、`error` 和 `final`。
-`final` 表示一次模型步骤结束,整个 turn 需要等到迭代完成。
-调用方应检查 error 事件并处理迭代抛出的异常。stream 对象还提供 `error` 和 `usage` 属性。
+```python
+agent = Agent(tools=[Tool.from_callable(lookup_order)], command_prefix=",")
-如果要提前退出已经开始的迭代,显式关闭迭代器:
+async with agent.run(",lookup_order order_id=A123", model=model, tape="customer-42") as stream:
+ async for event in stream:
+ print(event.kind, event.data) # 依次是 text 和 final,内容为工具渲染后的结果。
+```
+
+首尾空白会被忽略,且只去掉一个前缀;只有前缀时会抛出 `ValueError`。前缀必须非空且不含空白。带媒体的 prompt 不会作为命令。
+命令调用工具时和模型调用工具一样发出 `ToolCallStarted` 和 `ToolCallFinished`,因此工具处理器同样作用于它们;其
+`ToolContext` 的 `command` 为真。
+`agent.run_command(tape, line)` 可以在已打开的 tape 上直接执行命令。
+
+## 事件
+
+循环通过 `bub.core.events` 中的事件报告进度;注册处理器即可观察或干预它。
```python
-from contextlib import aclosing
+from bub.core.events import ToolCallFinished, ToolCallStarted, StepFinished, ToolCallDecision
+
+
+@agent.events.on(ToolCallStarted)
+def guard(event: ToolCallStarted) -> None:
+ if event.call.tool == "lookup_order" and not event.call.arguments.get("order_id"):
+ event.decision = ToolCallDecision.deny("order_id is required")
+
+
+@agent.events.on(ToolCallFinished)
+async def audit(event: ToolCallFinished) -> None:
+ print("tool", event.call.tool, event.result.duration_ms, "ms")
-stream = await agent.run_stream(session_id="customer-42", prompt="Check order A123")
-async with aclosing(stream.__aiter__()) as events:
- async for event in events:
- print(event.kind, event.data)
+
+agent.events.on(StepFinished, lambda event: print("step", event.step, event.status))
```
-所有 turn 完成之前,保持 `framework.running()` 开启。
-在进入生命周期前访问 Agent 缓存的 `tape`,可能使其绑定到内存 fallback。
-显式注入的 store 由应用管理生命周期。
-`Agent` 本身不是异步上下文管理器,当前也没有 `run()` 便捷方法。
+| 事件 | 时机 | 处理器可以 |
+| ------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `RunStarted` | 一次运行开始 | 观察模型 spec、prompt 和运行 metadata |
+| `RunFinished` | 运行的 stream 关闭 | 观察状态(`ok`、`error` 或 `cancelled`)、步数、耗时和错误 |
+| `StepStarted` / `StepFinished` | 一个模型步骤开始 / 以某个状态结束 | 观察 |
+| `LLMCallStarted` | 每次模型调用之前 | 替换 `event.request`(模型、消息、`max_tokens`),或设置 `event.decision = LLMCallDecision.finish(text)` 跳过调用 |
+| `LLMCallFinished` | 每次模型调用完成或失败之后 | 观察文本、工具调用、用量和错误 |
+| `ToolCallStarted` | 每次工具调用之前 | 替换 `event.call` 以修改参数,或设置 `ToolCallDecision.replace(result)` / `deny(message)` |
+| `ToolCallFinished` | 每次工具调用之后,包括被拒绝和被替换的调用 | 赋值 `event.result.result` 以改变模型看到的结果 |
+| `ChatFinished` | 一个步骤的对话已可记录 | 观察 |
+| `CommandFinished` | 一个命令 prompt 执行完毕(见 [命令](#命令)) | 观察命令、状态和输出 |
+
+`agent.events.on(EventType, handler)` 注册同步或异步处理器并返回它;`@agent.events.on(EventType)` 以装饰器形式完成同样的注册,
+`agent.events.off(EventType, handler)` 将其移除。为 `AgentEvent` 注册的处理器接收所有事件。处理器按注册顺序执行;
+在 `Before*` 事件上设置 decision 会停止向后续处理器分发。处理器抛出的异常会传给本次运行。
-## 接入 FastAPI
+每个事件都带有本次运行的 `state`;循环事件还带有它的 `tape`。记录本身也是一个处理器:最先注册的 `TapeRecorder`
+把对话和 `loop.*` 事件写入本次运行的 tape。
-运行 `uv add fastapi uvicorn` 安装服务依赖,然后在 `sdk_example.py` 旁保存 `app.py`:
+### 自定义事件
+
+可以定义自己的事件,让工具、处理器和应用通过同一条事件总线协作。事件是继承 `AgentEvent` 的 keyword-only dataclass
+(继承 `TapeEvent` 则还会携带本次运行的 `tape`)。用 `await events.emit(event)` 发出事件:它会把事件分发给为该类型或其
+父类型注册的处理器,并返回该事件。在工具中,本次运行的事件总线是 `context.events`:
```python
-import asyncio
-from contextlib import asynccontextmanager
+from dataclasses import dataclass
+
+from bub.core.events import AgentEvent
+from bub.core.tools import ToolContext
+
+
+@dataclass(kw_only=True)
+class OrderLookedUp(AgentEvent):
+ order_id: str
+ status: str
-from fastapi import FastAPI, HTTPException, Request
-from pydantic import BaseModel
-from sdk_example import create_agent
+@dataclass(kw_only=True)
+class RefundRequested(AgentEvent):
+ order_id: str
+ approved: bool | None = None
+ @property
+ def stopped(self) -> bool:
+ # 一旦有处理器做出决定,后续处理器就不再执行。
+ return self.approved is not None
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- framework, agent = create_agent()
- async with framework.running():
- app.state.agent = agent
- app.state.turn_lock = asyncio.Lock()
- yield
+async def lookup_order(order_id: str, *, context: ToolContext) -> dict[str, str]:
+ """Look up the status of an order and whether it can be refunded."""
+ result = {"order_id": order_id, "status": "shipped"}
+ if context.events is not None: # 工具在 agent 之外运行时为 None。
+ await context.events.emit(OrderLookedUp(state=context.state, **result))
+ request = await context.events.emit(RefundRequested(state=context.state, order_id=order_id))
+ result["refundable"] = str(bool(request.approved))
+ return result
-app = FastAPI(lifespan=lifespan)
+agent.tools["lookup_order"] = Tool.from_callable(lookup_order, context=True)
-class Task(BaseModel):
- session_id: str
- prompt: str
+@agent.events.on(OrderLookedUp)
+async def notify(event: OrderLookedUp) -> None:
+ print("order", event.order_id, "is", event.status, "for", event.state.get("user"))
-@app.post("/tasks")
-async def run_task(task: Task, request: Request):
- async with request.app.state.turn_lock:
- stream = await request.app.state.agent.run_stream(
- session_id=task.session_id,
- prompt=task.prompt,
- )
- parts: list[str] = []
- error: str | None = None
- async for event in stream:
- if event.kind == "text":
- parts.append(str(event.data.get("delta", "")))
- elif event.kind == "error":
- error = str(event.data.get("message", "Agent failed"))
- if error is not None:
- raise HTTPException(status_code=502, detail=error)
- return {"session_id": task.session_id, "output": "".join(parts)}
+
+@agent.events.on(RefundRequested)
+def approve(event: RefundRequested) -> None:
+ event.approved = event.state.get("user") == "Ada"
```
-使用 `uv run uvicorn app:app` 启动。这个示例在单进程内串行执行所有 turn。
-需要不同会话并行时,可以由应用维护带清理机制的按会话锁。
-多个 worker 共享 store 时需要跨进程协调,文件写锁并不能保证整个 turn 串行执行。
+每个事件都带有发出它的那次运行的 `state`,因此请传入 `context.state`(在其他处理器中则传 `event.state`)。处理器可以修改
+可变事件,发出方从返回的事件中读取结果;覆盖 `stopped` 可以在某个处理器做出决定后停止分发。应用也可以直接用
+`await agent.events.emit(...)` 发出事件。自定义事件不会写入 tape;需要记录时,请在处理器中自行追加,例如在工具里调用
+`await context.tape.append_event("order.looked_up", {...})`,或使用 `TapeEvent` 并在处理器中调用 `event.tape.append_event(...)`。
+
+## 流式事件与生命周期
+
+`agent.run()` 是一个异步上下文管理器:进入时开始运行,产出的流在迭代时驱动循环。事件包括 `text`、`reasoning`、`tool_call`、
+`tool_result`、`usage`、`error` 和 `final`。`final` 事件结束的是一个模型步骤;流被消费完时整个运行结束。请检查 error
+事件并处理迭代中抛出的异常。流对象还提供 `error` 和 `usage`。
+
+离开 `async with` 块时会关闭流,提前 `break` 或抛出异常时也一样:模型流停止、tape fork 合并回去、trace span 结束。
+
+```python
+async with agent.run("Check order A123", model=model, tape="customer-42") as stream:
+ async for event in stream:
+ if event.kind == "tool_call":
+ break # 运行在这里停止,已记录的内容会保留。
+```
-## Agent 与完整消息管线
+如果要把流交给其他代码(例如稍后才消费它的框架),用相同参数调用 `await agent.start(...)`。它返回流但不负责关闭;
+消费方必须把它消费完或调用 `aclose()`。
-对于普通 prompt,`Agent.run_stream()` 会运行 builtin loop,以及模型和工具拦截 hooks。
-它不会调用 `build_prompt`、`save_state`、outbound 渲染或 channel 分发,
-也不会选择插件替换的 `run_model` 实现。输出由应用消费。
+空 prompt 会直接得到 `error: empty prompt`,不会打开 tape。配置了 tracing 时,运行会记录为 `invoke_agent {name}` span;
+见 [Observability](/zh-cn/docs/tutorials/observability/)。
-需要完整消息管线或模型执行插件时,使用 `BubFramework.process_inbound()`。
-它返回包含 `model_output`、`state` 和 `outbounds` 的 `TurnResult`;
-`stream_output=True` 会通过绑定的 channel router 路由流式输出,但最终仍返回完整结果。
-扩展点详见 [Hooks](/zh-cn/docs/build/hooks/)。
+如果要在自己管理的 tape 上驱动循环,用 `agent.session(name, state=...)` 打开它,再调用
+`agent.run_on(tape, prompt, model=model)`;只跑一个模型步骤时用 `agent.run_once(prompt, model=model, tape=tape)`。
+两者都接受 `options`。
-自定义 channel 必须在把命令交给 channel manager 前设置 `ChannelMessage.kind="command"`,
-才能跳过缓冲和活跃窗口过滤。缓冲层不再根据开头的逗号推断命令。
-使用 builtin Agent 时,消息内容仍需保留配置的前缀,供 Agent 执行命令。
+## 定制循环
+
+继承 `Agent` 可以改变每一步的准备方式。每个方法都会收到本次运行的 tape,其 `context.state` 就是 turn state:
+
+| 方法 | 默认行为 | 用途 |
+| -------------------------------------- | -------- | ---------------------------------- |
+| `prepare_tools(tape, prompt)` | 全部工具 | 选择下一步模型可见的工具。 |
+| `pending_messages(tape)` | 无 | 返回运行期间到达的用户消息。 |
+| `has_pending_messages(tape)` | `False` | 有消息等待时让运行再继续一步。 |
+| `continue_prompt(tape, prompt, state)` | `None` | 在工具调用后返回下一步的用户消息。 |
+
+```python
+from bub.core.tape import Tape
+
+
+class TriageAgent(Agent):
+ async def prepare_tools(self, tape: Tape, prompt):
+ tools = await super().prepare_tools(tape, prompt)
+ if tape.context.state.get("read_only"):
+ tools = [tool for tool in tools if tool.name == "lookup_order"]
+ return tools
+```
-配置加载仍是进程级的,应在应用启动时统一配置。
-工具、skills 和 store 按实例隔离,不代表配置文件和环境变量也按实例隔离。
+要通过 HTTP 提供 agent 服务,请参阅 [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/)。
diff --git a/website/src/content/docs/zh-cn/docs/build/tools.mdx b/website/src/content/docs/zh-cn/docs/build/tools.mdx
index d13cb53e..fa009623 100644
--- a/website/src/content/docs/zh-cn/docs/build/tools.mdx
+++ b/website/src/content/docs/zh-cn/docs/build/tools.mdx
@@ -14,7 +14,7 @@ sidebar:
## 定义工具
-用 `@tool` 装饰任意函数。装饰器会构造一个 [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py) 对象,套上计时日志,并写入中央 `REGISTRY`:
+用 `@tool` 装饰任意函数。装饰器会构造一个 [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/core/tools.py) 对象,套上计时日志,并写入中央 `REGISTRY`:
```python
from bub import tool
@@ -59,6 +59,8 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个
- 其他允许的工具在 `run_code` 中都是异步函数,名称使用面向模型的形式(`tape.info` 对应 `await tools.tape_info()`)。支持顶层 `await`,可以用 `asyncio.gather` 并发调用。调用只接受关键字参数,照常经过 `before_tool_call`/`after_tool_call` hook,返回结构化结果,失败时抛出异常。
- Bub 会在 `~/.bub/codemode/` 下为允许的 `auto` 和 `code` 工具生成一个 Python stub 文件,每个工具对应一个函数,包含参数类型、返回类型和 docstring,并把文件路径写入 system prompt。只要工具集合不变,同一个 session 中这个路径保持不变。
- `run_code(code, timeout_seconds=120)` 返回代码写到 stdout 的内容。未捕获的异常会变成工具错误,错误详情中包含已打印的输出和 traceback。
+- 代码里还有一个 `republic` 模块(已作为全局变量预置,也可以 `import`),其用法写在 stub 文件末尾。其中有媒体类 `republic.Image`、`Audio` 和 `Video`。`republic.get_model(spec=None)` 返回对话模型——不传 spec 时就是当前会话使用的模型——支持 `chat()` 和 `stream()`(`async with model.stream(...) as stream: async for event in stream`,事件类型在 `republic.events` 中);`republic.get_embedding_model(spec)` 和 `republic.get_decision_model(spec)` 分别返回 embedding 模型和决策模型。它们都是代理:请求由 Bub 使用自己的 provider 配置和凭证发出,凭证不会进入代码。
+- 仅代码可用的工具 `tools.attach_image(image=...)`、`tools.attach_audio(audio=...)` 和 `tools.attach_video(video=...)` 会把媒体附加到这次 `run_code` 的结果里,模型因此可以看到代码生成的图表或截图。除了 `republic` 媒体对象,它们也接受执行环境内的文件路径(相对路径基于其 workspace)、http(s)/gs URL 或 data URL,并自行加载,文件通过执行环境的 `read_bytes` 读取。带媒体的结果以 `{"type": "tool_content", "content": [...]}` 记录(可用 `bub.core.tools.content_result` 构造),并作为多模态工具消息发给模型;spill 只截断其中的文本。
如果 `run_code` 不在允许范围内(例如被 `allowed_tools` 限制的 subagent),该 turn 会退回直接调用工具的方式。
@@ -76,6 +78,30 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个
`run_code` 会把代码和一个 `call_tool` 回调一起交给当前 session [执行环境](#在执行环境中运行工具)的 `run_code` 执行。工具调用始终经这个回调在宿主上执行,所以 hook 仍能看到每一次调用。超过 `timeout_seconds` 时会取消执行环境里的执行。builtin 的 `LocalEnvironment` 每次调用都会启动一个新的 Python 进程(使用 `sys.executable`,工作目录为 workspace),工具调用以 JSON 行的形式经进程的 stdin/stdout 转发,因此参数和结果都必须能 JSON 序列化。代码执行完、出错或超时后,它会杀掉该进程及其启动的所有子进程。这个进程直接跑在宿主上,权限与 `bash` 相同,因此只应在允许使用 `bash` 的场景下开启 code mode。stub 中的结果类型来自工具函数的返回注解(手动构造的工具则来自 `Tool.output_schema`)。
+#### 在自己的 agent 中使用 code mode
+
+Code mode 属于内核(`bub.core.codemode`),基于 `bub.core.Agent` 构建的 agent 不安装 `app` extra 也能使用。`CodeMode` 持有可在代码中调用的工具,并提供 `run_code` 工具和一个 system prompt 片段:
+
+```python
+from bub.core import Agent
+from bub.core.codemode import CodeMode
+from bub.environment import LocalEnvironment
+
+code_mode = CodeMode(
+ [search, fetch], # 在 run_code 中以 tools.(...) 调用的工具
+ environment=LocalEnvironment("/work"), # 代码运行的位置,默认是当前目录
+ models="openai:gpt-5", # 代码里 republic.get_model() 对应的模型
+)
+agent = Agent(model=model, tools=[code_mode.run_code], system_prompt=["You are ...", code_mode.prompt])
+```
+
+- `tools` 也可以是以 turn state 为参数的函数,返回本次运行的工具,返回 `None` 表示关闭 code mode;`environment` 同样可以是 state 的函数。
+- `models` 是默认的 `provider:model` spec,或一个 resolver `(spec, context) -> (republic_spec, client_kwargs)`,凭据始终留在宿主侧。不传时代码必须显式给出 spec,客户端从环境变量读取凭据。
+- stub 默认直接放进 system prompt;传入 `stub_dir=` 则写入文件并在 prompt 中引用路径,适合能读文件的 agent。
+- `media_tools=True`(默认)会额外提供 `attach_image`、`attach_audio` 和 `attach_video`。
+
+代码发起的调用同样经过 agent 的 `ToolCallStarted`/`ToolCallFinished` 事件,且 `context.code_mode` 为真,因此用 `agent.events.on` 注册的处理器都能看到。Bub 自己的 agent 使用的也是这个类(`bub.builtin.codemode.CODE_MODE`),只是接入了 session 设置、执行环境和模型配置。
+
### 按需加载工具
给工具标记 `deferred=True`(可用于 `@tool`、`Tool.from_callable`,或 `dataclasses.replace(tool, deferred=True)`),它的定义就不会进入模型请求,直到被加载。未标记的工具直接加载。
@@ -91,23 +117,23 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个
`bash`、`bash.output`、`bash.kill` 和 `fs.*` 不直接操作宿主机,而是在当前 session 的 `Environment`(见 [`bub.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/environment.py))里启动进程、读写文本文件,`run_code` 的代码也在这里执行。工具本身的行为(后台 shell、超时、渲染、hook)都留在宿主上,所以一个执行环境只需要实现几个操作:
- `spawn(command, *, cwd=None, env=None)` 返回一个 `Process`:传字符串时经 shell 执行,传序列时作为参数列表直接执行。进程提供 `stdout`/`stderr` 流、`write_stdin`、`wait` 和 `signal(kill=...)`,其中 `signal` 必须作用到整个进程树。
-- `read_text(path)` 和 `write_text(path, content)` 读写执行环境内的文件。
+- `read_text(path)` 和 `write_text(path, content)` 读写执行环境内的文件。`read_bytes(path)` 读取二进制文件;默认实现会用执行环境的 `python` 输出文件内容,能直接访问文件的执行环境应覆盖它。
- `workspace` 是执行环境内的工作目录,`resolve_path` 基于它解析相对路径。
-- `run_code(code, *, tools, call_tool, write)` 执行 Python 代码,代码里的 `await tools.(**kwargs)` 会调用 `call_tool(name, kwargs)`。代码写到 stdout 的内容要实时交给 `write`;代码抛异常时抛出 `CodeFailed`;被取消时要停止代码。具体怎么执行由执行环境决定。有 Python 解释器的执行环境可以直接复用 `bub.builtin.codemode.code_runner.run_code_in_subprocess`,它会用 `spawn` 启动进程来执行代码。
+- `run_code(code, *, tools, call_tool, write)` 执行 Python 代码,代码里的 `await tools.(**kwargs)` 会调用 `call_tool(name, kwargs)`。`republic` 模块会以 `republic.` 前缀的名字调用 `call_tool`,Republic 值以带 `"$republic"` 键的 JSON 对象传输;子进程 runner 已经提供了这个模块。代码写到 stdout 的内容要实时交给 `write`;代码抛异常时抛出 `CodeFailed`;被取消时要停止代码。具体怎么执行由执行环境决定。有 Python 解释器的执行环境可以直接复用 `bub.core.codemode.runner.run_code_in_subprocess`,它会用 `spawn` 启动进程来执行代码。
- `close()` 释放执行环境。
-通过 `provide_environment(session_id, workspace)` hook 为每个 session 提供执行环境。Bub 在 session 的第一个 turn 调用它,把结果放进 `state["_runtime_environment"]`,并在 `framework.running()` 退出时关闭。Bub 的 builtin hook 提供 `LocalEnvironment`(见 [`bub.builtin.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/environment.py)),直接在宿主上运行;插件提供的实现会优先于它。自定义工具可以用 `bub.builtin.environment.environment_from_state(context.state)` 使用同一个环境。
+通过 `provide_environment(session_id, workspace)` hook 为每个 session 提供执行环境。Bub 在 session 的第一个 turn 调用它,把结果放进 `state["_runtime_environment"]`,并在 `framework.running()` 退出时关闭。Bub 的 builtin hook 提供 `LocalEnvironment`(见 [`bub.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/environment.py)),直接在宿主上运行;插件提供的实现会优先于它。自定义工具可以用 `bub.builtin.environment.environment_from_state(context.state)` 使用同一个环境。
## 工具如何变得可用
-`REGISTRY` 位于 [`bub.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py):
+`REGISTRY` 位于 [`bub.core.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/core/tools.py):
```python
-# 来自 src/bub/tools.py
+# 来自 src/bub/core/tools.py
REGISTRY: dict[str, Tool] = {}
```
-每一次 `@tool` 调用都会在**导入时**修改这个字典。`Agent(tools=None)` 在创建时取得其快照,之后导入的工具不会自动更新已有 Agent。可直接向实例的 `Agent.tools` 添加工具,标记 `deferred=True` 即可[按需加载](#按需加载工具)。
+每一次 `@tool` 调用都会在**导入时**修改这个字典。`BubAgent(tools=None)` 在创建时取得其快照,之后导入的工具不会自动更新已有 Agent。可直接向实例的 `BubAgent.tools` 添加工具,标记 `deferred=True` 即可[按需加载](#按需加载工具)。
## 在插件里导入工具模块
@@ -142,7 +168,7 @@ from . import tools # noqa: F401 —— 触发 @tool 注册
工具不会出现在 `bub hooks` 中,但可以用一行 Python 验证注册:
```bash
-uv run python -c "import bub_myplugin.plugin; from bub.tools import REGISTRY; print(sorted(REGISTRY))"
+uv run python -c "import bub_myplugin.plugin; from bub.core.tools import REGISTRY; print(sorted(REGISTRY))"
```
输出应包含你的工具名。然后运行一次让模型调用它的轮次。
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 0415add1..2a70d676 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/` 的 `FileTapeStore`。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。
+默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bub.core.store.FileTapeStore` 的子类,文本查询按从新到旧返回去重后的结果,并支持近似匹配。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。
### spill sidecar
@@ -54,7 +54,7 @@ builtin spill 插件通过 `provide_tape_sidecar` 挂载 `SpillStore`、注册 `
主 tape 只保留有界预览和 opaque handle,不保存完整结果。`spill.read` 工具按 handle 与 cursor 有界读取,也支持从末尾开始读取。由 `spill.read` 明确返回的内容会作为普通的有界 tool result 记录。`spill.read` 的 `exposure` 是 `"direct"`,因此返回纯文本,并且在 code mode 下仍然直接可调用,而不必经过 `run_code`。
-sidecar 与 session tape 一起参与 fork、merge、archive 和 reset,但始终是独立 tape,构造主 context 时不会扫描它。也可以通过 `Tape.archive_sidecar("spill")` 和 `Tape.reset_sidecar("spill")` 单独 archive 或 reset sidecar。当 reset 要求先 archive 时,如果 archive 失败,Bub 会保留 sidecar。
+sidecar 与 session tape 一起参与 fork、merge、archive 和 reset,但始终是独立 tape,构造主 context 时不会扫描它。也可以通过 `Tape.reset_sidecar("spill")` 单独 reset sidecar,或通过 `bub.builtin.archive.archive_sidecar(tape, "spill", directory)` 单独 archive。当 `tape.reset` 要求先 archive 时,如果 archive 失败,Bub 会保留 sidecar。
spill 配置归 sidecar 插件自己所有:在 `config.yml` 使用 `spill.threshold`,或设置环境变量 `BUB_SPILL_THRESHOLD`。设为 `0` 只停止新写入,不会卸载 sidecar,因此已有 handle 仍可读取,tape 生命周期操作也仍会包含它。
@@ -64,16 +64,18 @@ spill 写入以 `spill.write` event 记录。框架管理的生命周期结果
在某条 tape 的第一次 turn 之前,`TapeService.ensure_bootstrap_anchor` 会检查是否存在 anchor entry。如果没有,则写入一条 `session/start` handoff,`state={"owner": "human"}`。这保证每条 tape 在 context 重建时都有起始 anchor。
-### default_tape_context:entry → OpenAI 消息
+### default_tape_context:entry → 模型消息
-`default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并产出 OpenAI 兼容的消息。映射规则如下:
+`default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并返回 `republic.Message` 列表。映射规则如下:
- `anchor` → 形如 `[Anchor created: ]: ` 的 `assistant` 消息。
-- `message` → 直接以 entry payload 作为 chat 消息 dict。
+- `message` → 用 `bub.core.tape.to_message` 转换 entry payload。新的用户消息以 `republic.Message.to_dict()` 格式存储;旧版 Bub payload(媒体 URL、`tool_call_id`、`reasoning`、`provider_data`)仍可读取。
- `tool_call` → 一条包含已保存文本与 `tool_calls` 数组的 `assistant` 消息;无文本的调用(包括旧版 entry)使用空 content。
-- `tool_result` → 每个 result 一条 `tool` role 消息;若前面有 pending `tool_call`,Bub 会按 result 位置复制对应 call 的 `id` 到 `tool_call_id`。
+- `tool_result` → 每个 result 一条 `republic.tool(...)` 消息,按位置与前一个 `tool_call` entry 中的 call 配对;没有对应 call 的 result 会被跳过。
-context selector 本身是个 hook(`build_tape_context`),插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。
+已保存的 reasoning 与 provider data 总是会带上;Republic 只会把它们发回接受这些数据的 API format。
+
+context selector 本身是个 hook(`build_tape_context`)。自定义 `select` 必须返回 Republic 消息(或返回它们的 awaitable);`bub.core.tape.to_messages` 可转换已存储的消息 payload。插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。
带有 `context=False` 标记的 entry 会在 selector 运行前被移除。spill 运维 event 因此仍可在 tape 上查询,但不会进入默认 context 或插件自定义 context。
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 12ed1f50..ccaae096 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
@@ -84,9 +84,9 @@ hook 运行时会在两者间互相适配:若插件只实现 `run_model_stream
`BuiltinImpl`(注册名 `builtin`)以合理默认实现上述每个 hook:
- `resolve_session` — 优先使用 envelope 的 `session_id`,否则 `f"{channel}:{chat_id}"`。
-- `load_state` — 打开 inbound 的可选 `lifespan`,初始化 `session_id` 与内部 `_runtime_agent`。
-- `build_prompt` — 处理 comma 命令(`,help`、`,skill` 等),用时间戳与上下文包裹文本,并在附带媒体时输出多模态片段。
-- `run_model` / `run_model_stream` — 分别委托给 `Agent.run` / `Agent.run_stream`,即由 [Republic](https://github.com/bubbuild/republic) 支撑、带 tool 使用与 auto-handoff 的循环(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。
+- `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`;非流式调用方通过上文描述的适配获得拼接后的文本。
- `save_state` — 用捕获的异常信息关闭 per-message lifespan。
- `render_outbound` — 将 `model_output` 包装为携带 inbound 路由字段的 `ChannelMessage`。
- `dispatch_outbound` — 转发到绑定的 `ChannelRouter`。
diff --git a/website/src/content/docs/zh-cn/docs/getting-started/first-plugin.mdx b/website/src/content/docs/zh-cn/docs/getting-started/first-plugin.mdx
index c456956f..7ac97683 100644
--- a/website/src/content/docs/zh-cn/docs/getting-started/first-plugin.mdx
+++ b/website/src/content/docs/zh-cn/docs/getting-started/first-plugin.mdx
@@ -38,7 +38,7 @@ build-backend = "hatchling.build"
name = "bub-echo-plugin"
version = "0.1.0"
requires-python = ">=3.12"
-dependencies = ["bub>=0.1"]
+dependencies = ["bub[app]>=0.1"]
[project.entry-points."bub"]
echo = "bub_echo_plugin.plugin:echo_plugin"
diff --git a/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx b/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx
index a056182c..b7ed31f8 100644
--- a/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx
+++ b/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx
@@ -12,7 +12,7 @@ sidebar:
你需要准备:
- 已 [安装 Bub](/zh-cn/docs/getting-started/install/) 且 `bub hooks` 报告 `builtin` 插件
-- 一种模型访问方式:通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key;也可以通过 `bub login openai` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=openai:`
+- 一种模型访问方式:通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key;也可以通过 `bub login codex` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=codex:`
## 1. 创建 workspace
@@ -80,15 +80,15 @@ Bub 会解析 session、加载 state、构建用户 prompt,并用包含 `AGENT
bub chat
```
-## 5. 可选:使用 OpenAI OAuth 登录
+## 5. 可选:使用 ChatGPT 套餐
-如果想使用 OpenAI Codex OAuth 路径而不是 API key,使用 OAuth 登录:
+安装 Codex CLI,然后登录以使用 ChatGPT 套餐:
```bash
-bub login openai
+bub login codex
```
-Bub 会在本地保存凭据。使用该路径时,选择你的 Codex 账号可用的模型并设置 `BUB_MODEL=openai:`,同时保持 `BUB_API_KEY` 未设置,也不要设置自定义 `BUB_API_BASE`。
+Codex CLI 将凭据保存到本地文件,Republic 负责读取和刷新。使用该路径时,选择你的 Codex 账号可用的模型并设置 `BUB_MODEL=codex:`,同时保持 `BUB_API_KEY` 未设置,也不要设置自定义 `BUB_API_BASE`。
## 下一步
diff --git a/website/src/content/docs/zh-cn/docs/getting-started/index.mdx b/website/src/content/docs/zh-cn/docs/getting-started/index.mdx
index e5dc30c2..1aeb76f8 100644
--- a/website/src/content/docs/zh-cn/docs/getting-started/index.mdx
+++ b/website/src/content/docs/zh-cn/docs/getting-started/index.mdx
@@ -17,7 +17,7 @@ Bub 是一个用于在共享环境中构建 agent 的小型 Python 框架。内
- 可以访问 `bub.build`、`astral.sh` 和 PyPI 的网络连接
- 一种模型访问方式,用于 model-backed 阶段:
- 通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key,或
- - 通过 `bub login openai` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=openai:`
+ - 通过 `bub login codex` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=codex:`
如果只想检查 Bub 而不实际跑一次 model turn,API key 是可选的——`,help` 等 comma 命令无需它即可使用。
diff --git a/website/src/content/docs/zh-cn/docs/getting-started/install.mdx b/website/src/content/docs/zh-cn/docs/getting-started/install.mdx
index 10acbdd0..1646d5f8 100644
--- a/website/src/content/docs/zh-cn/docs/getting-started/install.mdx
+++ b/website/src/content/docs/zh-cn/docs/getting-started/install.mdx
@@ -75,16 +75,18 @@ import { TabItem, Tabs } from '@astrojs/starlight/components';
如果系统中已有 `uv`,等价的用户级安装命令是:
```bash
-uv tool install "bub@latest"
+uv tool install "bub[app]@latest"
uv tool update-shell
```
如果你倾向使用 `pip`,等价命令是:
```bash
-pip install bub
+pip install "bub[app]"
```
+`app` extra 提供 `bub` 命令、channel、插件和 builtin agent。不带 extra 安装的 `bub` 只包含 agent 内核 `bub.core`,用于[在 Python 中嵌入](/zh-cn/docs/build/sdk/);此时运行 `bub` 命令会提示安装该 extra。
+
## 3. 从源码安装
如果你想为框架贡献代码或跟随 `main` 分支,使用本路径:
diff --git a/website/src/content/docs/zh-cn/docs/operate/configure.mdx b/website/src/content/docs/zh-cn/docs/operate/configure.mdx
index 90a68854..d2da0a36 100644
--- a/website/src/content/docs/zh-cn/docs/operate/configure.mdx
+++ b/website/src/content/docs/zh-cn/docs/operate/configure.mdx
@@ -13,16 +13,16 @@ Bub 自己的配置按以下优先级(从高到低)读取:
2. CLI 启动阶段加载的 `.env` 值,或显式声明 `env_file=".env"` 的 settings class 读取到的 `.env` 值。
3. `~/.bub/config.yml`(由 `bub onboard` 创建)。
-当未显式提供 `BUB_API_KEY` 时,下游模型 SDK 仍可能读取自己的提供商变量,例如 `OPENROUTER_API_KEY`。Bub 的 settings 层只映射 `BUB_*` 变量。
+当没有显式配置 Bub 凭证时,Bub 读取 `_API_KEY` 和 `_API_BASE`。provider 使用 Republic 名称,将连字符替换为下划线,例如 `OPENROUTER_API_KEY`、`GOOGLE_API_KEY` 和 `AZURE_OPENAI_API_KEY`。显式的 `BUB_*` 设置优先。
## 开始之前
- Bub 已安装(`bub --help` 可用)。
-- 你已拥有某个模型提供商的 API key,或已运行 `bub login openai` 完成 OAuth。
+- 你已拥有某个模型提供商的 API key,或已运行 `bub login codex` 完成 OAuth。
## 1. 运行交互式初始化
-`bub onboard` 依次引导你选择 provider、填写 API key 和必要的 URL、检查连接、搜索选择模型,再配置 channel 与流式输出。托管服务使用默认 URL;自定义 OpenAI 兼容服务请选择 **OpenAI-compatible**。API key 留空可使用环境变量中的凭证。获取模型列表失败时,可以修改连接、重试或手动输入模型 ID。连接检查只获取模型列表,不发起模型生成请求。已登录 OpenAI OAuth 时会跳过模型发现,直接手动输入模型。收集到的配置会替换 `~/.bub/config.yml`;已有文件内容不会合并到本轮交互中。插件可通过 `onboard_config` hook 添加自己的交互提示。
+`bub onboard` 依次引导你选择 provider、填写 API key 和必要的 URL、检查连接、搜索选择模型,再配置 channel 与流式输出。托管服务使用默认 URL;自定义 OpenAI 兼容服务请选择 **OpenAI-compatible**。API key 留空可使用环境变量中的凭证。获取模型列表失败时,可以修改连接、重试或手动输入模型 ID。连接检查只获取模型列表,不发起模型生成请求。选择 Codex 时会跳过模型发现,直接手动输入模型。收集到的配置会替换 `~/.bub/config.yml`;已有配置会作为本轮交互的默认值,未更新的字段会保留。插件可通过 `onboard_config` hook 添加自己的交互提示。
```bash
bub onboard
@@ -55,16 +55,16 @@ telegram:
| 变量 | 用途 |
|---|---|
-| `BUB_MODEL` | 模型标识,如 `openrouter:openrouter/free` 或 `openai:`。 |
-| `BUB_API_KEY` | 当前提供商的 API key。使用 `bub login openai` 时保持未设置。 |
-
-如果某个端点说的是已支持的协议,但需要单独起名(公司代理、中转站、第二个账号),就在 `providers` 下声明它,再用这个名字当模型前缀,见[自定义 provider](/zh-cn/docs/reference/settings/#自定义-provider)。
-| `BUB_API_BASE` | 覆盖提供商的基础 URL。使用 Codex OAuth 时保持未设置,以便 Bub 路由到 Codex backend。 |
+| `BUB_MODEL` | 模型标识,如 `openrouter:openrouter/free` 或 `codex:`。 |
+| `BUB_API_KEY` | 当前提供商的 API key。使用 `bub login codex` 时保持未设置。 |
+| `BUB_API_BASE` | 覆盖提供商的基础 URL。保持未设置可使用所选 provider 的默认端点。 |
| `BUB_HOME` | Bub 的运行时数据目录(history、tapes、托管插件项目)。默认 `~/.bub`;不会移动默认配置文件。 |
| `BUB__API_KEY` | 单个提供商的 key,如 `BUB_OPENROUTER_API_KEY`。 |
| `BUB__API_BASE` | 单个提供商的基础 URL。 |
-使用 `bub login openai` 时,Bub 会从 `CODEX_HOME` 或 `~/.codex` 读取 Codex OAuth 凭据,并自动 refresh。这个路径下使用 `openai:` provider 的模型标识,同时保持 `BUB_API_KEY` 和 `BUB_API_BASE` 未设置。
+Republic 从 `$CODEX_HOME/auth.json` 或 `~/.codex/auth.json` 读取并刷新 Codex 文件凭据。新登录需要安装 Codex CLI,然后选择 `codex:` 并保持 `BUB_API_KEY` 未设置。OpenAI API 访问使用 `openai:` 和 API 凭据。
+
+公司代理、中转站或第二个账号可在 `providers` 下单独命名,再使用该名称作为模型前缀,见[自定义 provider](/zh-cn/docs/reference/settings/#自定义-provider)。
完整变量表(包括 channel 防抖参数和高级模型客户端设置)见 [Settings 参考](/zh-cn/docs/reference/settings/)。
diff --git a/website/src/content/docs/zh-cn/docs/operate/deploy.mdx b/website/src/content/docs/zh-cn/docs/operate/deploy.mdx
index e4897530..edf8bc99 100644
--- a/website/src/content/docs/zh-cn/docs/operate/deploy.mdx
+++ b/website/src/content/docs/zh-cn/docs/operate/deploy.mdx
@@ -56,7 +56,7 @@ docker compose up -d --build
docker compose logs -f app
```
-首次构建需要几分钟 —— 镜像会安装系统工具(git、node、jq 等)并执行 `uv sync --no-dev --no-editable`。
+首次构建需要几分钟 —— 镜像会安装系统工具(git、node、jq 等)并执行 `uv sync --no-dev --no-editable --extra app`。
## 4. `entrypoint.sh` 做了什么
diff --git a/website/src/content/docs/zh-cn/docs/reference/cli.mdx b/website/src/content/docs/zh-cn/docs/reference/cli.mdx
index 74f92a23..2daf181f 100644
--- a/website/src/content/docs/zh-cn/docs/reference/cli.mdx
+++ b/website/src/content/docs/zh-cn/docs/reference/cli.mdx
@@ -18,6 +18,8 @@ bub [OPTIONS] COMMAND [ARGS]...
框架在 `bub/__main__.py` 中创建:先 `BubFramework().load_hooks()`,再 `create_cli_app()`。当前 framework 实例存放在 `ctx.obj` 上。
+支持 [OSC 7501](https://www.superlogical.com/rex/docs/build/program-status) 的终端可以显示 Bub 的运行、等待输入或授权、完成和失败状态。取消命令后状态为 `idle`。`chat` 和 `gateway` 开始监听后显示 `idle`,接收的消息全部处理完毕后才显示完成。其他终端正常显示命令输出,重定向输出不包含状态控制序列。
+
## `bub run`
将一条 `ChannelMessage` 通过 `BubFramework.process_inbound` 跑完整条流水线,并把每个 outbound 打印到 stdout。
@@ -109,13 +111,13 @@ bub onboard [OPTIONS] [PLUGIN_NAME]
行为说明:
- 所有 provider 使用同一流程:选择 provider、填写连接信息、检查连接、选择模型,再配置 channel 和流式输出。
-- 选项明确区分 **OpenAI (official API)**(官方接口)与 **OpenAI-compatible (custom URL / local server)**(兼容接口或本地服务)。兼容服务保存为 `openai:`,并使用你填写的 API base URL。
-- 托管服务使用默认地址,通常只询问 API key。兼容服务、Azure、Ollama、其他 provider 以及前序钩子提供的自定义地址会额外询问 URL。API key 留空会保留本轮为相同地址提供的 key,或使用环境变量中的凭证,包括 `OPENAI_API_KEY`、`BUB_API_KEY` 和 `BUB_OPENAI_API_KEY`。环境变量中的 key 不会复制到配置文件;仅在没有输入 key 且环境变量也没有凭证时,才为兼容服务保存 SDK 所需的占位 key。
-- Bub 获取 provider 的模型列表,异步超时为 10 秒,随后支持搜索选择或手动输入模型。阻塞式 SDK 调用可能等待更久。检查只验证模型列表接口可访问,不发起模型生成请求。获取失败时可修改 URL/key、重试或手动输入;不支持模型发现的 provider(如当前 SDK 中的 Azure)会说明限制并直接进入手动输入,不提供重试菜单。
-- 实际连接使用 OpenAI OAuth 时会跳过模型发现,直接手动输入模型。界面显示的默认 URL 不会写入配置。URL 留空可保留默认地址和 OAuth 路由;明确输入 URL 时会保存该地址,即使它与显示的默认值相同。
+- 选项明确区分 **OpenAI (official API)**(官方接口)与 **OpenAI-compatible (custom URL / local server)**(兼容接口或本地服务)。兼容服务保存为 `openai:`,并使用你填写的 API base URL 和 `client_args.api_format: chat`。
+- 托管服务使用默认地址,通常只询问 API key。兼容服务、Azure OpenAI、Ollama、其他 provider 以及前序钩子提供的自定义地址会额外询问 URL。API key 留空会保留本轮为相同地址提供的 key,或使用环境变量中的凭证,包括 `OPENAI_API_KEY`、`BUB_API_KEY` 和 `BUB_OPENAI_API_KEY`。环境变量中的 key 不会复制到配置文件。
+- Bub 获取 provider 的模型列表,异步超时为 10 秒,随后支持搜索选择或手动输入模型。检查只验证模型列表接口可访问,不发起模型生成请求。获取失败时可修改 URL/key、重试或手动输入;不支持模型发现的 provider(如 Codex)会说明限制并直接进入手动输入,不提供重试菜单。
+- 选择 Codex provider 时会跳过模型发现,直接手动输入模型。界面显示的默认 URL 不会写入配置。URL 留空可保留默认地址;明确输入 URL 时会保存该地址,即使它与显示的默认值相同。
- 每个插件的 `onboard_config` 钩子拿到已加载配置的副本,以及此前钩子的累计更新;已有字段会保留,除非被钩子覆盖。返回值不是 dict 时抛 `TypeError` 并中止。
- 合并后的 dict 会经过 `configure.validate` 验证后才写入。
-- `onboard` 是交互命令。provider 标签和提示顺序已调整,连接检查结果也会决定后续提示。按固定顺序输入答案的脚本需要更新;无人值守配置请直接生成配置文件。内置安装器仅在交互模式下启动 `onboard`。
+- `onboard` 是交互命令,连接检查结果会决定后续提示。无人值守配置请直接生成配置文件。内置安装器仅在交互模式下启动 `onboard`。
- Linux 或 Windows 上保存了至少一个 channel 后,onboarding 会询问是否把 gateway 安装为当前用户的后台服务,默认选择否。安装失败不会回滚已保存的配置,并以退出码 `1` 结束。
退出码:
@@ -189,25 +191,25 @@ bub login [OPTIONS] COMMAND [ARGS]...
| --- | --- | --- | --- |
| `--help` | flag | — | 显示帮助并退出。 |
-### `bub login openai`
+### `bub login codex`
-跑 Codex OAuth 流程获取 OpenAI 凭据,并保存到解析后的 Codex home 目录下。
+调用已安装的 Codex CLI,并使用文件凭据存储。
```text
-bub login openai [OPTIONS]
+bub login codex [OPTIONS]
```
| 选项 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
-| `--codex-home` | PATH | `$CODEX_HOME` 或 `~/.codex` | 写入 `auth.json` 的目录。 |
-| `--browser` / `--no-browser` | flag | `--browser` | 在默认浏览器中打开 OAuth authorize URL。 |
-| `--manual` | flag | off | 跳过本地回调服务器,改为提示粘贴 callback URL 或 code。 |
-| `--timeout` | FLOAT | `300.0` | OAuth 等待超时时间(秒)。 |
+| `--executable` | TEXT | `codex` | Codex CLI 可执行文件。 |
+| `--device-auth` | flag | off | 使用设备授权,适用于没有浏览器的终端。 |
+| `--codex-home` | PATH | `$CODEX_HOME` 或 `~/.codex` | 登录子进程使用的凭据目录。 |
行为说明:
-- 成功时打印 `login: ok`、account id、auth 文件路径,并提示设置 `BUB_MODEL=openai:` 且不再提供 `BUB_API_KEY`。
-- 抛出 `CodexOAuthLoginError` 时以退出码 `1` 退出。
+- 成功时打印 `login: ok`、account id,并提示设置 `BUB_MODEL=codex:` 且不再提供 `BUB_API_KEY`。
+- 认证失败时以退出码 `1` 退出;找不到可执行文件时提示安装 CLI。
+- 凭据保存到 `$CODEX_HOME/auth.json` 或 `~/.codex/auth.json`。使用 `--codex-home` 指定目录,无浏览器登录使用 `--device-auth`。
## `bub hooks`
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 5406fe2a..6c9686de 100644
--- a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx
+++ b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx
@@ -17,7 +17,7 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用
| --- | --- | --- | --- | --- | --- |
| `resolve_session` | firstresult | `(message: Envelope) -> str` | session id | `BubFramework.process_inbound` | 没有任何实现返回值时,框架回退为 `"{channel}:{chat_id}"`。 |
| `load_state` | broadcast | `(message: Envelope, session_id: str) -> TurnState` | dict to merge | `BubFramework.process_inbound` | 框架先反转结果列表再依次合并,使高优先级的键覆盖低优先级。 |
-| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> str \| list[dict]` | prompt or content parts | `BubFramework.process_inbound` | 没有实现返回非 `None` 值,或选中的 firstresult 为 falsy 时,回退到 `content_of(message)`。已选中的 falsy 值不会触发继续尝试低优先级实现。 |
+| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> list[UserContent]` | prompt content | `BubFramework.process_inbound` | 没有实现返回非 `None` 值,或选中的 firstresult 为 falsy 时,回退到 `content_of(message)`。已选中的 falsy 值不会触发继续尝试低优先级实现。 |
| `run_model` | firstresult | `(prompt, session_id, state) -> str` | model text | `HookRuntime.run_model` | 旧版纯文本路径。`run_model` 与 `run_model_stream` 选其一实现,不要同时实现。 |
| `run_model_stream` | firstresult | `(prompt, session_id, state) -> AsyncStreamEvents` | async stream | `HookRuntime.run_model_stream` | 推荐路径。无流式实现时框架将 `run_model` 结果包装为单个 chunk 的流。 |
| `continue_prompt` | firstresult | `(prompt: str \| list[dict] \| None, tape: Tape, state: StreamState) -> str \| None` | 可选的下一步 user 消息 | `Agent._stream_events_with_auto_handoff` | 每次 agent loop 继续前调用。`prompt` 是刚完成 step 使用的 prompt;如果上一步未添加消息则为 `None`。实现可同步或异步;若没有 hook 返回字符串,下一次模型调用只使用现有对话,不额外添加 user 消息。 |
@@ -36,10 +36,16 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用
| `provide_channels` | sync-only consumer (deduped) | `(message_handler: MessageHandler) -> list[Channel]` | channels | `BubFramework.get_channels` (`call_many_sync`) | 按 `Channel.name` 去重;在钩子优先级顺序中最先出现的 channel 胜出。 |
| `build_tape_context` | firstresult | `() -> TapeContext` | tape context | `BubFramework.build_tape_context` (`call_first_sync`) | 仅同步;awaitable 返回会被跳过。 |
| `admit_message` | firstresult | `(session_id, message, turn) -> AdmitDecision \| None` | turn admission decision | `ChannelManager` | 调度 channel message 前调用。返回 `None` 保持默认并发调度;decision 类型见 [核心契约](/zh-cn/docs/reference/types/)。 |
-| `before_llm_call` | chained | `(request: LlmCallRequest, state: TurnState) -> LlmCallRequest \| LlmCallDecision \| None` | 修改后的 request 或 finish 决定 | `ModelRunner.run` 经 `AgentHooks` | 实现按 LIFO 链式执行,每个实现看到的是前一个实现修改后的 request。返回 `LlmCallDecision.finish(text)` 跳过 provider 调用。实现抛异常仅记日志并跳过。 |
-| `after_llm_call` | observer | `(request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None` | 无 | `ModelRunner.run` 经 `AgentHooks` | 每次完成的调用恰好触发一次:成功或 `Exception` 失败。取消 / 消费方 `aclose()` 不观察。`result.error` 是原始异常。 |
-| `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` 经 `AgentHooks` | 逐次工具调用。`proceed(arguments=…)` 折叠参数修改;`replace(result)` / `deny(message)` 短路。否决只能通过 decision 对象——异常仅记日志并跳过。 |
-| `after_tool_call` | 终态处理 | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | 无 | `ToolExecutor` 经 `AgentHooks` | 成功、失败、deny/replace 会触发。取消不观察。赋值 `result.result` 会替换模型可见结果;失败时 `result.error` 仍是原始 `BubError`。 |
+| `before_llm_call` | chained | `(request: LLMCallRequest, state: TurnState) -> LLMCallRequest \| LLMCallDecision \| None` | 修改后的 request 或 finish 决定 | core `Agent` 经 `PluginAgentHooks` | 实现按 LIFO 链式执行,每个实现看到的是前一个实现修改后的 request。返回 `LLMCallDecision.finish(text)` 跳过 provider 调用。实现抛异常仅记日志并跳过。 |
+| `after_llm_call` | observer | `(request: LLMCallRequest, result: LLMCallResult, state: TurnState) -> None` | 无 | core `Agent` 经 `PluginAgentHooks` | 每次完成的调用恰好触发一次:成功或 `Exception` 失败。取消 / 消费方 `aclose()` 不观察。`result.error` 是原始异常。 |
+| `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` 经 `PluginAgentHooks` | 逐次工具调用。`proceed(arguments=…)` 折叠参数修改;`replace(result)` / `deny(message)` 短路。否决只能通过 decision 对象——异常仅记日志并跳过。 |
+| `after_tool_call` | 终态处理 | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | 无 | `ToolExecutor` 经 `PluginAgentHooks` | 成功、失败、deny/replace 会触发。取消不观察。赋值 `result.result` 会替换模型可见结果;失败时 `result.error` 仍是原始 `BubError`。 |
+
+### 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": "..."}`)。框架会根据实际返回值进行转换。
+
+接收 prompt 的 hook(`run_model`、`run_model_stream`、`continue_prompt`、`system_prompt`)仍收到旧格式:没有媒体时为字符串,否则为 content block 列表,媒体使用 URL(内联媒体转为 `data:` URL)。可用 `bub.prompt.to_content(prompt)` 转为 user content。
## 钩子如何被调用
@@ -119,15 +125,15 @@ Windows 当前只清理直接 shell 进程。
`notify_error` 与 `notify_error_sync` 把每个实现包在 `try`/`except` 中;观察者失败会写入日志 (`hook.on_error_failed stage=… adapter=…`) 但不会向上传递。这样可以保证某个观察者出错不会阻塞其他观察者,也不会让 `on_error` 掩盖原始异常。
-### Agent-loop 钩子(`AgentHooks`)
+### Agent-loop 钩子(`PluginAgentHooks`)
-四个 `*_llm_call` / `*_tool_call` 钩子经由 `AgentHooks` 门面(`src/bub/hooks/interception.py`)分发,而不是 `call_first`/`call_many`,有三处刻意差异:
+四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `LLMCallStarted`/`LLMCallFinished`/`ToolCallStarted`/`ToolCallFinished` 事件,见 `bub.core.events`)分发,而不是 `call_first`/`call_many`,有三处刻意差异:
-- **链式** — `before_llm_call` 与 `before_tool_call` 按 LIFO 顺序执行全部实现并**折叠**修改:每个实现收到的是前序实现修改后的 request/call。第一个短路决定(`LlmCallDecision.finish`、`ToolCallDecision.replace`/`deny`)终止链。
+- **链式** — `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/hooks/interception.py`。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。
+载荷数据类(`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。
continuation 风格的 `wrap_tool_call`(重试 / human-in-the-loop / 缓存)曾在原 PR 中实现,按 review 方向移除;若相关用例成熟,将作为独立提案回归。
diff --git a/website/src/content/docs/zh-cn/docs/reference/settings.mdx b/website/src/content/docs/zh-cn/docs/reference/settings.mdx
index 3fde61cc..9644b27e 100644
--- a/website/src/content/docs/zh-cn/docs/reference/settings.mdx
+++ b/website/src/content/docs/zh-cn/docs/reference/settings.mdx
@@ -65,23 +65,33 @@ class AgentSettings(Settings):
| `BUB_API_BASE` | unset | `api_base` | 默认 API base URL,或按 provider 索引的映射。 |
| `BUB__API_KEY` | unset | — | 按 provider 划分的 API key,例如 `BUB_OPENAI_API_KEY`。 |
| `BUB__API_BASE` | unset | — | 按 provider 划分的 API base URL,例如 `BUB_OPENROUTER_API_BASE`。 |
-| `BUB_PROVIDERS` | `{}` | `providers` | 自定义命名的端点,每个用 `type` 指定走哪个 any-llm provider 的协议,并有自己的 `api_base` 和 `api_key`。见[自定义 provider](#自定义-provider)。 |
+| `BUB_PROVIDERS` | `{}` | `providers` | 自定义命名的端点,每个用 `type` 指定走哪个 Republic provider 的协议,并有自己的 `api_base` 和 `api_key`。见[自定义 provider](#自定义-provider)。 |
| `BUB_MAX_STEPS` | 不限制 | `max_steps` | 单次 turn 内 agent 循环的最大步数;配置时必须为正整数。 |
| `BUB_MAX_TOKENS` | `16384` | `max_tokens` | 单次模型调用的最大 token 数。 |
| `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | 单次调用的超时秒数。 |
| `BUB_CLIENT_ARGS` | `{}` | `client_args` | 传递给底层模型 client 的额外 kwargs(JSON / dict)。 |
-| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | 传递给每次 completion 调用的额外 kwargs,例如 `{"reasoning_effort":"high"}`;Bub 管理的参数优先。 |
+| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | 传递给每次 completion 调用的额外 kwargs,例如 `{"reasoning_effort":"high"}`;工具和 token 上限由 Bub 提供,优先级见下文。 |
| `BUB_VERBOSE` | `0` | `verbose` | 日志详细级别(`0`–`2`)。 |
例如,配置 `command_prefix: "!"` 后,内置 channel 使用 `!help`,`,help` 按普通文本处理。
CLI 控制命令(`quit`、`exit`、`thinking`)、补全和 shell 模式使用同一前缀。
Telegram 保留现有的原生斜杠命令过滤;前缀设为 `/` 时,使用 `/bub /help`。
-启动时 `ProviderSpecificEnvSource` 会扫描 `os.environ` 中匹配 `^BUB_(.+)_(API_KEY|API_BASE)$` 的变量,把捕获到的 provider 名称小写后作为 key 收集。
+启动时 `ProviderSpecificEnvSource` 会扫描 `os.environ` 中匹配 `^BUB_(.+)_(API_KEY|API_BASE)$` 的变量,并将环境变量前缀解析为 Republic provider 名称,例如 `AZURE_OPENAI` → `azure-openai`。
+
+Bub 使用 Republic 发起模型请求。ChatGPT 套餐访问使用 `codex:`,OpenAI API 访问使用 `openai:`。本地 Codex 登录不会改变 `openai:` 路由。provider 标识使用 Republic 的名称,例如 `google:` 和 `azure-openai:`。
+
+`client_args` 接受 Republic provider 参数,例如 `headers`、`api_format`、`timeout` 和 `max_retries`。Bub 提供解析后的连接设置(`api_key`、`api_base`),其他参数原样传递。协议默认值遵循 Republic。Chat Completions 端点需显式设置 `api_format: chat`。注入的 HTTP client 必须是 `httpx2.AsyncClient`,生命周期由调用方管理。
+
+`completion_args` 接受 Republic chat 参数。Bub 提供 `tools` 和运行时 token 上限;会话中的 `reasoning_effort` 覆盖配置中的同名 chat 参数。Codex 端点不支持 token 上限,因此不附加运行时的该参数。Republic 负责协议转换,Anthropic 请求开启 prompt caching。
+
+提供商专用字段放入 `extra_body`,`client_args` 和 `completion_args` 都支持它。Republic 将 provider 默认值与请求级 extras 深合并,请求级值优先,再覆盖编码后的请求体。这些显式协议字段可覆盖 reasoning、token 上限等 chat 参数;Bub 原样传递。
+
+Provider 名称和协议能力以 [Republic provider 目录](https://getrepublic.org/providers/)为准。自定义 provider 实现需注册到 Republic。
### 自定义 provider
-模型前缀通常是 any-llm 的 provider 名,`api_key` / `api_base` 每个 provider 只能放一个值。如果某个端点说的是 any-llm 已支持的协议,但需要单独起名,就用 `providers`:比如公司代理、中转站或本地网关、自建服务,或同一厂商的第二个账号。每个名字都可以当模型前缀用,`type` 指定它走哪个 any-llm provider 的协议:
+模型前缀通常是 Republic 的 provider 名,`api_key` / `api_base` 每个 provider 只能放一个值。如果某个端点说的是 Republic 已支持的协议,但需要单独起名,就用 `providers`:比如公司代理、中转站或本地网关、自建服务,或同一厂商的第二个账号。每个名字都可以当模型前缀用,`type` 指定它走哪个 Republic provider 的协议:
```yaml
model: proxy:gpt-5.5
@@ -99,9 +109,8 @@ providers:
api_key: sk-relay-...
```
-这里 `proxy:gpt-5.5` 和 `openai:gpt-5.5` 都走 OpenAI 协议,但发往不同的地址、用不同的 key;`openai:` 仍使用它自己的 `api_key` / `api_base`,或 Codex OAuth。条目里没写的字段会回退到 `api_key` / `api_base` 以及 `BUB__API_KEY` / `BUB__API_BASE`。`type` 不是已知 provider 时,加载配置会直接报错。
+这里 `proxy:gpt-5.5` 和 `openai:gpt-5.5` 都走 OpenAI 协议,但发往不同的地址、用不同的 key;`openai:` 仍使用它自己的 `api_key` / `api_base`。条目里没写的字段会回退到 `api_key` / `api_base` 以及 `BUB__API_KEY` / `BUB__API_BASE`。`type` 不是已知 provider 时,加载配置会直接报错。
-OpenAI Codex OAuth 不需要单独的请求格式设置。运行 `bub login openai` 后,当模型使用 `openai:` provider 且没有自定义 API base 时,Bub 会检测并使用本地保存的 Codex OAuth token。
## Spill sidecar —— `SpillSettings`
@@ -173,11 +182,11 @@ class TelegramSettings(Settings):
## Login —— 第三方环境变量
-`bub login openai` 读取一个非 `BUB_*` 环境变量:
+`bub login codex` 读取一个非 `BUB_*` 环境变量:
| 环境变量 | 默认值 | 读取方 | 描述 |
| --- | --- | --- | --- |
-| `CODEX_HOME` | `~/.codex` | `bub login openai`(`src/bub/builtin/auth.py`) | Codex OAuth `auth.json` 的存放目录。可被 `--codex-home` 覆盖。 |
+| `CODEX_HOME` | `~/.codex` | `bub login codex`(`src/bub/builtin/auth.py`) | Codex OAuth `auth.json` 的存放目录。可被 `--codex-home` 覆盖。 |
## 插件自定义配置
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 b55aee87..5dc5da0c 100644
--- a/website/src/content/docs/zh-cn/docs/reference/types.mdx
+++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx
@@ -6,7 +6,7 @@ description: Bub 中由 turn、model、stream、hook 与 channel 子系统分别
Bub 把契约放在拥有其行为的子系统旁边。请从定义它的模块导入;顶层 `bub` 包只暴露常用框架入口。
:::caution
-为兼容旧代码,`bub.types` 暂时只重导出 `Envelope`、`State` 和 `MessageHandler`,并在导入时发出 `DeprecationWarning`。新代码应直接使用 `bub.envelope.Envelope`、`bub.turn.TurnState` 和 `bub.channels.contracts.MessageHandler`。
+原先的兼容模块 `bub.types` 已移除。请直接使用 `bub.envelope.Envelope`、`bub.turn.TurnState` 和 `bub.channels.contracts.MessageHandler`。
:::
## 内核词汇
@@ -31,7 +31,7 @@ type TurnState = dict[str, Any]
@dataclass(frozen=True)
class TurnResult:
session_id: str
- prompt: str | list[dict[str, Any]]
+ prompt: list[UserContent]
model_output: str
outbounds: list[Envelope] = field(default_factory=list)
state: TurnState = field(default_factory=dict)
@@ -67,6 +67,10 @@ from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
`StreamEvent.kind` 可以是 `text`、`reasoning`、`tool_call`、`tool_result`、`usage`、`error` 或 `final`。`AsyncStreamEvents` 包装异步迭代器,并暴露最终 `error` 与 `usage` 状态。
+builtin 的 `tool_call` 事件使用 Republic 字段:`id`、`name`、`arguments` 和 `metadata`。usage 字典包含 `input_tokens`、`output_tokens`、`reasoning_tokens`、`cached_tokens`、`cache_write_tokens` 和 `total_tokens`。
+
+builtin 的多部分 prompt 包含文本块(`type: text`、`text`)或媒体块(`type: image|audio|video`、`media_type`、`url`)。URL 可以是远程地址或 base64 data URL。Bub 将持久化内容块转换为 Republic 原生 part,provider 编码由 Republic 负责。hook 提供这些文本与媒体块。
+
### Error — `bub.errors`
```python
@@ -82,16 +86,16 @@ Hook 声明与 marker 位于 `bub.hooks`;agent-loop 拦截 payload 与它们
```python
from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl, hookspec
from bub.hooks.interception import (
- AgentHooks,
- LlmCallDecision,
- LlmCallRequest,
- LlmCallResult,
+ LLMCallDecision,
+ LLMCallRequest,
+ LLMCallResult,
+ PluginAgentHooks,
ToolCall,
ToolCallDecision,
ToolCallResult,
)
from bub.hooks.runtime import HookRuntime
-from bub.sidecars import TapeSidecar, sidecar_tape_name
+from bub.core.sidecars import TapeSidecar, sidecar_tape_name
```
插件作者通常只需要 `hookimpl` 与对应 hook 使用的 payload 类型。`TapeSidecar` 是 `provide_tape_sidecar` 返回的最小具名契约;`sidecar_tape_name` 用于解析 sibling tape 名称。分发和故障隔离语义见 [Hook 参考](/zh-cn/docs/reference/hooks/)。
diff --git a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx
new file mode 100644
index 00000000..50513580
--- /dev/null
+++ b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx
@@ -0,0 +1,125 @@
+---
+title: 用 FastAPI 提供 Bub agent 服务
+description: 用 FastAPI 把 bub.core agent 暴露为 HTTP API,每个会话对应一段对话。
+sidebar:
+ order: 7
+---
+
+本教程把基于 `bub.core` 构建的 agent 作为 HTTP API 提供服务。每个请求都指定一个会话;同一会话的请求会延续同一段对话,
+对话以 tape 的形式保存在磁盘上。
+
+你需要 Python 3.12+ 和某个模型提供方的 API key。示例通过 [Republic](https://github.com/bubbuild/republic) 使用 OpenAI;
+任何 Republic `provider:model` 都可以。
+
+## 安装
+
+```bash
+uv init bub-api && cd bub-api
+uv add bub fastapi uvicorn
+```
+
+## 编写应用
+
+将以下内容保存为 `app.py`:
+
+```python
+import asyncio
+from collections import defaultdict
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+import republic
+from fastapi import FastAPI, HTTPException, Request
+from pydantic import BaseModel
+
+from bub.core import Agent
+from bub.core.store import FileTapeStore
+from bub.core.tools import 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"}
+
+
+def create_agent() -> Agent:
+ return Agent(
+ store=FileTapeStore(Path(__file__).resolve().parent / "sessions"),
+ tools=[Tool.from_callable(lookup_order)],
+ system_prompt=[
+ "You are an order assistant. Reply directly to the user.",
+ "Use lookup_order to check order status before answering.",
+ ],
+ )
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # API key 从 REPUBLIC_OPENAI_API_KEY 读取。
+ async with republic.get_model("openai:gpt-5-mini") as model:
+ app.state.agent = create_agent()
+ app.state.model = model
+ app.state.session_locks = defaultdict(asyncio.Lock)
+ yield
+
+
+app = FastAPI(lifespan=lifespan)
+
+
+class Task(BaseModel):
+ session_id: str
+ prompt: str
+
+
+@app.post("/tasks")
+async def run_task(task: Task, request: Request):
+ state = request.app.state
+ # 同一会话 tape 上的运行不能重叠;不同会话可以并发运行。
+ async with state.session_locks[task.session_id]:
+ parts: list[str] = []
+ error: str | None = None
+ async with state.agent.run([task.prompt], model=state.model, tape=task.session_id) as stream:
+ async for event in stream:
+ if event.kind == "text":
+ parts.append(str(event.data.get("delta", "")))
+ elif event.kind == "error":
+ error = str(event.data.get("message", "Agent failed"))
+ if error is not None:
+ raise HTTPException(status_code=502, detail=error)
+ return {"session_id": task.session_id, "output": "".join(parts)}
+```
+
+lifespan 只打开一次 Republic 模型,并在服务关闭时关闭它。agent 不保存任何运行级状态,因此一个实例即可服务所有请求;
+每次运行使用该会话 tape 的 fork,离开 `async with agent.run(...)` 块时合并回去,运行失败时也是如此。
+
+## 运行
+
+```bash
+REPUBLIC_OPENAI_API_KEY=... uv run uvicorn app:app
+```
+
+在另一个终端中开始一段对话,再用同一个 `session_id` 继续:
+
+```bash
+curl -s localhost:8000/tasks -H 'content-type: application/json' \
+ -d '{"session_id": "customer-42", "prompt": "Where is order A123?"}'
+
+curl -s localhost:8000/tasks -H 'content-type: application/json' \
+ -d '{"session_id": "customer-42", "prompt": "And when did it ship?"}'
+```
+
+第二次回答可以引用第一次的内容,因为两次运行读取的都是 `sessions/` 下的 `customer-42` tape。
+
+## 部署之前
+
+- `session_locks` 会随会话数量增长;长期运行的服务需要清理空闲的锁。
+- 这些锁只能让同一进程内的运行串行。多个 worker 进程共享同一个 store 时需要跨进程协调,例如使用数据库中的锁;
+ 文件写锁并不能让一次运行串行化。
+- `FileTapeStore` 适合单机。多台主机共享会话时,请使用基于数据库的 store,参见
+ [使用 SQLAlchemy 将 tape 持久化到 SQLite](/zh-cn/docs/tutorials/tapestore-sqlalchemy/)。
+
+## 下一步
+
+- 添加工具、system prompt section 和事件处理器:阅读 [Python SDK](/zh-cn/docs/build/sdk/)。
+- 端到端追踪请求:阅读 [使用 tape、Phoenix 与 Logfire 观察 Bub](/zh-cn/docs/tutorials/observability/)。
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 2b3a29f3..12058ca2 100644
--- a/website/src/content/docs/zh-cn/docs/tutorials/index.mdx
+++ b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx
@@ -16,6 +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,每个会话对应一段对话。
## 下一步
diff --git a/website/src/content/docs/zh-cn/docs/tutorials/observability.mdx b/website/src/content/docs/zh-cn/docs/tutorials/observability.mdx
index 0c58b5d4..87d06470 100644
--- a/website/src/content/docs/zh-cn/docs/tutorials/observability.mdx
+++ b/website/src/content/docs/zh-cn/docs/tutorials/observability.mdx
@@ -22,7 +22,7 @@ sidebar:
- 启动带 Phoenix 的 Bub 之前,在已激活的 Bub 虚拟环境中安装 `trace` extra:
```bash
-uv pip install "bub[trace]"
+uv pip install "bub[app,trace]"
```
`trace` extra 包含 OpenTelemetry API、SDK 和 HTTP/protobuf OTLP exporter。以下 Phoenix 流程无需安装或配置 Logfire。
@@ -144,7 +144,7 @@ bub run ",tape.info"
## 将执行轨迹发送到 Logfire
-如需将执行轨迹发送到 Logfire,安装包含 `bub[trace]` 和 Logfire 的 `logfire` extra。没有显式配置 OTLP endpoint 时,Bub CLI 会在启动时配置 Logfire,无需额外的 tracing 插件。
+如需将执行轨迹发送到 Logfire,安装包含 `bub[app,trace]` 和 Logfire 的 `logfire` extra。没有显式配置 OTLP endpoint 时,Bub CLI 会在启动时配置 Logfire,无需额外的 tracing 插件。
从 Phoenix 切换时,先清除两个 OTLP endpoint 环境变量,再登录并选择 Logfire 项目,然后运行 Bub: