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 32bb991f..498b4382 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,22 +4,24 @@ Core code lives under `src/`: -- `src/bub/__main__.py`: Typer CLI entrypoint. +- `src/bub/__main__.py`: Typer CLI entrypoint; `main` (the `bub` script) checks the `app` extra before importing the CLI. - `src/bub/core/`: agent kernel with no settings or plugin dependencies: agent loop over a tape store (`agent.py`), model protocol and Republic chat model adapter (`model.py`), agent events and their bus (`events.py`), the tape recorder handler (`recorder.py`), system prompt sections (`system_prompt.py`), command prompts (`commands.py`), tools, tape, store, and tape context. - `src/bub/framework.py`: turn orchestration and outbound routing. - `src/bub/hooks/`: hook specifications, execution helpers, and interception contracts. - `src/bub/channels/`: channel bases, routing contracts, admission policy, and lifecycle manager. - `src/bub/{envelope,turn,streaming,errors,model_selection}.py`: small kernel vocabulary owned by each concern. -- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.core.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, and tape services. +- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.core.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, tape services, and the fuzzy-searching file tape store. - `src/bub/channels/`: channel abstractions plus CLI and Telegram adapters. -- `src/bub/skills.py` / `src/bub/tools.py`: skill discovery and tool registry. +- `src/bub/skills.py` / `src/bub/core/tools.py`: skill discovery and tool registry. - `src/skills/`: bundled skills shipped with Bub. Tests live in `tests/`. The website and docs runtime lives in `website/`. +Packaging: `pip install bub` installs only the kernel's dependencies (republic, pydantic, loguru, typing-extensions). Everything outside `bub.core` (framework, CLI, channels, hooks, skills, builtin) needs the `app` extra, and the `bub` script (`bub.__main__:main`) asks for it when it is missing. Add new non-kernel dependencies to `app`, not to `dependencies`. + ## Build, Test, and Development Commands -- `uv sync`: install or update the Python environment; enough to run Bub from source or for deployment-only hosts. +- `uv sync`: install or update the Python environment, including the `app` extra through the `dev` group; enough to run Bub from source. Deployment-only hosts use `uv sync --no-dev --extra app`. - `make install`: full local development bootstrap; sync Python deps, install website deps, and install `prek` hooks. - `uv run bub chat`: run the interactive CLI. - `uv run bub gateway`: start channel listener mode. diff --git a/Dockerfile b/Dockerfile index bf6f90c3..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 --locked --no-dev --no-editable && \ +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 be728b6e..5884dbb2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ See [Getting Started](https://bub.build/docs/getting-started/) for installer pre - **One runtime across surfaces.** The same inbound pipeline runs across CLI, Telegram, and custom channels. Adapters change the surface, not the runtime model. - **Batteries included.** CLI, Telegram, tools, skills, and model execution ship with the core runtime. Use the defaults first, replace them later. - **Operator equivalence.** Humans and agents work inside the same runtime boundaries, with the same evidence trail and handoff model. -- **Embeddable Python SDK.** `bub.core` exposes the agent loop on its own — bring your own model, tools, prompts, and tape store, with no settings or plugins required. +- **Embeddable Python SDK.** `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 diff --git a/pyproject.toml b/pyproject.toml index 6235fc2c..a215b902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,21 +20,11 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] +# The agent kernel, bub.core. Everything else (framework, CLI, channels, builtin agent) is in the `app` extra. dependencies = [ - "pydantic>=2.12.5", - "pydantic-settings>=2.14.2", - "pyyaml>=6.0.3", - "pluggy>=1.6.0", - "inquirer-textual>=0.8.0", - "typer>=0.24.1", "republic>=0.6.0a1", - "rich>=14.3.4", - "prompt-toolkit>=3.0.52", - "python-telegram-bot>=22.7", + "pydantic>=2.12.5", "loguru>=0.7.3", - "rapidfuzz>=3.14.5", - "aiohttp>=3.14.3", - "httpx[socks]>=0.28.1", "typing-extensions>=4.15.0", ] @@ -44,21 +34,35 @@ Repository = "https://github.com/bubbuild/bub" Documentation = "https://bub.build" [project.scripts] -bub = "bub.__main__:app" +bub = "bub.__main__:main" [project.optional-dependencies] +app = [ + "pydantic-settings>=2.14.2", + "pyyaml>=6.0.3", + "pluggy>=1.6.0", + "inquirer-textual>=0.8.0", + "typer>=0.24.1", + "rich>=14.3.4", + "prompt-toolkit>=3.0.52", + "python-telegram-bot>=22.7", + "rapidfuzz>=3.14.5", + "aiohttp>=3.14.3", + "httpx[socks]>=0.28.1", +] trace = [ "opentelemetry-api>=1.39.0", "opentelemetry-sdk>=1.39.0", "opentelemetry-exporter-otlp-proto-http>=1.39.0", ] logfire = [ - "bub[trace]", + "bub[app,trace]", "logfire>=4.32.0", ] [dependency-groups] dev = [ + "bub[app]", "pytest>=9.0.3", "tox-uv>=1.35.1", "types-PyYAML>=6.0.12.20260408", diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 28666a72..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.core.tools import tool -from bub.framework import DEFAULT_HOME, BubFramework -from bub.hooks import hookimpl +if TYPE_CHECKING: + from bub.configure import Settings, config, ensure_config + from bub.core.tools import tool + from bub.framework import BubFramework + from bub.hooks import hookimpl + + home: Path __all__ = [ "BubFramework", @@ -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/builtin/cli.py b/src/bub/builtin/cli.py index 5ab0058e..3b832fd9 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -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/hook_impl.py b/src/bub/builtin/hook_impl.py index 77615b0d..cfc302b2 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -295,9 +295,9 @@ def provide_environment(self, session_id: str, workspace: Path) -> Environment: @hookimpl def provide_tape_store(self) -> TapeStore: import bub - from bub.core.store import FileTapeStore + from bub.builtin.store import FuzzyFileTapeStore - return FileTapeStore(directory=bub.home / "tapes") + return FuzzyFileTapeStore(directory=bub.home / "tapes") @hookimpl def provide_tape_sidecar(self) -> TapeSidecar: 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/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 96a5adac..c4caca2a 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -39,7 +39,7 @@ from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage from bub.core.commands import strip_command_prefix -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus +from bub.core.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 @@ -191,14 +191,14 @@ def __init__(self, renderer: CliRenderer, presenter: TerminalPresenter) -> None: self._presenter = presenter def subscribe(self, events: EventBus) -> None: - events.on(BeforeToolCall, self.on_before_tool_call) - events.on(AfterToolCall, self.on_after_tool_call) + events.on(ToolCallStarted, self.on_before_tool_call) + events.on(ToolCallFinished, self.on_after_tool_call) - async def on_before_tool_call(self, event: BeforeToolCall) -> None: + 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 on_after_tool_call(self, event: AfterToolCall) -> None: + async def on_after_tool_call(self, event: ToolCallFinished) -> None: result = event.result if result.error is None: await self._presenter.write( 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 index 10b135db..b4d39dea 100644 --- a/src/bub/core/__init__.py +++ b/src/bub/core/__init__.py @@ -7,25 +7,26 @@ from bub.core.agent import Agent, events_from from bub.core.context import default_tape_context from bub.core.events import ( - AfterLlmCall, - AfterToolCall, AgentEvent, - BeforeLlmCall, - BeforeToolCall, - ChatCompleted, + ChatFinished, CommandFinished, EventBus, EventHandler, - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + 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 @@ -34,24 +35,23 @@ from bub.core.tools import Tool, ToolContext, tool __all__ = [ - "AfterLlmCall", - "AfterToolCall", "Agent", "AgentEvent", - "BeforeLlmCall", - "BeforeToolCall", - "ChatCompleted", + "ChatFinished", "ChatModelClient", "CommandFinished", "EventBus", "EventHandler", - "LlmCallDecision", - "LlmCallRequest", - "LlmCallResult", + "LLMCallDecision", + "LLMCallFinished", + "LLMCallRequest", + "LLMCallResult", + "LLMCallStarted", "Model", "ModelClient", "PromptContext", "PromptSection", + "RunFinished", "RunStarted", "StepFinished", "StepStarted", @@ -63,7 +63,9 @@ "Tool", "ToolCall", "ToolCallDecision", + "ToolCallFinished", "ToolCallResult", + "ToolCallStarted", "ToolContext", "default_tape_context", "events_from", diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py index 318e6e88..78e8502a 100644 --- a/src/bub/core/agent.py +++ b/src/bub/core/agent.py @@ -4,7 +4,7 @@ import sys import time -from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Mapping, Sequence +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 @@ -19,14 +19,15 @@ 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 ( - AfterLlmCall, AgentEvent, - BeforeLlmCall, - ChatCompleted, + ChatFinished, CommandFinished, EventBus, - LlmCallRequest, - LlmCallResult, + LLMCallFinished, + LLMCallRequest, + LLMCallResult, + LLMCallStarted, + RunFinished, RunStarted, StepFinished, StepStarted, @@ -78,7 +79,7 @@ class Agent: 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. ``BeforeLlmCall`` and ``BeforeToolCall`` handlers + 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`, @@ -216,14 +217,11 @@ async def start( with span.activate(): span.messages("gen_ai.input.messages", [republic.user(*prompt)]) if not any(prompt): - events = events_from([ - StreamEvent("text", {"delta": "error: empty prompt"}), - StreamEvent("final", {"text": "error: empty prompt", "ok": False}), - ]) - else: - session = await stack.enter_async_context(self.session(tape, state=state, merge_back=merge_back)) - events = await self.run_on(session, prompt, model=model, options=options, metadata=metadata) - stack.push_async_callback(events.aclose) + 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: @@ -232,7 +230,6 @@ async def start( finally: span.end() raise - return AsyncStreamEvents(_trace_events(events, span), state=events._state, on_close=stack.aclose, span=span) async def run_on( self, @@ -242,174 +239,63 @@ async def run_on( 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: - result = await self.run_command(tape, line) - return events_from([ - StreamEvent("text", {"delta": result}), - StreamEvent("final", {"text": result, "ok": True}), - ]) + 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() - return AsyncStreamEvents(self._run_steps(tape, client, dict(options or {}), prompt, state), state=state) + tracker = _RunTracker(span) - def run_once( + 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, - ) -> AsyncStreamEvents: + 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 = StreamState() - return AsyncStreamEvents( - self._run_once(tape, self._model_client(model), dict(options or {}), prompt, state), state=state - ) - - 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. + ``state``, when given, receives the call's usage. """ - 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 - ) -> AsyncGenerator[StreamEvent, None]: - auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES - next_prompt: list[UserContent] | None = prompt - for step in range(1, self.max_steps + 1): - 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) - async with aclosing(output): - async for event in output: - 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 - - state.error = output.error - state.usage = output.usage - if not (should_continue or self.has_pending_messages(tape)): - await self._record_step(tape, step, start, "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}") - - 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 _run_once( - self, - tape: Tape, - client: ModelClient, - options: dict[str, Any], - prompt: list[UserContent] | None, - state: StreamState, - ) -> AsyncGenerator[StreamEvent, None]: + 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) @@ -422,18 +308,18 @@ async def _run_once( 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( + 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(BeforeLlmCall, tape, request=request) + before = await self._emit(LLMCallStarted, tape, request=request) request, decision = before.request, before.decision if decision is not None: await self._emit( - ChatCompleted, + ChatFinished, tape, run_id=run_id, model=request.model, @@ -485,7 +371,7 @@ async def _run_once( if value } await self._emit( - ChatCompleted, + ChatFinished, tape, run_id=run_id, model=request.model, @@ -504,6 +390,146 @@ async def _run_once( 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, @@ -518,7 +544,7 @@ async def _build_messages( messages = await tape.read_messages() except BubError as exc: await self._emit( - ChatCompleted, + ChatFinished, tape, run_id=run_id, model=model, @@ -542,7 +568,7 @@ async def _traced_completion( tape: Tape, client: ModelClient, options: dict[str, Any], - request: LlmCallRequest, + request: LLMCallRequest, tools: list[Tool], state: StreamState, ) -> AsyncGenerator[tuple[str, republic.Stream[Any], AsyncIterator[StreamEvent]]]: @@ -587,11 +613,11 @@ async def _traced_completion( except BaseException as exc: span.fail(exc) if isinstance(exc, Exception): - await self._fire_after_llm_call(tape, request, "".join(text), calls, state, started, error=exc) + await self._fire_llm_call_finished(tape, request, "".join(text), calls, state, started, error=exc) raise else: completed = stream.response - await self._fire_after_llm_call( + await self._fire_llm_call_finished( tape, request, completed.text or completed.refusal or "", completed.tool_calls, state, started ) finally: @@ -606,10 +632,10 @@ async def _traced_completion( ) span.end() - async def _fire_after_llm_call( + async def _fire_llm_call_finished( self, tape: Tape, - request: LlmCallRequest, + request: LLMCallRequest, text: str, tool_calls: list[republic.ToolCall], state: StreamState, @@ -617,7 +643,7 @@ async def _fire_after_llm_call( error: Exception | None = None, ) -> None: duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) - result = LlmCallResult( + result = LLMCallResult( run_id=request.run_id, text=text or None, tool_calls=[asdict(call) for call in tool_calls], @@ -625,7 +651,7 @@ async def _fire_after_llm_call( error=error, duration_ms=duration_ms, ) - await self._emit(AfterLlmCall, tape, request=request, result=result) + await self._emit(LLMCallFinished, tape, request=request, result=result) def events_from(items: Iterable[StreamEvent]) -> AsyncStreamEvents: @@ -638,30 +664,53 @@ async def generator() -> AsyncIterator[StreamEvent]: return AsyncStreamEvents(generator()) -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) +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: @@ -707,7 +756,7 @@ def _tool_invocation(tool_call: republic.ToolCall, tool_map: dict[str, Tool]) -> 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 - ``BeforeToolCall`` handlers can recover it into a guidance + ``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. diff --git a/src/bub/core/events.py b/src/bub/core/events.py index 7eafebcb..b4a6cb38 100644 --- a/src/bub/core/events.py +++ b/src/bub/core/events.py @@ -20,7 +20,7 @@ @dataclass(frozen=True) -class LlmCallRequest: +class LLMCallRequest: """Outgoing agent-loop LLM request exposed to interception hooks. Hooks may return a modified copy (``dataclasses.replace``) to change the @@ -36,7 +36,7 @@ class LlmCallRequest: @dataclass(frozen=True) -class LlmCallResult: +class LLMCallResult: """Terminal outcome of one LLM call exposed to interception hooks. For streaming completions this is the fully accumulated final state, not @@ -54,14 +54,14 @@ class LlmCallResult: @dataclass(frozen=True) -class LlmCallDecision: +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: + def finish(cls, text: str) -> LLMCallDecision: """Skip the provider call and emit ``text`` as its final output.""" return cls(action="finish", text=text) @@ -152,6 +152,20 @@ class RunStarted(TapeEvent): 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.""" @@ -186,15 +200,15 @@ class CommandFinished(TapeEvent): @dataclass(kw_only=True) -class BeforeLlmCall(TapeEvent): +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 + request: LLMCallRequest + decision: LLMCallDecision | None = None @property def stopped(self) -> bool: @@ -202,15 +216,15 @@ def stopped(self) -> bool: @dataclass(kw_only=True) -class AfterLlmCall(TapeEvent): +class LLMCallFinished(TapeEvent): """An LLM call completed or failed with an ``Exception``; cancellation is not observed.""" - request: LlmCallRequest - result: LlmCallResult + request: LLMCallRequest + result: LLMCallResult @dataclass(kw_only=True) -class BeforeToolCall(AgentEvent): +class ToolCallStarted(AgentEvent): """A tool call is about to run. Handlers may replace ``call`` to change its arguments, or set ``decision`` to @@ -226,7 +240,7 @@ def stopped(self) -> bool: @dataclass(kw_only=True) -class AfterToolCall(AgentEvent): +class ToolCallFinished(AgentEvent): """A tool call ended (success, failure, denial or replacement); assign ``result.result`` to change it.""" call: ToolCall @@ -234,7 +248,7 @@ class AfterToolCall(AgentEvent): @dataclass(kw_only=True) -class ChatCompleted(TapeEvent): +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 @@ -281,8 +295,8 @@ def on(self, event_type: type[E], handler: EventHandler[E] | None = None) -> Any Without ``handler``, return a decorator registering the decorated function:: - @events.on(BeforeToolCall) - def guard(event: BeforeToolCall) -> None: ... + @events.on(ToolCallStarted) + def guard(event: ToolCallStarted) -> None: ... """ if handler is None: return lambda handler: self.on(event_type, handler) diff --git a/src/bub/core/model.py b/src/bub/core/model.py index 6034af9d..2ec1d48a 100644 --- a/src/bub/core/model.py +++ b/src/bub/core/model.py @@ -36,7 +36,7 @@ def open_stream( ) -> 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 ``BeforeLlmCall`` handler redirected the call; + ``spec`` is the default spec unless a ``LLMCallStarted`` handler redirected the call; ``options`` are the call's ``republic.ChatOptions``, tools included. """ ... diff --git a/src/bub/core/recorder.py b/src/bub/core/recorder.py index fc3e48ca..d2446841 100644 --- a/src/bub/core/recorder.py +++ b/src/bub/core/recorder.py @@ -7,7 +7,15 @@ import republic -from bub.core.events import ChatCompleted, CommandFinished, EventBus, RunStarted, StepFinished, StepStarted +from bub.core.events import ( + ChatFinished, + CommandFinished, + EventBus, + RunFinished, + RunStarted, + StepFinished, + StepStarted, +) from bub.prompt import UserContent @@ -20,9 +28,10 @@ class TapeRecorder: 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(ChatCompleted, self.on_chat_completed) + events.on(ChatFinished, self.on_chat_finished) events.on(CommandFinished, self.on_command_finished) async def on_run_started(self, event: RunStarted) -> None: @@ -30,6 +39,13 @@ async def on_run_started(self, event: RunStarted) -> None: "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}) @@ -54,7 +70,7 @@ async def on_command_finished(self, event: CommandFinished) -> None: }, ) - async def on_chat_completed(self, event: ChatCompleted) -> None: + async def on_chat_finished(self, event: ChatFinished) -> None: await event.tape.record_chat( run_id=event.run_id, system_prompt=event.system_prompt, diff --git a/src/bub/core/store.py b/src/bub/core/store.py index 2b824929..51604ed7 100644 --- a/src/bub/core/store.py +++ b/src/bub/core/store.py @@ -4,7 +4,6 @@ import inspect import itertools import json -import re import threading from collections.abc import Coroutine, Iterable, Sequence from dataclasses import asdict, dataclass, field, replace @@ -18,12 +17,6 @@ from bub.core.tape import TapeEntry from bub.errors import BubError, ErrorKind -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): @@ -386,73 +379,6 @@ def __init__(self, directory: Path) -> None: 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") diff --git a/src/bub/core/tools.py b/src/bub/core/tools.py index 93954e29..10fdbe9a 100644 --- a/src/bub/core/tools.py +++ b/src/bub/core/tools.py @@ -12,7 +12,7 @@ from loguru import logger from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult +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 @@ -285,7 +285,7 @@ class ToolExecutor: 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. ``BeforeToolCall`` and ``AfterToolCall`` + when the caller is code rather than the model. ``ToolCallStarted`` and ``ToolCallFinished`` events are emitted on ``events``. """ @@ -467,11 +467,11 @@ async def _apply_before_tool_call( hook_state: dict[str, Any], started: float, ) -> tuple[ToolCall, Callable[[], Any] | None]: - """Emit BeforeToolCall and translate deny/replace into a short-circuit thunk.""" + """Emit ToolCallStarted and translate deny/replace into a short-circuit thunk.""" if self._events is None: return call, None - before = await self._events.emit(BeforeToolCall(state=hook_state, call=call)) + before = await self._events.emit(ToolCallStarted(state=hook_state, call=call)) call, decision = before.call, before.decision if decision.action == "deny": error = BubError( @@ -504,7 +504,7 @@ async def _fire_after_tool_call( duration_ms=duration_ms, ) if self._events is not None: - await self._events.emit(AfterToolCall(state=state, call=call, result=outcome)) + await self._events.emit(ToolCallFinished(state=state, call=call, result=outcome)) return outcome diff --git a/src/bub/framework.py b/src/bub/framework.py index e9300eab..4b6cbc40 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -11,12 +11,12 @@ import pluggy import typer -from dotenv import find_dotenv, load_dotenv from loguru import logger from bub import configure from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import ChannelRouter, MessageHandler +from bub.configure import DEFAULT_HOME from bub.core.sidecars import TapeSidecar from bub.core.store import AsyncTapeStore, TapeStore from bub.core.tape import Tape, TapeContext @@ -35,8 +35,6 @@ 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() diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index a16c9c74..bcc56c37 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -8,25 +8,25 @@ from loguru import logger from bub.core.events import ( - AfterLlmCall, - AfterToolCall, - BeforeLlmCall, - BeforeToolCall, EventBus, - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallFinished, + LLMCallRequest, + LLMCallResult, + LLMCallStarted, ToolCall, ToolCallDecision, + ToolCallFinished, ToolCallResult, + ToolCallStarted, ) from bub.hooks.runtime import _SKIP_VALUE, HookRuntime from bub.turn import TurnState __all__ = [ - "LlmCallDecision", - "LlmCallRequest", - "LlmCallResult", + "LLMCallDecision", + "LLMCallRequest", + "LLMCallResult", "PluginAgentHooks", "ToolCall", "ToolCallDecision", @@ -48,47 +48,47 @@ def __init__(self, runtime: HookRuntime) -> None: 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(BeforeLlmCall, self._on_before_llm_call) - events.on(AfterLlmCall, self._on_after_llm_call) - events.on(BeforeToolCall, self._on_before_tool_call) - events.on(AfterToolCall, self._on_after_tool_call) + 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_before_llm_call(self, event: BeforeLlmCall) -> None: + 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_after_llm_call(self, event: AfterLlmCall) -> None: + async def _on_llm_call_finished(self, event: LLMCallFinished) -> None: await self.after_llm_call(event.request, event.result, event.state) - async def _on_before_tool_call(self, event: BeforeToolCall) -> None: + 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_after_tool_call(self, event: AfterToolCall) -> None: + 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/specs.py b/src/bub/hooks/specs.py index 50f1619c..af4f3b58 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -11,9 +11,9 @@ from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import MessageHandler from bub.core.events import ( - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallRequest, + LLMCallResult, ToolCall, ToolCallDecision, ToolCallResult, @@ -148,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/sidecars.py b/src/bub/sidecars.py deleted file mode 100644 index 88e59846..00000000 --- a/src/bub/sidecars.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.sidecars`.""" - -import sys - -from bub.core import sidecars as _module -from bub.core.sidecars import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/src/bub/store.py b/src/bub/store.py deleted file mode 100644 index 3fd8aeca..00000000 --- a/src/bub/store.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.store`.""" - -import sys - -from bub.core import store as _module -from bub.core.store import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/src/bub/tape.py b/src/bub/tape.py deleted file mode 100644 index 965d1d18..00000000 --- a/src/bub/tape.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.tape`.""" - -import sys - -from bub.core import tape as _module -from bub.core.tape import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/src/bub/tools.py b/src/bub/tools.py deleted file mode 100644 index 76d4ee66..00000000 --- a/src/bub/tools.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.tools`.""" - -import sys - -from bub.core import tools as _module -from bub.core.tools import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/tests/conftest.py b/tests/conftest.py index a92497a7..56a64baf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ @pytest.fixture(autouse=True) def isolate_bub_environment(monkeypatch: pytest.MonkeyPatch) -> None: - # Importing bub.framework loads the developer's .env into os.environ; tests set what they need. + # 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) diff --git a/tests/model_fakes.py b/tests/model_fakes.py index ee8f98e6..c23bf7da 100644 --- a/tests/model_fakes.py +++ b/tests/model_fakes.py @@ -3,18 +3,18 @@ from __future__ import annotations import json -from collections.abc import Sequence +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 AsyncStreamEvents -from bub.tape import Tape -from bub.tools import Tool +from bub.streaming import StreamEvent class StepRunner: @@ -33,7 +33,7 @@ def run( system_prompt: str | None, prompt: list[UserContent] | None, steering_messages: list[list[UserContent]] | None = None, - ) -> AsyncStreamEvents: + ) -> AsyncIterator[StreamEvent]: class Agent(CoreAgent): async def pending_messages(self, tape: Tape) -> list[list[UserContent]]: return steering_messages or [] diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 59d65f5f..f633c0a7 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -12,12 +12,15 @@ 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 ( - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallRequest, + LLMCallResult, PluginAgentHooks, ToolCall, ToolCallDecision, @@ -25,9 +28,6 @@ ) from bub.hooks.runtime import HookRuntime from bub.prompt import to_content -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import Tool, ToolExecutor from tests.model_fakes import ProviderService, StepRunner, sse @@ -39,8 +39,8 @@ def make_hooks(*plugins: Any) -> PluginAgentHooks: return PluginAgentHooks(HookRuntime(pm)) -def request() -> LlmCallRequest: - return LlmCallRequest(run_id="run-1", model="openai:gpt-x", messages=[republic.user("hi")]) +def request() -> LLMCallRequest: + return LLMCallRequest(run_id="run-1", model="openai:gpt-x", messages=[republic.user("hi")]) class TestBeforeLlmCall: @@ -48,12 +48,12 @@ 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, republic.user("extra")]) @@ -69,12 +69,12 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallReques 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()) @@ -85,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()) @@ -227,20 +227,20 @@ async def test_no_hooks_keeps_current_behavior(self) -> None: 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] @@ -250,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 @@ -283,7 +283,7 @@ async def test_rewritten_model_and_max_tokens_reach_provider_and_tape( ) -> 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) provider_transport.reply( @@ -306,11 +306,11 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallReques async def test_after_llm_call_not_fired_on_early_close( self, tmp_path: Path, provider_transport: ProviderService ) -> None: - observed: list[LlmCallResult] = [] + 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) provider_transport.reply_chat("done") @@ -326,11 +326,11 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: async def test_after_llm_call_fires_exactly_once_on_success( self, tmp_path: Path, provider_transport: ProviderService ) -> None: - observed: list[LlmCallResult] = [] + 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) provider_transport.reply_chat("done") @@ -345,11 +345,11 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: async def test_failed_stream_reports_partial_text_once_without_trying_fallback( self, tmp_path: Path, provider_transport: ProviderService ) -> None: - observed: list[LlmCallResult] = [] + 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) provider_transport.reply(sse([{"choices": [{"delta": {"content": "partial"}}]}])) @@ -370,11 +370,11 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: async def test_refusal_text_reaches_the_hook_final_event_and_tape( self, tmp_path: Path, provider_transport: ProviderService ) -> None: - observed: list[LlmCallResult] = [] + 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) provider_transport.reply( diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 435780f8..e6b80e29 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -14,14 +14,14 @@ from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox -from bub.core import PromptContext, StepFinished +from bub.core import PromptContext from bub.core.context import default_tape_context -from bub.core.tape import session_tape_name +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.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.tape import Tape -from bub.tools import REGISTRY, tool +from bub.streaming import StreamEvent, StreamState from tests.model_fakes import ProviderService # --------------------------------------------------------------------------- @@ -172,14 +172,12 @@ async def test_agent_loop_awaits_continue_prompt_hook_with_stream_state(continua continuation_prompts: list[list[UserContent] | None] = [] observed_usage: list[dict[str, Any] | None] = [] - def run_once(prompt: list[UserContent] | None, *, model: Any, tape: Tape, **_: Any) -> AsyncStreamEvents: + async def run_once( + prompt: list[UserContent] | None, *, model: Any, tape: Tape, state: StreamState, **_: Any + ) -> AsyncIterator[StreamEvent]: prompts.append(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)})) + state.usage = {"step": len(prompts)} + yield StreamEvent("final", {"tool_calls": ["call"] if len(prompts) == 1 else []}) async def continue_prompt(*, prompt: list[UserContent] | None, tape: Tape, state: StreamState) -> str | None: continuation_prompts.append(prompt) diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index 878ce94c..877651bf 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -633,7 +633,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 3c6c42c6..2400582d 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -12,11 +12,11 @@ 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 bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry -from bub.tools import Tool from tests.model_fakes import ProviderService, StepRunner, sse diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index b2a9ee62..76147fe3 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -8,13 +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.tape import session_tape_name +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: @@ -442,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_tools.py b/tests/test_builtin_tools.py index 209a502f..a3960c53 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -26,10 +26,10 @@ set_reasoning_effort, tape_info, ) +from bub.core.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: diff --git a/tests/test_channels.py b/tests/test_channels.py index ec71b592..6ceced98 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1334,7 +1334,7 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp 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.tools import Tool, ToolExecutor + from bub.core.tools import Tool, ToolExecutor events: list[str] = [] @@ -1367,7 +1367,7 @@ def ordered_tool() -> str: 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.tools import Tool, ToolExecutor + from bub.core.tools import Tool, ToolExecutor errors: list[BaseException] = [] 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_codemode.py b/tests/test_codemode.py index 75ca8be5..ff7f4e15 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -23,13 +23,13 @@ write_tool_stub, ) from bub.builtin.environment import LocalEnvironment, LocalProcess -from bub.core.events import BeforeToolCall, EventBus +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 from bub.errors import BubError, ErrorKind from bub.hooks.interception import ToolCallDecision -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool class Order(TypedDict): @@ -152,13 +152,13 @@ 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]] = [] - def before_tool_call(event: BeforeToolCall) -> 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") events = EventBus() - events.on(BeforeToolCall, before_tool_call) + 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" @@ -388,13 +388,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]] = [] - def before_tool_call(event: BeforeToolCall) -> 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") events = EventBus() - events.on(BeforeToolCall, before_tool_call) + 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 = ( @@ -469,7 +469,7 @@ async def test_run_code_attaches_media_to_its_result(tmp_path: Path) -> None: import republic from bub.builtin.codemode import attach_audio, attach_image, attach_video - from bub.tools import result_content + from bub.core.tools import result_content (tmp_path / "charts").mkdir() (tmp_path / "charts" / "chart.png").write_bytes(PNG_BYTES) @@ -527,8 +527,8 @@ def test_media_tool_results_become_multimodal_tool_messages(tmp_path: Path) -> N import republic from bub.core.context import default_tape_context - from bub.tape import TapeEntry - from bub.tools import content_result + from bub.core.tape import TapeEntry + from bub.core.tools import content_result image = republic.Image("image/png", data=PNG_BYTES) entries = [ diff --git a/tests/test_core_agent.py b/tests/test_core_agent.py index 087a7d07..e2e22327 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -9,24 +9,24 @@ import republic import bub.core -from bub.core import ( - AfterLlmCall, - AfterToolCall, - Agent, +from bub.core import Agent, PromptContext +from bub.core.events import ( AgentEvent, - BeforeLlmCall, - BeforeToolCall, - ChatCompleted, + ChatFinished, CommandFinished, EventBus, - LlmCallDecision, - PromptContext, + LLMCallDecision, + LLMCallFinished, + LLMCallStarted, + RunFinished, RunStarted, StepFinished, - Tool, 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 @@ -62,8 +62,8 @@ def tools_section(context: PromptContext) -> str: async def empty_section(context: PromptContext) -> None: return None - @agent.events.on(BeforeToolCall) - def rewrite_path(event: BeforeToolCall) -> 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." @@ -85,39 +85,40 @@ def rewrite_path(event: BeforeToolCall) -> None: assert seen == [ "RunStarted", "StepStarted", - "BeforeLlmCall", - "AfterLlmCall", - "BeforeToolCall", - "AfterToolCall", - "ChatCompleted", + "LLMCallStarted", + "LLMCallFinished", + "ToolCallStarted", + "ToolCallFinished", + "ChatFinished", "StepFinished", "StepStarted", - "BeforeLlmCall", - "AfterLlmCall", - "ChatCompleted", + "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") + 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[ChatCompleted] = [] + 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(BeforeToolCall, lambda event: setattr(event, "decision", ToolCallDecision.deny("no secrets"))) - agent.events.on(BeforeToolCall, lambda event: later.append("tool")) - agent.events.on(BeforeLlmCall, lambda event: later.append("llm")) - agent.events.on(ChatCompleted, finished.append) + 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)] @@ -126,9 +127,9 @@ async def test_before_events_short_circuit_remaining_handlers(provider_service: assert later == ["llm"] agent.events.on( - BeforeLlmCall, lambda event: setattr(event, "decision", LlmCallDecision.finish("budget exhausted")) + LLMCallStarted, lambda event: setattr(event, "decision", LLMCallDecision.finish("budget exhausted")) ) - agent.events.on(AfterLlmCall, lambda event: later.append("after")) + 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] @@ -172,11 +173,12 @@ async def test_core_agent_stops_at_max_steps(provider_service: ProviderService) ) 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(AfterToolCall, lambda event: statuses.append(f"tool={event.result.result}")) + 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"] + assert statuses == ["model=openai:test", "tool=ok", "continue", "run=error:1:max_steps_reached=1"] @pytest.mark.asyncio @@ -185,8 +187,8 @@ async def test_redirected_call_reuses_the_provider_of_a_republic_model(provider_ async with provider_service.client() as client: agent = Agent(store=InMemoryTapeStore()) - @agent.events.on(BeforeLlmCall) - def reroute(event: BeforeLlmCall) -> None: + @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: @@ -220,8 +222,10 @@ def test_tapes_share_the_agent_store() -> None: @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": @@ -231,6 +235,34 @@ async def test_leaving_run_early_closes_the_stream_and_merges_the_fork(provider_ 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 @@ -305,8 +337,8 @@ def greet(name: str, punctuation: str = "!") -> str: agent = Agent(tools=[Tool.from_callable(greet, name="greet")], command_prefix="/") calls: list[tuple[str, dict[str, Any]]] = [] - @agent.events.on(BeforeToolCall) - def deny_bob(event: BeforeToolCall) -> None: + @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") diff --git a/tests/test_core_agent_step.py b/tests/test_core_agent_step.py index 00145bef..0ef1ecff 100644 --- a/tests/test_core_agent_step.py +++ b/tests/test_core_agent_step.py @@ -11,11 +11,11 @@ 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 bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry -from bub.tools import Tool from tests.model_fakes import ProviderService, StepRunner, chat_events, sse, tool_events 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_environment.py b/tests/test_environment.py index 1169996f..4d5b066d 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -8,12 +8,12 @@ import bub.builtin.tools as builtin_tools from bub.builtin.environment import LocalEnvironment, LocalProcess, environment_from_state from bub.builtin.shell_manager import ShellManager +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import ToolContext from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, Environment, Process from bub.framework import BubFramework from bub.hooks import hookimpl -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import ToolContext class MemoryEnvironment(Environment): 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_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_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_provider_messages.py b/tests/test_provider_messages.py index ad0f21fa..1b7bb911 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -12,12 +12,12 @@ 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 bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import Tool from tests.model_fakes import ProviderService, StepRunner, chat_events, sse @@ -177,8 +177,8 @@ async def test_fresh_process_replays_tool_signatures_to_the_provider( from bub.core.context import default_tape_context from tests.model_fakes import StepRunner from bub.builtin.settings import AgentSettings -from bub.store import FileTapeStore, AsyncTapeStoreAdapter -from bub.tape import Tape +from bub.core.store import FileTapeStore, AsyncTapeStoreAdapter +from bub.core.tape import Tape async def main(): bodies = [] def respond(request): diff --git a/tests/test_sdk_isolation.py b/tests/test_sdk_isolation.py index eab0d938..72407ebb 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -7,13 +7,12 @@ 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.tape import session_tape_name +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.hooks import hookimpl -from bub.store import InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tape import Tape -from bub.tools import Tool, ToolContext def _reply() -> AsyncStreamEvents: diff --git a/tests/test_session_settings.py b/tests/test_session_settings.py index bc49ab9f..c6c19886 100644 --- a/tests/test_session_settings.py +++ b/tests/test_session_settings.py @@ -5,9 +5,9 @@ import pytest from bub.builtin.settings import load_session_settings, set_session_setting -from bub.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: diff --git a/tests/test_settings.py b/tests/test_settings.py index 632fd546..41926d9a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -11,9 +11,9 @@ from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, load_settings from bub.builtin.spill import SpillSettings from bub.configure import ensure_config +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext from bub.prompt import to_content -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext from tests.model_fakes import ProviderService, StepRunner, chat_events, sse diff --git a/tests/test_spill.py b/tests/test_spill.py index 2e870955..d89286bd 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -21,22 +21,22 @@ ) from bub.builtin.tools import render_tools_prompt from bub.core.context import default_tape_context -from bub.core.events import AfterToolCall, EventBus +from bub.core.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 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 def _spill_events() -> EventBus: - async def spill(event: AfterToolCall) -> None: + async def spill(event: ToolCallFinished) -> None: await BuiltinImpl.after_tool_call(None, event.call, event.result, event.state) # type: ignore[arg-type] events = EventBus() - events.on(AfterToolCall, spill) + events.on(ToolCallFinished, spill) return events @@ -438,7 +438,7 @@ async def test_result_spill_without_tool_availability_keeps_full_output(tmp_path async def test_spill_bounds_text_of_content_results_and_keeps_media(tmp_path: Path) -> None: import republic - from bub.tools import content_result, result_content + 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) 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 21fa8be2..e0874722 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -6,12 +6,12 @@ import republic from bub.core.context import default_tape_context -from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry, to_messages +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", 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 b84b0819..e0658693 100644 --- a/tests/test_tool_loading.py +++ b/tests/test_tool_loading.py @@ -7,8 +7,8 @@ import pytest from bub.builtin.agent import BubAgent +from bub.core.tools import Tool from bub.framework import BubFramework -from bub.tools import Tool from tests.model_fakes import ProviderService diff --git a/tests/test_tools.py b/tests/test_tools.py index 99d07c5e..08441506 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -7,11 +7,11 @@ from loguru import logger from pydantic import BaseModel -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus +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 -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool class EchoInput(BaseModel): @@ -149,8 +149,8 @@ async def test_tool_executor_logs_arguments_changed_by_handlers(monkeypatch: pyt monkeypatch.setattr(logger, "info", lambda message, *args: messages.append(message.format(*args))) events = EventBus() - @events.on(BeforeToolCall) - def rewrite(event: BeforeToolCall) -> None: + @events.on(ToolCallStarted) + def rewrite(event: ToolCallStarted) -> None: event.call = replace(event.call, arguments={"value": "rewritten"}) echo = Tool.from_callable(lambda value: value, name="tests.echo") @@ -233,8 +233,8 @@ async def test_executor_passes_tool_context_to_context_tools_and_hooks(tmp_path) seen: dict[str, Any] = {} events = EventBus() - events.on(BeforeToolCall, lambda event: seen.update(before=event.call.context)) - events.on(AfterToolCall, lambda event: seen.update(after=(event.call.context, event.result.result))) + 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} @@ -252,8 +252,8 @@ def mode(*, context: ToolContext) -> dict[str, bool]: @pytest.mark.asyncio async def test_executor_execute_returns_the_result_or_raises_the_tool_error() -> None: events = EventBus() - finished: list[AfterToolCall] = [] - events.on(AfterToolCall, finished.append) + finished: list[ToolCallFinished] = [] + events.on(ToolCallFinished, finished.append) executor = ToolExecutor(events, render=False) def failing_tool() -> str: diff --git a/tests/test_tracing.py b/tests/test_tracing.py index a23352d6..ff820122 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -15,14 +15,13 @@ from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.core.context import default_tape_context -from bub.core.tape import session_tape_name +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 @@ -257,8 +256,8 @@ async def cleanup() -> 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=[]) @@ -323,7 +322,7 @@ def find_spec(self, fullname, path=None, target=None): sys.meta_path.insert(0, BlockTelemetry()) from bub import tracing from bub.builtin.agent import BubAgent -from bub.tools import Tool, ToolExecutor +from bub.core.tools import Tool, ToolExecutor assert tracing.otel is None async def main(): span = tracing.Span('noop') diff --git a/uv.lock b/uv.lock index 6480ddab..fa9d5ef3 100644 --- a/uv.lock +++ b/uv.lock @@ -185,29 +185,42 @@ wheels = [ name = "bub" source = { editable = "." } dependencies = [ + { name = "loguru" }, + { name = "pydantic" }, + { name = "republic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +app = [ { name = "aiohttp" }, { 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 = "republic" }, { 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" }, @@ -217,6 +230,7 @@ trace = [ [package.dev-dependencies] dev = [ + { name = "bub", extra = ["app"] }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, @@ -228,9 +242,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.14.3" }, - { 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" }, @@ -239,22 +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 = "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", specifier = ">=14.3.4" }, - { name = "typer", specifier = ">=0.24.1" }, + { 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" }, 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..a3078950 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" diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index c7dfe9e1..1e2fc35d 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -20,6 +20,8 @@ Install Bub in your Python 3.12+ project: uv add bub ``` +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 @@ -32,8 +34,9 @@ from pathlib import Path import republic -from bub.core import Agent, Tool +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]: @@ -106,7 +109,7 @@ 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 `BeforeLlmCall` handler may redirect a call to another `provider:model` spec: a model of the same provider reuses +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. For a policy of your own, such as fallbacks, per-provider credentials, or a timeout for the whole call, pass an @@ -117,14 +120,14 @@ provider name and a `republic.Stream`. Bub's own `bub.builtin.model.BubModel` is ## Tools `Tool.from_callable()` derives a schema from annotations and a description from the docstring, without registering -the tool globally. The `@tool` decorator from `bub.core` builds the same `Tool` and also adds it to Bub's global +the tool globally. The `@tool` decorator from `bub.core.tools` builds the same `Tool` and also adds it to Bub's global registry, which Bub's own agent snapshots. A tool created with `context=True` receives a `ToolContext` with the run's `tape`, turn `state`, `run_id`, and event bus: ```python -from bub.core import ToolContext +from bub.core.tools import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -207,7 +210,7 @@ async with agent.run(",lookup_order order_id=A123", model=model, tape="customer- 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 -`BeforeToolCall` and `AfterToolCall` like model tool calls, so tool handlers apply to them; their `ToolContext` has +`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. ## Events @@ -215,17 +218,17 @@ Prefixes must be non-empty and contain no whitespace. Prompts with media are nev The loop reports its progress as events from `bub.core.events`; register handlers to observe or steer it. ```python -from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision +from bub.core.events import ToolCallFinished, ToolCallStarted, StepFinished, ToolCallDecision -@agent.events.on(BeforeToolCall) -def guard(event: BeforeToolCall) -> None: +@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(AfterToolCall) -async def audit(event: AfterToolCall) -> None: +@agent.events.on(ToolCallFinished) +async def audit(event: ToolCallFinished) -> None: print("tool", event.call.tool, event.result.duration_ms, "ms") @@ -235,12 +238,13 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat | 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 | -| `BeforeLlmCall` | Before each model call | replace `event.request` (model, messages, `max_tokens`), or set `event.decision = LlmCallDecision.finish(text)` to skip the call | -| `AfterLlmCall` | After each completed or failed model call | observe text, tool calls, usage, and errors | -| `BeforeToolCall` | Before each tool call | replace `event.call` to change arguments, or set `ToolCallDecision.replace(result)` / `deny(message)` | -| `AfterToolCall` | After each tool call, including denied and replaced ones | assign `event.result.result` to change what the model sees | -| `ChatCompleted` | A step's exchange is ready to record | 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)` @@ -261,7 +265,8 @@ Inside a tool, the run's bus is `context.events`: ```python from dataclasses import dataclass -from bub.core import AgentEvent, ToolContext +from bub.core.events import AgentEvent +from bub.core.tools import ToolContext @dataclass(kw_only=True) @@ -351,7 +356,7 @@ the turn state: | `continue_prompt(tape, prompt, state)` | `None` | Return a user message for the next step after tool calls. | ```python -from bub.core import Tape +from bub.core.tape import Tape class TriageAgent(Agent): diff --git a/website/src/content/docs/docs/build/tools.mdx b/website/src/content/docs/docs/build/tools.mdx index 1cf8b08d..506883b9 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 @@ -60,7 +60,7 @@ Code mode lets the model call tools from Python instead of one tool call at a ti - 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.tools.content_result`) and sent to the model as a multimodal tool message; spilling bounds only its text. +- 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. @@ -101,10 +101,10 @@ Provide one per session with the `provide_environment(session_id, workspace)` ho ## 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] = {} ``` @@ -143,7 +143,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 55ecedaf..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 @@ -69,13 +69,13 @@ Before the first turn on a tape, `TapeService.ensure_bootstrap_anchor` checks fo `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, converted with `bub.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. +- `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 `republic.tool(...)` message per result, paired with the preceding `tool_call` entry's calls by position. Results without a recorded call are skipped. 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.tape.to_messages` converts stored message payloads. +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/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/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/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/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx index 5c6f6dc0..e0be40b0 100644 --- a/website/src/content/docs/docs/reference/hooks.mdx +++ b/website/src/content/docs/docs/reference/hooks.mdx @@ -36,8 +36,8 @@ 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 | 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_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. | @@ -128,13 +128,13 @@ Each `provide_tape_sidecar` implementation contributes one named capability back ### Agent-loop hooks (`PluginAgentHooks`) -The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` events (`bub.core.events`), not `call_first`/`call_many`, with three deliberate differences: +The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `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 in `src/bub/core/events.py` and are re-exported by `bub.hooks.interception`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. +Payload dataclasses (`LLMCallRequest`, `LLMCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LLMCallDecision`) live in `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/types.mdx b/website/src/content/docs/docs/reference/types.mdx index e783ebf8..54eb23cd 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -86,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 ( - 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 index 738fe60d..e068ed67 100644 --- a/website/src/content/docs/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/docs/tutorials/fastapi.mdx @@ -32,8 +32,9 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.core import Agent, Tool +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]: 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..e56336c7 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" diff --git a/website/src/content/docs/zh-cn/docs/build/sdk.md b/website/src/content/docs/zh-cn/docs/build/sdk.md index 1b67ed00..3097e792 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -19,6 +19,8 @@ Bub 自身的运行时也构建在这个内核之上:`bub.builtin.BubAgent` uv add bub ``` +这只会安装内核。如果还需要 `bub` 命令、channel 或插件,请安装 `app` extra(`bub[app]`)。 + 下文描述的是当前源码中的 API;请使用包含这些接口的发布版本,或安装当前源码。 ## 创建 Agent @@ -31,8 +33,9 @@ from pathlib import Path import republic -from bub.core import Agent, Tool +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]: @@ -103,7 +106,7 @@ async with agent.run(prompt, tape="t") as stream: ... ``` -`options` 是随本次运行每次模型调用发送的 `republic.ChatOptions`;循环会加入本步的工具。`BeforeLlmCall` +`options` 是随本次运行每次模型调用发送的 `republic.ChatOptions`;循环会加入本步的工具。`LLMCallStarted` 处理器可以把一次调用重定向到其他 `provider:model`:同一 provider 的模型复用运行模型的客户端,其他 provider 使用从 Republic 环境变量配置的客户端。 @@ -114,13 +117,13 @@ Bub 自己的 `bub.builtin.model.BubModel` 就是这样的客户端。 ## 工具 -`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core` 中的 +`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core.tools` 中的 `@tool` 装饰器生成同样的 `Tool`,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。 以 `context=True` 创建的工具会收到 `ToolContext`,其中包含本次运行的 `tape`、turn `state`、`run_id` 和事件总线: ```python -from bub.core import ToolContext +from bub.core.tools import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -197,7 +200,7 @@ async with agent.run(",lookup_order order_id=A123", model=model, tape="customer- ``` 首尾空白会被忽略,且只去掉一个前缀;只有前缀时会抛出 `ValueError`。前缀必须非空且不含空白。带媒体的 prompt 不会作为命令。 -命令调用工具时和模型调用工具一样发出 `BeforeToolCall` 和 `AfterToolCall`,因此工具处理器同样作用于它们;其 +命令调用工具时和模型调用工具一样发出 `ToolCallStarted` 和 `ToolCallFinished`,因此工具处理器同样作用于它们;其 `ToolContext` 的 `command` 为真。 `agent.run_command(tape, line)` 可以在已打开的 tape 上直接执行命令。 @@ -206,17 +209,17 @@ async with agent.run(",lookup_order order_id=A123", model=model, tape="customer- 循环通过 `bub.core.events` 中的事件报告进度;注册处理器即可观察或干预它。 ```python -from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision +from bub.core.events import ToolCallFinished, ToolCallStarted, StepFinished, ToolCallDecision -@agent.events.on(BeforeToolCall) -def guard(event: BeforeToolCall) -> None: +@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(AfterToolCall) -async def audit(event: AfterToolCall) -> None: +@agent.events.on(ToolCallFinished) +async def audit(event: ToolCallFinished) -> None: print("tool", event.call.tool, event.result.duration_ms, "ms") @@ -226,12 +229,13 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat | 事件 | 时机 | 处理器可以 | | ------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `RunStarted` | 一次运行开始 | 观察模型 spec、prompt 和运行 metadata | +| `RunFinished` | 运行的 stream 关闭 | 观察状态(`ok`、`error` 或 `cancelled`)、步数、耗时和错误 | | `StepStarted` / `StepFinished` | 一个模型步骤开始 / 以某个状态结束 | 观察 | -| `BeforeLlmCall` | 每次模型调用之前 | 替换 `event.request`(模型、消息、`max_tokens`),或设置 `event.decision = LlmCallDecision.finish(text)` 跳过调用 | -| `AfterLlmCall` | 每次模型调用完成或失败之后 | 观察文本、工具调用、用量和错误 | -| `BeforeToolCall` | 每次工具调用之前 | 替换 `event.call` 以修改参数,或设置 `ToolCallDecision.replace(result)` / `deny(message)` | -| `AfterToolCall` | 每次工具调用之后,包括被拒绝和被替换的调用 | 赋值 `event.result.result` 以改变模型看到的结果 | -| `ChatCompleted` | 一个步骤的对话已可记录 | 观察 | +| `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)` 以装饰器形式完成同样的注册, @@ -250,7 +254,8 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat ```python from dataclasses import dataclass -from bub.core import AgentEvent, ToolContext +from bub.core.events import AgentEvent +from bub.core.tools import ToolContext @dataclass(kw_only=True) @@ -335,7 +340,7 @@ async with agent.run("Check order A123", model=model, tape="customer-42") as str | `continue_prompt(tape, prompt, state)` | `None` | 在工具调用后返回下一步的用户消息。 | ```python -from bub.core import Tape +from bub.core.tape import Tape class TriageAgent(Agent): 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 e7b82f38..cf675b49 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 @@ -60,7 +60,7 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 - 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.tools.content_result` 构造),并作为多模态工具消息发给模型;spill 只截断其中的文本。 +- 仅代码可用的工具 `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 会退回直接调用工具的方式。 @@ -102,10 +102,10 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 ## 工具如何变得可用 -`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] = {} ``` @@ -144,7 +144,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 c0d730cc..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 @@ -69,13 +69,13 @@ spill 写入以 `spill.write` event 记录。框架管理的生命周期结果 `default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并返回 `republic.Message` 列表。映射规则如下: - `anchor` → 形如 `[Anchor created: ]: ` 的 `assistant` 消息。 -- `message` → 用 `bub.tape.to_message` 转换 entry payload。新的用户消息以 `republic.Message.to_dict()` 格式存储;旧版 Bub payload(媒体 URL、`tool_call_id`、`reasoning`、`provider_data`)仍可读取。 +- `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 一条 `republic.tool(...)` 消息,按位置与前一个 `tool_call` entry 中的 call 配对;没有对应 call 的 result 会被跳过。 已保存的 reasoning 与 provider data 总是会带上;Republic 只会把它们发回接受这些数据的 API format。 -context selector 本身是个 hook(`build_tape_context`)。自定义 `select` 必须返回 Republic 消息(或返回它们的 awaitable);`bub.tape.to_messages` 可转换已存储的消息 payload。插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 +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/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/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/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/hooks.mdx b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx index fbace925..6c9686de 100644 --- a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx @@ -36,8 +36,8 @@ 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 决定 | 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_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`。 | @@ -127,13 +127,13 @@ Windows 当前只清理直接 shell 进程。 ### Agent-loop 钩子(`PluginAgentHooks`) -四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` 事件,见 `bub.core.events`)分发,而不是 `call_first`/`call_many`,有三处刻意差异: +四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `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/core/events.py`,并由 `bub.hooks.interception` 重新导出。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 +载荷数据类(`LLMCallRequest`、`LLMCallResult`、`ToolCall`、`ToolCallDecision`、`ToolCallResult`、`LLMCallDecision`)位于 `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/types.mdx b/website/src/content/docs/zh-cn/docs/reference/types.mdx index edd76252..5dc5da0c 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -86,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 ( - 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 index b425630f..50513580 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx @@ -32,8 +32,9 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.core import Agent, Tool +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]: 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: