From c10a88013ff4231670c0461c2616a8abd187228f Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 15:06:51 +0800 Subject: [PATCH 01/11] refactor: make bub.core independent of the rest of bub - Import bub's top-level names lazily so `import bub.core` no longer loads the framework, CLI or settings; accessing them still imports bub.framework first so `.env` is loaded as before. - Move errors, prompt, tracing, streaming and TurnState into bub.core; the old top-level modules remain aliases of the core modules. - Move FileTapeStore's newest-first, deduplicated fuzzy search into bub.builtin.store.FuzzyFileTapeStore, so bub.core no longer needs yaml or rapidfuzz. The builtin tape store keeps the existing behavior. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- src/bub/__init__.py | 45 ++- src/bub/__main__.py | 2 +- src/bub/builtin/agent.py | 4 +- src/bub/builtin/codemode/__init__.py | 4 +- src/bub/builtin/codemode/code_runner.py | 2 +- src/bub/builtin/codemode/republic_bridge.py | 2 +- src/bub/builtin/hook_impl.py | 10 +- src/bub/builtin/model.py | 2 +- src/bub/builtin/spill.py | 2 +- src/bub/builtin/store.py | 87 ++++++ src/bub/channels/base.py | 2 +- src/bub/channels/cli/__init__.py | 2 +- src/bub/channels/contracts.py | 2 +- src/bub/channels/manager.py | 2 +- src/bub/core/agent.py | 10 +- src/bub/core/errors.py | 40 +++ src/bub/core/events.py | 6 +- src/bub/core/prompt.py | 58 ++++ src/bub/core/recorder.py | 2 +- src/bub/core/store.py | 76 +---- src/bub/core/streaming.py | 92 ++++++ src/bub/core/system_prompt.py | 4 +- src/bub/core/tape.py | 4 +- src/bub/core/tools.py | 6 +- src/bub/core/tracing.py | 259 +++++++++++++++++ src/bub/core/turn.py | 5 + src/bub/errors.py | 42 +-- src/bub/framework.py | 6 +- src/bub/hooks/runtime.py | 4 +- src/bub/hooks/specs.py | 6 +- src/bub/prompt.py | 60 +--- src/bub/streaming.py | 94 +------ src/bub/tracing.py | 261 +----------------- src/bub/turn.py | 7 +- tests/model_fakes.py | 8 +- tests/test_agent_hooks.py | 10 +- tests/test_builtin_agent.py | 11 +- tests/test_builtin_codex.py | 8 +- tests/test_builtin_hook_impl.py | 12 +- tests/test_builtin_tools.py | 8 +- tests/test_channels.py | 8 +- tests/test_codemode.py | 14 +- tests/test_core_agent.py | 2 +- tests/test_core_agent_step.py | 10 +- tests/test_core_boundary.py | 60 ++++ tests/test_environment.py | 6 +- tests/test_errors.py | 2 +- tests/test_file_tape_store_entry_ids.py | 6 +- tests/test_fork_store_merge_back.py | 4 +- tests/test_framework.py | 2 +- tests/test_hook_runtime.py | 2 +- tests/test_image_message.py | 2 +- tests/test_otlp.py | 8 +- tests/test_provider_messages.py | 14 +- tests/test_sdk_isolation.py | 9 +- tests/test_session_settings.py | 6 +- tests/test_settings.py | 6 +- tests/test_spill.py | 8 +- tests/test_subagent_tool.py | 4 +- tests/test_tape.py | 4 +- tests/test_tape_search_output.py | 2 +- tests/test_tool_loading.py | 2 +- tests/test_tools.py | 8 +- tests/test_tracing.py | 15 +- .../docs/docs/concepts/tape-and-context.mdx | 2 +- .../src/content/docs/docs/reference/types.mdx | 10 +- .../zh-cn/docs/concepts/tape-and-context.mdx | 2 +- .../docs/zh-cn/docs/reference/types.mdx | 10 +- 69 files changed, 817 insertions(+), 684 deletions(-) create mode 100644 src/bub/builtin/store.py create mode 100644 src/bub/core/errors.py create mode 100644 src/bub/core/prompt.py create mode 100644 src/bub/core/streaming.py create mode 100644 src/bub/core/tracing.py create mode 100644 src/bub/core/turn.py create mode 100644 tests/test_core_boundary.py diff --git a/AGENTS.md b/AGENTS.md index 32bb991f..d76b34e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,12 @@ Core code lives under `src/`: - `src/bub/__main__.py`: Typer CLI entrypoint. -- `src/bub/core/`: agent kernel with no settings or plugin dependencies: agent loop over a tape store (`agent.py`), model protocol and Republic chat model adapter (`model.py`), agent events and their bus (`events.py`), the tape recorder handler (`recorder.py`), system prompt sections (`system_prompt.py`), command prompts (`commands.py`), tools, tape, store, and tape context. +- `src/bub/core/`: agent kernel with no settings or plugin dependencies: agent loop over a tape store (`agent.py`), model protocol and Republic chat model adapter (`model.py`), agent events and their bus (`events.py`), the tape recorder handler (`recorder.py`), system prompt sections (`system_prompt.py`), command prompts (`commands.py`), tools, tape, store, and tape context, plus the kernel vocabulary it shares with the framework (`errors.py`, `prompt.py`, `streaming.py`, `tracing.py`, `turn.py`). Nothing in `bub.core` imports the rest of `bub`; the old top-level paths (`bub.errors`, `bub.store`, ...) are compatibility aliases. - `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/{envelope,turn,model_selection}.py`: small framework vocabulary owned by each concern. +- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.core.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, tape services, and the fuzzy-searching file tape store. - `src/bub/channels/`: channel abstractions plus CLI and Telegram adapters. - `src/bub/skills.py` / `src/bub/tools.py`: skill discovery and tool registry. - `src/skills/`: bundled skills shipped with Bub. diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 28666a72..429b1842 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 + +if TYPE_CHECKING: + from bub.configure import Settings, config, ensure_config + from bub.core.tools import tool + from bub.framework import BubFramework + from bub.hooks import hookimpl -from 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 + home: Path __all__ = [ "BubFramework", @@ -24,6 +31,14 @@ "tool", ] +_EXPORTS = { + "BubFramework": "bub.framework", + "Settings": "bub.configure", + "config": "bub.configure", + "ensure_config": "bub.configure", + "hookimpl": "bub.hooks", +} + try: __version__ = import_module("bub._version").version except ModuleNotFoundError: @@ -33,13 +48,19 @@ __version__ = "0.0.0" -if TYPE_CHECKING: - home: Path - +def __getattr__(name: str) -> Any: + if name == "tool": + from bub.core.tools import tool -def __getattr__(name: str): + return tool + if name != "home" and name not in _EXPORTS: + raise AttributeError(f"module {__name__} has no attribute {name}") + # The framework loads ``.env`` on import, before settings, plugins or ``home`` read the environment. + framework = import_module("bub.framework") if name == "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}") + return framework.DEFAULT_HOME + value = getattr(import_module(_EXPORTS[name]), name) + globals()[name] = value + return value diff --git a/src/bub/__main__.py b/src/bub/__main__.py index 5494d737..4b275a05 100644 --- a/src/bub/__main__.py +++ b/src/bub/__main__.py @@ -24,7 +24,7 @@ def _instrument_bub(level: str) -> None: logger.add(sys.stderr, level=level, colorize=True, diagnose=False) if os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): - from bub.tracing import configure_otlp + from bub.core.tracing import configure_otlp try: configure_otlp() diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 8f6f6cd7..c6a4006e 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -9,14 +9,14 @@ import bub.core as core from bub.builtin.settings import load_settings +from bub.core.prompt import LegacyPrompt, UserContent, prompt_text, to_content from bub.core.store import AsyncTapeStore, TapeStore +from bub.core.streaming import AsyncStreamEvents, StreamState from bub.core.tape import Tape, session_tape_name from bub.core.tools import REGISTRY, Tool, model_tools from bub.envelope import field_of from bub.framework import BubFramework -from bub.prompt import LegacyPrompt, UserContent, prompt_text, to_content from bub.skills import discover_skills, render_skills_prompt -from bub.streaming import AsyncStreamEvents, StreamState from bub.turn import TurnState from bub.utils import workspace_from_state diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py index 0a366a67..02060a2f 100644 --- a/src/bub/builtin/codemode/__init__.py +++ b/src/bub/builtin/codemode/__init__.py @@ -19,10 +19,10 @@ from bub.builtin.codemode.republic_bridge import REPUBLIC_CALL_PREFIX, RepublicSession, decode_value, encode_value from bub.builtin.environment import environment_from_state from bub.builtin.settings import set_session_setting +from bub.core.errors import BubError, ErrorKind +from bub.core.prompt import UserContent from bub.core.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool from bub.environment import CallTool, CodeFailed -from bub.errors import BubError, ErrorKind -from bub.prompt import UserContent RUN_CODE_TOOL_NAME = "run_code" CODE_MODE_STATE_KEY = "code_mode" diff --git a/src/bub/builtin/codemode/code_runner.py b/src/bub/builtin/codemode/code_runner.py index faffeb73..52eb0226 100644 --- a/src/bub/builtin/codemode/code_runner.py +++ b/src/bub/builtin/codemode/code_runner.py @@ -18,8 +18,8 @@ from pydantic_core import to_jsonable_python from bub.builtin.codemode import code_runner_child +from bub.core.errors import BubError, ErrorKind from bub.environment import CodeFailed -from bub.errors import BubError, ErrorKind if TYPE_CHECKING: from bub.environment import CallTool, Environment, Process diff --git a/src/bub/builtin/codemode/republic_bridge.py b/src/bub/builtin/codemode/republic_bridge.py index 902d3ceb..8eb1406d 100644 --- a/src/bub/builtin/codemode/republic_bridge.py +++ b/src/bub/builtin/codemode/republic_bridge.py @@ -29,8 +29,8 @@ UsageDelta, ) +from bub.core.errors import BubError, ErrorKind from bub.core.tools import ToolContext -from bub.errors import BubError, ErrorKind REPUBLIC_CALL_PREFIX = "republic." REPUBLIC_KEY = "$republic" diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 77615b0d..1885f59b 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -20,19 +20,19 @@ from bub.channels.message import ChannelMessage, MediaItem from bub.core.commands import strip_command_prefix from bub.core.context import default_tape_context +from bub.core.errors import BubError from bub.core.events import ToolCall, ToolCallDecision, ToolCallResult +from bub.core.prompt import UserContent from bub.core.sidecars import TapeSidecar from bub.core.store import TapeStore +from bub.core.streaming import AsyncStreamEvents from bub.core.tape import TapeContext, session_tape_name from bub.core.tools import is_content_result, render_result from bub.envelope import Envelope, content_of, field_of from bub.environment import Environment -from bub.errors import BubError from bub.framework import BubFramework from bub.hooks import hookimpl from bub.model_selection import ModelChoice, ModelOptions -from bub.prompt import UserContent -from bub.streaming import AsyncStreamEvents from bub.turn import TurnState AGENTS_FILE_NAME = "AGENTS.md" @@ -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/model.py b/src/bub/builtin/model.py index 23e837d4..2d1452cc 100644 --- a/src/bub/builtin/model.py +++ b/src/bub/builtin/model.py @@ -11,7 +11,7 @@ import republic from loguru import logger -from bub.tracing import current_span, event +from bub.core.tracing import current_span, event @dataclass(frozen=True) diff --git a/src/bub/builtin/spill.py b/src/bub/builtin/spill.py index 2ebea5d9..46c614cb 100644 --- a/src/bub/builtin/spill.py +++ b/src/bub/builtin/spill.py @@ -12,9 +12,9 @@ from bub import config from bub.configure import Settings +from bub.core.errors import BubError, ErrorKind from bub.core.tape import Tape, TapeEntry, TapeQuery from bub.core.tools import ToolContext, tool -from bub.errors import BubError, ErrorKind SPILL_READ_TOOL_NAME = "spill.read" SPILL_READ_MODEL_NAME = SPILL_READ_TOOL_NAME.replace(".", "_") diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py new file mode 100644 index 00000000..2da18e2c --- /dev/null +++ b/src/bub/builtin/store.py @@ -0,0 +1,87 @@ +"""File tape store with the newest-first, deduplicated and fuzzy text search used by the builtin tools.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import replace + +from bub.core.store import FileTapeStore, TapeQuery +from bub.core.tape import TapeEntry +from bub.utils import get_entry_text + +WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") +MIN_FUZZY_QUERY_LENGTH = 3 +MIN_FUZZY_SCORE = 80 +MAX_FUZZY_CANDIDATES = 128 + + +class FuzzyFileTapeStore(FileTapeStore): + """:class:`~bub.core.store.FileTapeStore` whose text queries also match approximately.""" + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: + if not query._query: + result: Iterable[TapeEntry] = super().fetch_all(query) + return result + unlimited_query = replace(query, _limit=None) + entries: Iterable[TapeEntry] = super().fetch_all(unlimited_query) + return self._filter_entries(list(entries), query._query, query._limit or 20) + + def _filter_entries(self, entries: list[TapeEntry], query: str, limit: int) -> list[TapeEntry]: + normalized_query = query.strip().lower() + if not normalized_query: + return [] + results: list[TapeEntry] = [] + seen: set[str] = set() + + count = 0 + for entry in reversed(entries): + payload_text = get_entry_text(entry).lower() + if payload_text in seen: + continue + seen.add(payload_text) + + if normalized_query in payload_text or self._is_fuzzy_match(normalized_query, payload_text): + results.append(entry) + count += 1 + if count >= limit: + break + return results + + @staticmethod + def _is_fuzzy_match(normalized_query: str, payload_text: str) -> bool: + from rapidfuzz import fuzz, process + + if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH: + return False + + query_tokens = WORD_PATTERN.findall(normalized_query) + if not query_tokens: + return False + query_phrase = " ".join(query_tokens) + window_size = len(query_tokens) + + source_tokens = WORD_PATTERN.findall(payload_text) + if not source_tokens: + return False + + candidates: list[str] = [] + for token in source_tokens: + candidates.append(token) + if len(candidates) >= MAX_FUZZY_CANDIDATES: + break + + if window_size > 1: + max_window_start = len(source_tokens) - window_size + 1 + for idx in range(max(0, max_window_start)): + candidates.append(" ".join(source_tokens[idx : idx + window_size])) + if len(candidates) >= MAX_FUZZY_CANDIDATES: + break + + best_match = process.extractOne( + query_phrase, + candidates, + scorer=fuzz.WRatio, + score_cutoff=MIN_FUZZY_SCORE, + ) + return best_match is not None diff --git a/src/bub/channels/base.py b/src/bub/channels/base.py index 6b0f6018..36587f9a 100644 --- a/src/bub/channels/base.py +++ b/src/bub/channels/base.py @@ -5,8 +5,8 @@ from bub.channels.admission import AdmitDecision, TurnSnapshot from bub.channels.message import ChannelMessage +from bub.core.streaming import StreamEvent from bub.envelope import Envelope -from bub.streaming import StreamEvent class Channel(ABC): diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 96a5adac..9d73b08b 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -40,10 +40,10 @@ from bub.channels.message import ChannelMessage from bub.core.commands import strip_command_prefix from bub.core.events import AfterToolCall, BeforeToolCall, EventBus +from bub.core.streaming import StreamEvent from bub.core.tape import TapeInfo, session_tape_name from bub.core.tools import REGISTRY from bub.envelope import Envelope, field_of -from bub.streaming import StreamEvent _GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment] _PROMPT_REFRESH_INTERVAL: float = SPINNERS["dots"]["interval"] / 1000.0 # type: ignore[operator] diff --git a/src/bub/channels/contracts.py b/src/bub/channels/contracts.py index 76ed016a..05e00bbc 100644 --- a/src/bub/channels/contracts.py +++ b/src/bub/channels/contracts.py @@ -5,8 +5,8 @@ from collections.abc import AsyncIterable, Callable, Coroutine from typing import TYPE_CHECKING, Any, Protocol +from bub.core.streaming import StreamEvent from bub.envelope import Envelope -from bub.streaming import StreamEvent if TYPE_CHECKING: from bub.channels.admission import AdmitDecision, TurnSnapshot diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index 06059e57..cf032b1d 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -15,9 +15,9 @@ from bub.channels.handler import BufferedMessageHandler from bub.channels.message import ChannelMessage from bub.configure import Settings, ensure_config +from bub.core.streaming import StreamEvent from bub.envelope import Envelope, content_of, field_of from bub.framework import BubFramework -from bub.streaming import StreamEvent from bub.turn import TurnState from bub.utils import wait_until_stopped diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py index 318e6e88..a5bf187b 100644 --- a/src/bub/core/agent.py +++ b/src/bub/core/agent.py @@ -18,6 +18,7 @@ from bub.core.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix from bub.core.context import default_tape_context +from bub.core.errors import BubError, ErrorKind from bub.core.events import ( AfterLlmCall, AgentEvent, @@ -33,17 +34,16 @@ TapeEvent, ) from bub.core.model import Model, ModelClient, is_context_length_error, model_client +from bub.core.prompt import UserContent from bub.core.recorder import TapeRecorder from bub.core.sidecars import TapeSidecar from bub.core.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store +from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.core.system_prompt import PromptContext, PromptSection, SystemPrompt from bub.core.tape import Tape, TapeContext from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools -from bub.errors import BubError, ErrorKind -from bub.prompt import UserContent -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.tracing import Span, current_span -from bub.turn import TurnState +from bub.core.tracing import Span, current_span +from bub.core.turn import TurnState MAX_AUTO_HANDOFF_RETRIES = 1 diff --git a/src/bub/core/errors.py b/src/bub/core/errors.py new file mode 100644 index 00000000..141bd03f --- /dev/null +++ b/src/bub/core/errors.py @@ -0,0 +1,40 @@ +"""Errors reported by Bub's model and tool execution paths.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + + +class ErrorKind(StrEnum): + """Stable error kinds for execution decisions.""" + + INVALID_INPUT = "invalid_input" + CONFIG = "config" + PROVIDER = "provider" + TOOL = "tool" + TEMPORARY = "temporary" + NOT_FOUND = "not_found" + UNKNOWN = "unknown" + + +@dataclass +class BubError(Exception): + """Public error type; exception machinery must be able to assign traceback fields.""" + + kind: ErrorKind + message: str + details: dict[str, Any] | None = None + + def __str__(self) -> str: + return f"[{self.kind.value}] {self.message}" + + def as_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "kind": self.kind.value, + "message": self.message, + } + if self.details: + payload["details"] = self.details + return payload diff --git a/src/bub/core/events.py b/src/bub/core/events.py index 7eafebcb..90cc2cf1 100644 --- a/src/bub/core/events.py +++ b/src/bub/core/events.py @@ -10,9 +10,9 @@ import republic -from bub.errors import BubError -from bub.prompt import UserContent -from bub.turn import TurnState +from bub.core.errors import BubError +from bub.core.prompt import UserContent +from bub.core.turn import TurnState if TYPE_CHECKING: from bub.core.tape import Tape diff --git a/src/bub/core/prompt.py b/src/bub/core/prompt.py new file mode 100644 index 00000000..9b1a5378 --- /dev/null +++ b/src/bub/core/prompt.py @@ -0,0 +1,58 @@ +"""User prompt content and the legacy prompt forms accepted for compatibility.""" + +from __future__ import annotations + +from typing import Any + +import republic + +from bub.core.errors import BubError, ErrorKind + +type UserContent = str | republic.Image | republic.Audio | republic.Video +type LegacyPrompt = str | list[dict[str, Any]] +"""Prompt forms used before ``list[UserContent]``: text, or text and media content blocks.""" + +_MEDIA_TYPES = (republic.Image, republic.Audio, republic.Video) + + +def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: + """Return user content, converting text and legacy content blocks by their value.""" + from bub.core.tape import to_message + + if isinstance(prompt, str): + return [prompt] + if not isinstance(prompt, list): + raise BubError(ErrorKind.INVALID_INPUT, "Expected user content, text, or a list of content blocks.") + content: list[UserContent] = [] + for item in prompt: + if isinstance(item, str | republic.Image | republic.Audio | republic.Video): + content.append(item) + elif isinstance(item, dict): + for part in to_message({"role": "user", "content": [item]}).parts: + if isinstance(part, republic.Text): + content.append(part.text) + elif isinstance(part, _MEDIA_TYPES): + content.append(part) + else: + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content block: {item.get('type')}") + else: + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content: {type(item).__name__}") + return content + + +def to_legacy_prompt(content: list[UserContent]) -> LegacyPrompt: + """Return the legacy form: text without media, otherwise content blocks with media URLs.""" + if all(isinstance(item, str) for item in content): + return prompt_text(content) + blocks: list[dict[str, Any]] = [] + for item in content: + if isinstance(item, str): + blocks.append({"type": "text", "text": item}) + else: + blocks.append({"type": item.kind, "media_type": item.media_type, "url": item.data_url}) + return blocks + + +def prompt_text(content: list[UserContent]) -> str: + """Join the text items of a prompt.""" + return "\n".join(item for item in content if isinstance(item, str)) diff --git a/src/bub/core/recorder.py b/src/bub/core/recorder.py index fc3e48ca..ddc14f21 100644 --- a/src/bub/core/recorder.py +++ b/src/bub/core/recorder.py @@ -8,7 +8,7 @@ import republic from bub.core.events import ChatCompleted, CommandFinished, EventBus, RunStarted, StepFinished, StepStarted -from bub.prompt import UserContent +from bub.core.prompt import UserContent class TapeRecorder: diff --git a/src/bub/core/store.py b/src/bub/core/store.py index 2b824929..cd90dd1d 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 @@ -16,14 +15,8 @@ from loguru import logger from typing_extensions import TypeIs +from bub.core.errors import BubError, ErrorKind from bub.core.tape import TapeEntry -from 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/streaming.py b/src/bub/core/streaming.py new file mode 100644 index 00000000..4ccf06c6 --- /dev/null +++ b/src/bub/core/streaming.py @@ -0,0 +1,92 @@ +"""Transport-neutral events produced by streaming model runs.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import nullcontext +from dataclasses import dataclass +from typing import Any, Literal + +from bub.core.errors import BubError +from bub.core.tracing import Span + + +@dataclass +class StreamState: + error: BubError | None = None + usage: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class StreamEvent: + kind: Literal["text", "reasoning", "tool_call", "tool_result", "usage", "error", "final"] + data: dict[str, Any] + + +class AsyncStreamEvents: + def __init__( + self, + iterator: AsyncIterator[StreamEvent], + *, + state: StreamState | None = None, + on_close: Callable[[], Awaitable[None]] | None = None, + span: Span | None = None, + ) -> None: + self._iterator = iterator + self._state = state or StreamState() + self._on_close = on_close + self._span = span + self._closed = False + + def __aiter__(self) -> AsyncIterator[StreamEvent]: + return self + + async def __anext__(self) -> StreamEvent: + if self._closed: + raise StopAsyncIteration + try: + # Detach before returning an event to the consumer, even across tasks. + with self._span.activate() if self._span else nullcontext(): + return await anext(self._iterator) + except StopAsyncIteration: + await self._close() + raise + except BaseException as exc: + if self._span: + self._span.fail(exc) + await self._close() + raise + + async def aclose(self) -> None: + """Close the source and release resources, including before first iteration.""" + if not self._closed and self._span: + self._span.set(**{"bub.cancelled": True}) + await self._close() + + async def _close(self) -> None: + if self._closed: + return + self._closed = True + try: + with self._span.activate() if self._span else nullcontext(): + try: + if close := getattr(self._iterator, "aclose", None): + await close() + finally: + if self._on_close is not None: + await self._on_close() + except BaseException as exc: + if self._span: + self._span.fail(exc) + raise + finally: + if self._span: + self._span.end() + + @property + def error(self) -> BubError | None: + return self._state.error + + @property + def usage(self) -> dict[str, Any] | None: + return self._state.usage diff --git a/src/bub/core/system_prompt.py b/src/bub/core/system_prompt.py index aa01739b..21b605b3 100644 --- a/src/bub/core/system_prompt.py +++ b/src/bub/core/system_prompt.py @@ -8,8 +8,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, TypeVar -from bub.prompt import UserContent -from bub.turn import TurnState +from bub.core.prompt import UserContent +from bub.core.turn import TurnState if TYPE_CHECKING: from bub.core.agent import Agent diff --git a/src/bub/core/tape.py b/src/bub/core/tape.py index 36c881b3..6d5c4a33 100644 --- a/src/bub/core/tape.py +++ b/src/bub/core/tape.py @@ -14,9 +14,9 @@ import republic -from bub import tracing +from bub.core import tracing +from bub.core.errors import BubError, ErrorKind from bub.core.sidecars import TapeSidecar, sidecar_tape_name -from bub.errors import BubError, ErrorKind __all__ = [ "LAST_ANCHOR", diff --git a/src/bub/core/tools.py b/src/bub/core/tools.py index 93954e29..7441a993 100644 --- a/src/bub/core/tools.py +++ b/src/bub/core/tools.py @@ -12,11 +12,11 @@ from loguru import logger from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call +from bub.core.errors import BubError, ErrorKind from bub.core.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult +from bub.core.prompt import UserContent from bub.core.tape import Tape -from bub.errors import BubError, ErrorKind -from bub.prompt import UserContent -from bub.tracing import Span +from bub.core.tracing import Span @dataclass(frozen=True) diff --git a/src/bub/core/tracing.py b/src/bub/core/tracing.py new file mode 100644 index 00000000..d9b0fe11 --- /dev/null +++ b/src/bub/core/tracing.py @@ -0,0 +1,259 @@ +"""Optional GenAI telemetry. Importing Bub never configures an exporter.""" + +from __future__ import annotations + +import asyncio +import importlib +import json +import os +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager, suppress +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any + +import republic + +if TYPE_CHECKING: + import opentelemetry.trace as otel +else: + try: + otel: Any = importlib.import_module("opentelemetry.trace") + except ImportError: + otel = None + +_OPENINFERENCE_ATTRIBUTES = { + "gen_ai.provider.name": "llm.provider", + "gen_ai.request.model": "llm.model_name", + "gen_ai.response.model": "llm.model_name", + "gen_ai.usage.input_tokens": "llm.token_count.prompt", + "gen_ai.usage.output_tokens": "llm.token_count.completion", + "gen_ai.tool.name": "tool.name", + "gen_ai.tool.call.arguments": "input.value", + "gen_ai.tool.call.result": "output.value", +} + + +def configure_otlp() -> None: + """Configure opt-in HTTP export; preserve application-owned providers.""" + if ( + otel is None + or not (os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) + or os.getenv("OTEL_SDK_DISABLED", "").lower() == "true" + or not isinstance(otel.get_tracer_provider(), otel.ProxyTracerProvider) + ): + return + try: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + + protocol = os.getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") or os.getenv( + "OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf" + ) + if protocol != "http/protobuf": + raise ValueError("Bub's trace extra supports OTLP http/protobuf only.") + + exporter = OTLPSpanExporter() + # The SDK reads resource/sampler settings and drains the batch queue at process exit. + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(exporter)) + otel.set_tracer_provider(provider) + + +def _json(value: Any) -> str: + try: + return json.dumps(value, ensure_ascii=False, default=str) + except (TypeError, ValueError, RecursionError): + return '"[unserializable value]"' + + +def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]: + content = message.get("content") + if message.get("role") == "tool": + call_id = message.get("tool_call_id") or (message.get("tool_call") or {}).get("id", "") + return [{"type": "tool_call_response", "id": call_id, "response": content}] + parts: list[dict[str, Any]] = [] + if isinstance(content, str) and content: + parts.append({"type": "text", "content": content}) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append({"type": "text", "content": part.get("text", "")}) + elif isinstance(part, dict): + # Preserve media structure without copying inline binary payloads. + parts.append({"type": "text", "content": f"[{part.get('type', 'media')} omitted]"}) + for call in message.get("tool_calls") or []: + arguments = call.get("arguments", {}) + if isinstance(arguments, str): + with suppress(ValueError): + arguments = json.loads(arguments) + parts.append({ + "type": "tool_call", + "id": call.get("id", ""), + "name": call.get("name", ""), + "arguments": arguments, + }) + return parts + + +class Span: + """A span whose lifetime is independent of its task-local activation.""" + + def __init__(self, name: str, attributes: Mapping[str, Any] | None = None) -> None: + self._operation = str((attributes or {}).get("gen_ai.operation.name", "")) + if otel is not None: + self._span = otel.get_tracer("bub").start_span( + name, + kind=otel.SpanKind.CLIENT if self._operation == "chat" else otel.SpanKind.INTERNAL, + attributes={k: v for k, v in (attributes or {}).items() if isinstance(v, str | bool | int | float)}, + ) + else: + self._span = None + self._ended = False + self.set(**(attributes or {})) + self.set(**{ + "openinference.span.kind": { + "invoke_agent": "AGENT", + "chat": "LLM", + "execute_tool": "TOOL", + }.get(self._operation) + }) + + @property + def recording(self) -> bool: + return self._span is not None and self._span.is_recording() + + def set(self, **attributes: Any) -> None: + if not self.recording: + return + for key, alias in _OPENINFERENCE_ATTRIBUTES.items(): + if (value := attributes.get(key)) is not None: + if alias in {"input.value", "output.value"}: + attributes[alias] = value if isinstance(value, str) else _json(value) + attributes[alias.replace("value", "mime_type")] = ( + "text/plain" if isinstance(value, str) else "application/json" + ) + else: + attributes[alias] = value + for key, value in attributes.items(): + if value is not None: + self._span.set_attribute( + key, + value + if isinstance(value, str | bool | int | float) + or ( + key == "gen_ai.response.finish_reasons" + and isinstance(value, list | tuple) + and all(isinstance(v, str) for v in value) + ) + else _json(value), + ) + + def rename(self, name: str) -> None: + if self.recording: + self._span.update_name(name) + + def messages(self, key: str, messages: Sequence[Mapping[str, Any] | republic.Message]) -> None: + if self.recording: + dicts = [m.to_dict() if isinstance(m, republic.Message) else m for m in messages] + normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in dicts] + direction = "input" if key == "gen_ai.input.messages" else "output" + self.set(**{ + key: _json(normalized), + f"{direction}.value": _json(normalized), + f"{direction}.mime_type": "application/json", + }) + if self._operation == "chat": + self.set(**_openinference_messages(direction, normalized)) + + def event(self, name: str, attributes: Mapping[str, Any]) -> None: + if self.recording: + self._span.add_event( + name, + { + k: v if isinstance(v, str | bool | int | float) else _json(v) + for k, v in attributes.items() + if v is not None + }, + ) + + def fail(self, error: BaseException) -> None: + if not self.recording: + return + if isinstance(error, asyncio.CancelledError | GeneratorExit): + self.set(**{"bub.cancelled": True}) + else: + self._span.record_exception(error) + self._span.set_status(otel.Status(otel.StatusCode.ERROR, str(error))) + self.set(**{"error.type": type(error).__name__}) + + def end(self) -> None: + if not self._ended: + self._ended = True + if self._span is not None: + self._span.end() + + @contextmanager + def activate(self) -> Iterator[None]: + token = _CURRENT.set(self) + try: + if self._span is None: + yield + else: + with otel.use_span( + self._span, end_on_exit=False, record_exception=False, set_status_on_exception=False + ): + yield + finally: + _CURRENT.reset(token) + + def correlation(self) -> dict[str, str]: + if self._span is None: + return {} + context = self._span.get_span_context() + if not context.is_valid: + return {} + return {"trace_id": f"{context.trace_id:032x}", "span_id": f"{context.span_id:016x}"} + + +_CURRENT: ContextVar[Span | None] = ContextVar("bub_trace_span", default=None) + + +def _openinference_messages(direction: str, messages: list[dict[str, Any]]) -> dict[str, Any]: + attributes: dict[str, Any] = {} + for index, message in enumerate(messages): + prefix = f"llm.{direction}_messages.{index}.message" + attributes[f"{prefix}.role"] = message["role"] + text: list[str] = [] + call_index = 0 + for part in message["parts"]: + if part["type"] == "text": + text.append(part["content"]) + elif part["type"] == "tool_call_response": + attributes[f"{prefix}.tool_call_id"] = part["id"] + text.append(part["response"] if isinstance(part["response"], str) else _json(part["response"])) + elif part["type"] == "tool_call": + call_prefix = f"{prefix}.tool_calls.{call_index}.tool_call" + attributes[f"{call_prefix}.id"] = part["id"] + attributes[f"{call_prefix}.function.name"] = part["name"] + attributes[f"{call_prefix}.function.arguments"] = _json(part["arguments"]) + call_index += 1 + if text: + attributes[f"{prefix}.content"] = "\n".join(text) + return attributes + + +def current_span() -> Span | None: + return _CURRENT.get() + + +def correlation() -> dict[str, str]: + span = current_span() + return span.correlation() if span else {} + + +def event(event_name: str, **attributes: Any) -> None: + if span := current_span(): + span.event(event_name, attributes) diff --git a/src/bub/core/turn.py b/src/bub/core/turn.py new file mode 100644 index 00000000..6f446a55 --- /dev/null +++ b/src/bub/core/turn.py @@ -0,0 +1,5 @@ +"""State shared by the hooks and handlers of one turn.""" + +from typing import Any + +type TurnState = dict[str, Any] diff --git a/src/bub/errors.py b/src/bub/errors.py index 141bd03f..c5e7e8f2 100644 --- a/src/bub/errors.py +++ b/src/bub/errors.py @@ -1,40 +1,8 @@ -"""Errors reported by Bub's model and tool execution paths.""" +"""Compatibility alias for :mod:`bub.core.errors`.""" -from __future__ import annotations +import sys -from dataclasses import dataclass -from enum import StrEnum -from typing import Any +from bub.core import errors as _module +from bub.core.errors import * # noqa: F403 - -class ErrorKind(StrEnum): - """Stable error kinds for execution decisions.""" - - INVALID_INPUT = "invalid_input" - CONFIG = "config" - PROVIDER = "provider" - TOOL = "tool" - TEMPORARY = "temporary" - NOT_FOUND = "not_found" - UNKNOWN = "unknown" - - -@dataclass -class BubError(Exception): - """Public error type; exception machinery must be able to assign traceback fields.""" - - kind: ErrorKind - message: str - details: dict[str, Any] | None = None - - def __str__(self) -> str: - return f"[{self.kind.value}] {self.message}" - - def as_dict(self) -> dict[str, Any]: - payload: dict[str, Any] = { - "kind": self.kind.value, - "message": self.message, - } - if self.details: - payload["details"] = self.details - return payload +sys.modules[__name__] = _module diff --git a/src/bub/framework.py b/src/bub/framework.py index e9300eab..3f8fd3f8 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -17,18 +17,18 @@ from bub import configure from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import ChannelRouter, MessageHandler +from bub.core.errors import BubError, ErrorKind +from bub.core.prompt import UserContent, to_content, to_legacy_prompt from bub.core.sidecars import TapeSidecar from bub.core.store import AsyncTapeStore, TapeStore +from bub.core.streaming import StreamState from bub.core.tape import Tape, TapeContext from bub.envelope import Envelope, content_of, field_of, unpack_batch from bub.environment import ENVIRONMENT_STATE_KEY, Environment -from bub.errors import BubError, ErrorKind from bub.hooks.interception import PluginAgentHooks from bub.hooks.runtime import _SKIP_VALUE, HookRuntime from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs from bub.model_selection import ModelOptions -from bub.prompt import UserContent, to_content, to_legacy_prompt -from bub.streaming import StreamState from bub.turn import TurnResult, TurnState from bub.utils import maybe_context_manager diff --git a/src/bub/hooks/runtime.py b/src/bub/hooks/runtime.py index 9a353d74..8c3ce927 100644 --- a/src/bub/hooks/runtime.py +++ b/src/bub/hooks/runtime.py @@ -10,9 +10,9 @@ import pluggy from loguru import logger +from bub.core.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt +from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.envelope import Envelope -from bub.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.turn import TurnState diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index 50f1619c..dbdf2126 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -18,14 +18,14 @@ ToolCallDecision, ToolCallResult, ) +from bub.core.prompt import LegacyPrompt, UserContent from bub.core.sidecars import TapeSidecar from bub.core.store import AsyncTapeStore, TapeStore +from bub.core.streaming import AsyncStreamEvents, StreamState from bub.core.tape import Tape, TapeContext from bub.envelope import Envelope from bub.environment import Environment from bub.model_selection import ModelOptions -from bub.prompt import LegacyPrompt, UserContent -from bub.streaming import AsyncStreamEvents, StreamState from bub.turn import TurnState if TYPE_CHECKING: @@ -43,7 +43,7 @@ class BubHookSpecs: ``republic.Audio`` and ``republic.Video``; legacy text and content-block results are still converted by inspecting the returned value. Hooks that receive a prompt get the legacy form: text without media, otherwise content - blocks with media URLs (see ``bub.prompt.to_legacy_prompt``). + blocks with media URLs (see ``bub.core.prompt.to_legacy_prompt``). """ @hookspec(firstresult=True) diff --git a/src/bub/prompt.py b/src/bub/prompt.py index ee018291..68680793 100644 --- a/src/bub/prompt.py +++ b/src/bub/prompt.py @@ -1,58 +1,8 @@ -"""User prompt content and the legacy prompt forms accepted for compatibility.""" +"""Compatibility alias for :mod:`bub.core.prompt`.""" -from __future__ import annotations +import sys -from typing import Any +from bub.core import prompt as _module +from bub.core.prompt import * # noqa: F403 -import republic - -from bub.errors import BubError, ErrorKind - -type UserContent = str | republic.Image | republic.Audio | republic.Video -type LegacyPrompt = str | list[dict[str, Any]] -"""Prompt forms used before ``list[UserContent]``: text, or text and media content blocks.""" - -_MEDIA_TYPES = (republic.Image, republic.Audio, republic.Video) - - -def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: - """Return user content, converting text and legacy content blocks by their value.""" - from bub.core.tape import to_message - - if isinstance(prompt, str): - return [prompt] - if not isinstance(prompt, list): - raise BubError(ErrorKind.INVALID_INPUT, "Expected user content, text, or a list of content blocks.") - content: list[UserContent] = [] - for item in prompt: - if isinstance(item, str | republic.Image | republic.Audio | republic.Video): - content.append(item) - elif isinstance(item, dict): - for part in to_message({"role": "user", "content": [item]}).parts: - if isinstance(part, republic.Text): - content.append(part.text) - elif isinstance(part, _MEDIA_TYPES): - content.append(part) - else: - raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content block: {item.get('type')}") - else: - raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content: {type(item).__name__}") - return content - - -def to_legacy_prompt(content: list[UserContent]) -> LegacyPrompt: - """Return the legacy form: text without media, otherwise content blocks with media URLs.""" - if all(isinstance(item, str) for item in content): - return prompt_text(content) - blocks: list[dict[str, Any]] = [] - for item in content: - if isinstance(item, str): - blocks.append({"type": "text", "text": item}) - else: - blocks.append({"type": item.kind, "media_type": item.media_type, "url": item.data_url}) - return blocks - - -def prompt_text(content: list[UserContent]) -> str: - """Join the text items of a prompt.""" - return "\n".join(item for item in content if isinstance(item, str)) +sys.modules[__name__] = _module diff --git a/src/bub/streaming.py b/src/bub/streaming.py index 73392b6e..f3aff141 100644 --- a/src/bub/streaming.py +++ b/src/bub/streaming.py @@ -1,92 +1,8 @@ -"""Transport-neutral events produced by streaming model runs.""" +"""Compatibility alias for :mod:`bub.core.streaming`.""" -from __future__ import annotations +import sys -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import nullcontext -from dataclasses import dataclass -from typing import Any, Literal +from bub.core import streaming as _module +from bub.core.streaming import * # noqa: F403 -from bub.errors import BubError -from bub.tracing import Span - - -@dataclass -class StreamState: - error: BubError | None = None - usage: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class StreamEvent: - kind: Literal["text", "reasoning", "tool_call", "tool_result", "usage", "error", "final"] - data: dict[str, Any] - - -class AsyncStreamEvents: - def __init__( - self, - iterator: AsyncIterator[StreamEvent], - *, - state: StreamState | None = None, - on_close: Callable[[], Awaitable[None]] | None = None, - span: Span | None = None, - ) -> None: - self._iterator = iterator - self._state = state or StreamState() - self._on_close = on_close - self._span = span - self._closed = False - - def __aiter__(self) -> AsyncIterator[StreamEvent]: - return self - - async def __anext__(self) -> StreamEvent: - if self._closed: - raise StopAsyncIteration - try: - # Detach before returning an event to the consumer, even across tasks. - with self._span.activate() if self._span else nullcontext(): - return await anext(self._iterator) - except StopAsyncIteration: - await self._close() - raise - except BaseException as exc: - if self._span: - self._span.fail(exc) - await self._close() - raise - - async def aclose(self) -> None: - """Close the source and release resources, including before first iteration.""" - if not self._closed and self._span: - self._span.set(**{"bub.cancelled": True}) - await self._close() - - async def _close(self) -> None: - if self._closed: - return - self._closed = True - try: - with self._span.activate() if self._span else nullcontext(): - try: - if close := getattr(self._iterator, "aclose", None): - await close() - finally: - if self._on_close is not None: - await self._on_close() - except BaseException as exc: - if self._span: - self._span.fail(exc) - raise - finally: - if self._span: - self._span.end() - - @property - def error(self) -> BubError | None: - return self._state.error - - @property - def usage(self) -> dict[str, Any] | None: - return self._state.usage +sys.modules[__name__] = _module diff --git a/src/bub/tracing.py b/src/bub/tracing.py index d9b0fe11..a713df6f 100644 --- a/src/bub/tracing.py +++ b/src/bub/tracing.py @@ -1,259 +1,8 @@ -"""Optional GenAI telemetry. Importing Bub never configures an exporter.""" +"""Compatibility alias for :mod:`bub.core.tracing`.""" -from __future__ import annotations +import sys -import asyncio -import importlib -import json -import os -from collections.abc import Iterator, Mapping, Sequence -from contextlib import contextmanager, suppress -from contextvars import ContextVar -from typing import TYPE_CHECKING, Any +from bub.core import tracing as _module +from bub.core.tracing import * # noqa: F403 -import republic - -if TYPE_CHECKING: - import opentelemetry.trace as otel -else: - try: - otel: Any = importlib.import_module("opentelemetry.trace") - except ImportError: - otel = None - -_OPENINFERENCE_ATTRIBUTES = { - "gen_ai.provider.name": "llm.provider", - "gen_ai.request.model": "llm.model_name", - "gen_ai.response.model": "llm.model_name", - "gen_ai.usage.input_tokens": "llm.token_count.prompt", - "gen_ai.usage.output_tokens": "llm.token_count.completion", - "gen_ai.tool.name": "tool.name", - "gen_ai.tool.call.arguments": "input.value", - "gen_ai.tool.call.result": "output.value", -} - - -def configure_otlp() -> None: - """Configure opt-in HTTP export; preserve application-owned providers.""" - if ( - otel is None - or not (os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) - or os.getenv("OTEL_SDK_DISABLED", "").lower() == "true" - or not isinstance(otel.get_tracer_provider(), otel.ProxyTracerProvider) - ): - return - try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - - protocol = os.getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") or os.getenv( - "OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf" - ) - if protocol != "http/protobuf": - raise ValueError("Bub's trace extra supports OTLP http/protobuf only.") - - exporter = OTLPSpanExporter() - # The SDK reads resource/sampler settings and drains the batch queue at process exit. - provider = TracerProvider() - provider.add_span_processor(BatchSpanProcessor(exporter)) - otel.set_tracer_provider(provider) - - -def _json(value: Any) -> str: - try: - return json.dumps(value, ensure_ascii=False, default=str) - except (TypeError, ValueError, RecursionError): - return '"[unserializable value]"' - - -def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]: - content = message.get("content") - if message.get("role") == "tool": - call_id = message.get("tool_call_id") or (message.get("tool_call") or {}).get("id", "") - return [{"type": "tool_call_response", "id": call_id, "response": content}] - parts: list[dict[str, Any]] = [] - if isinstance(content, str) and content: - parts.append({"type": "text", "content": content}) - elif isinstance(content, list): - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - parts.append({"type": "text", "content": part.get("text", "")}) - elif isinstance(part, dict): - # Preserve media structure without copying inline binary payloads. - parts.append({"type": "text", "content": f"[{part.get('type', 'media')} omitted]"}) - for call in message.get("tool_calls") or []: - arguments = call.get("arguments", {}) - if isinstance(arguments, str): - with suppress(ValueError): - arguments = json.loads(arguments) - parts.append({ - "type": "tool_call", - "id": call.get("id", ""), - "name": call.get("name", ""), - "arguments": arguments, - }) - return parts - - -class Span: - """A span whose lifetime is independent of its task-local activation.""" - - def __init__(self, name: str, attributes: Mapping[str, Any] | None = None) -> None: - self._operation = str((attributes or {}).get("gen_ai.operation.name", "")) - if otel is not None: - self._span = otel.get_tracer("bub").start_span( - name, - kind=otel.SpanKind.CLIENT if self._operation == "chat" else otel.SpanKind.INTERNAL, - attributes={k: v for k, v in (attributes or {}).items() if isinstance(v, str | bool | int | float)}, - ) - else: - self._span = None - self._ended = False - self.set(**(attributes or {})) - self.set(**{ - "openinference.span.kind": { - "invoke_agent": "AGENT", - "chat": "LLM", - "execute_tool": "TOOL", - }.get(self._operation) - }) - - @property - def recording(self) -> bool: - return self._span is not None and self._span.is_recording() - - def set(self, **attributes: Any) -> None: - if not self.recording: - return - for key, alias in _OPENINFERENCE_ATTRIBUTES.items(): - if (value := attributes.get(key)) is not None: - if alias in {"input.value", "output.value"}: - attributes[alias] = value if isinstance(value, str) else _json(value) - attributes[alias.replace("value", "mime_type")] = ( - "text/plain" if isinstance(value, str) else "application/json" - ) - else: - attributes[alias] = value - for key, value in attributes.items(): - if value is not None: - self._span.set_attribute( - key, - value - if isinstance(value, str | bool | int | float) - or ( - key == "gen_ai.response.finish_reasons" - and isinstance(value, list | tuple) - and all(isinstance(v, str) for v in value) - ) - else _json(value), - ) - - def rename(self, name: str) -> None: - if self.recording: - self._span.update_name(name) - - def messages(self, key: str, messages: Sequence[Mapping[str, Any] | republic.Message]) -> None: - if self.recording: - dicts = [m.to_dict() if isinstance(m, republic.Message) else m for m in messages] - normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in dicts] - direction = "input" if key == "gen_ai.input.messages" else "output" - self.set(**{ - key: _json(normalized), - f"{direction}.value": _json(normalized), - f"{direction}.mime_type": "application/json", - }) - if self._operation == "chat": - self.set(**_openinference_messages(direction, normalized)) - - def event(self, name: str, attributes: Mapping[str, Any]) -> None: - if self.recording: - self._span.add_event( - name, - { - k: v if isinstance(v, str | bool | int | float) else _json(v) - for k, v in attributes.items() - if v is not None - }, - ) - - def fail(self, error: BaseException) -> None: - if not self.recording: - return - if isinstance(error, asyncio.CancelledError | GeneratorExit): - self.set(**{"bub.cancelled": True}) - else: - self._span.record_exception(error) - self._span.set_status(otel.Status(otel.StatusCode.ERROR, str(error))) - self.set(**{"error.type": type(error).__name__}) - - def end(self) -> None: - if not self._ended: - self._ended = True - if self._span is not None: - self._span.end() - - @contextmanager - def activate(self) -> Iterator[None]: - token = _CURRENT.set(self) - try: - if self._span is None: - yield - else: - with otel.use_span( - self._span, end_on_exit=False, record_exception=False, set_status_on_exception=False - ): - yield - finally: - _CURRENT.reset(token) - - def correlation(self) -> dict[str, str]: - if self._span is None: - return {} - context = self._span.get_span_context() - if not context.is_valid: - return {} - return {"trace_id": f"{context.trace_id:032x}", "span_id": f"{context.span_id:016x}"} - - -_CURRENT: ContextVar[Span | None] = ContextVar("bub_trace_span", default=None) - - -def _openinference_messages(direction: str, messages: list[dict[str, Any]]) -> dict[str, Any]: - attributes: dict[str, Any] = {} - for index, message in enumerate(messages): - prefix = f"llm.{direction}_messages.{index}.message" - attributes[f"{prefix}.role"] = message["role"] - text: list[str] = [] - call_index = 0 - for part in message["parts"]: - if part["type"] == "text": - text.append(part["content"]) - elif part["type"] == "tool_call_response": - attributes[f"{prefix}.tool_call_id"] = part["id"] - text.append(part["response"] if isinstance(part["response"], str) else _json(part["response"])) - elif part["type"] == "tool_call": - call_prefix = f"{prefix}.tool_calls.{call_index}.tool_call" - attributes[f"{call_prefix}.id"] = part["id"] - attributes[f"{call_prefix}.function.name"] = part["name"] - attributes[f"{call_prefix}.function.arguments"] = _json(part["arguments"]) - call_index += 1 - if text: - attributes[f"{prefix}.content"] = "\n".join(text) - return attributes - - -def current_span() -> Span | None: - return _CURRENT.get() - - -def correlation() -> dict[str, str]: - span = current_span() - return span.correlation() if span else {} - - -def event(event_name: str, **attributes: Any) -> None: - if span := current_span(): - span.event(event_name, attributes) +sys.modules[__name__] = _module diff --git a/src/bub/turn.py b/src/bub/turn.py index 8b90ed36..c759553b 100644 --- a/src/bub/turn.py +++ b/src/bub/turn.py @@ -3,14 +3,15 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING +from bub.core.turn import TurnState from bub.envelope import Envelope if TYPE_CHECKING: - from bub.prompt import UserContent + from bub.core.prompt import UserContent -type TurnState = dict[str, Any] +__all__ = ["TurnResult", "TurnState"] @dataclass(frozen=True) diff --git a/tests/model_fakes.py b/tests/model_fakes.py index ee8f98e6..df915e28 100644 --- a/tests/model_fakes.py +++ b/tests/model_fakes.py @@ -10,11 +10,11 @@ from bub.builtin.settings import AgentSettings from bub.core import Agent as CoreAgent +from bub.core.prompt import UserContent +from bub.core.streaming import AsyncStreamEvents +from bub.core.tape import Tape +from bub.core.tools import Tool from bub.hooks.interception import PluginAgentHooks -from bub.prompt import UserContent -from bub.streaming import AsyncStreamEvents -from bub.tape import Tape -from bub.tools import Tool class StepRunner: diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 59d65f5f..5b235bdb 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -12,7 +12,11 @@ from republic.errors import StreamIncompleteError from bub.builtin.settings import AgentSettings -from bub.errors import BubError +from bub.core.errors import BubError +from bub.core.prompt import to_content +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import Tool, ToolExecutor from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import ( LlmCallDecision, @@ -24,10 +28,6 @@ ToolCallResult, ) 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 diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 435780f8..cf3f11ae 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -16,12 +16,11 @@ from bub.builtin.steering import InMemorySteeringInbox from bub.core import PromptContext, StepFinished from bub.core.context import default_tape_context -from bub.core.tape import session_tape_name -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.core.prompt import UserContent +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import REGISTRY, tool from tests.model_fakes import ProviderService # --------------------------------------------------------------------------- diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 3c6c42c6..62ddefcc 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.prompt import to_content +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, TapeEntry +from bub.core.tools import Tool from bub.framework import BubFramework -from 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..5736c76e 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.streaming import AsyncStreamEvents, StreamEvent +from bub.core.tape import Tape, TapeContext, session_tape_name +from bub.core.tools import REGISTRY from bub.framework import BubFramework -from 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..75a2b5a9 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -26,10 +26,10 @@ set_reasoning_effort, tape_info, ) -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 +from bub.core.errors import ErrorKind +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool def _tool_context(tmp_path, **state) -> ToolContext: diff --git a/tests/test_channels.py b/tests/test_channels.py index ec71b592..4e912d4b 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -26,7 +26,7 @@ from bub.channels.manager import ChannelManager from bub.channels.message import ChannelMessage from bub.channels.telegram import BubMessageFilter, TelegramChannel, TelegramMessageParser -from bub.streaming import StreamEvent +from bub.core.streaming import StreamEvent from bub.turn import TurnResult ANSI_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[()][A-Za-z])") @@ -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] = [] @@ -1507,7 +1507,7 @@ def test_cli_stream_output_does_not_overlap_active_pty_prompt() -> None: import bub.channels.cli as cli_module from bub.channels.cli import CliChannel, _StreamPrinter from bub.channels.cli.terminal_output import TerminalPresenter, create_synchronized_output - from bub.streaming import StreamEvent + from bub.core.streaming import StreamEvent async def main(): diff --git a/tests/test_codemode.py b/tests/test_codemode.py index 75ca8be5..118f5727 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -23,13 +23,13 @@ write_tool_stub, ) from bub.builtin.environment import LocalEnvironment, LocalProcess +from bub.core.errors import BubError, ErrorKind from bub.core.events import BeforeToolCall, EventBus +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, CodeFailed -from bub.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): @@ -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..bd4452fd 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -26,8 +26,8 @@ Tool, ToolCallDecision, ) +from bub.core.errors import BubError from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.errors import BubError from tests.model_fakes import ProviderService, chat_events, sse, tool_events diff --git a/tests/test_core_agent_step.py b/tests/test_core_agent_step.py index 00145bef..d76fb25a 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.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 bub.core.errors import BubError, ErrorKind +from bub.core.prompt import to_content +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, TapeEntry +from bub.core.tools import Tool from 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..da6c01a6 --- /dev/null +++ b/tests/test_core_boundary.py @@ -0,0 +1,60 @@ +import subprocess +import sys + +import pytest + +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_framework_or_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(sorted(n for n in sys.modules if n.startswith('bub.') and not n.startswith('bub.core')))\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True) + + assert result.stdout.splitlines() == ["[]", "['bub._version']"] + + +@pytest.mark.parametrize("name", ["errors", "prompt", "tracing", "streaming", "store", "tape", "tools", "sidecars"]) +def test_legacy_top_level_modules_alias_core_modules(name: str) -> None: + __import__(f"bub.{name}") + __import__(f"bub.core.{name}") + + assert sys.modules[f"bub.{name}"] is sys.modules[f"bub.core.{name}"] + + +def test_turn_state_is_shared_with_core() -> None: + from bub.core.turn import TurnState as CoreTurnState + from bub.turn import TurnState + + assert TurnState is CoreTurnState + + +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_errors.py b/tests/test_errors.py index bf3628eb..78030580 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -3,7 +3,7 @@ import pytest -from bub.errors import BubError, ErrorKind +from bub.core.errors import BubError, ErrorKind def test_error_survives_generator_context_manager_with_identity_and_details() -> None: diff --git a/tests/test_file_tape_store_entry_ids.py b/tests/test_file_tape_store_entry_ids.py index 0f73b91e..1f046fe9 100644 --- a/tests/test_file_tape_store_entry_ids.py +++ b/tests/test_file_tape_store_entry_ids.py @@ -2,9 +2,9 @@ import pytest -from bub.sidecars import sidecar_tape_name -from bub.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore -from bub.tape import TapeEntry +from bub.core.sidecars import sidecar_tape_name +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore +from bub.core.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_fork_store_merge_back.py b/tests/test_fork_store_merge_back.py index e614900e..8e756c70 100644 --- a/tests/test_fork_store_merge_back.py +++ b/tests/test_fork_store_merge_back.py @@ -2,8 +2,8 @@ import pytest -from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery -from bub.tape import TapeEntry +from bub.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery +from bub.core.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_framework.py b/tests/test_framework.py index 51243f2e..f549b921 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -19,10 +19,10 @@ from bub.channels.message import ChannelMessage from bub.channels.telegram import TelegramSettings from bub.configure import ensure_config +from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.framework import BubFramework from bub.hooks import hookimpl from bub.model_selection import ModelChoice, ModelOptions -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState def make_named_channel(name: str, label: str) -> Channel: diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py index 3605d401..1c839c37 100644 --- a/tests/test_hook_runtime.py +++ b/tests/test_hook_runtime.py @@ -2,9 +2,9 @@ import pytest import republic +from bub.core.streaming import AsyncStreamEvents, StreamEvent from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.runtime import HookRuntime -from bub.streaming import AsyncStreamEvents, StreamEvent def _runtime_with_plugins(*plugins: tuple[str, object]) -> HookRuntime: diff --git a/tests/test_image_message.py b/tests/test_image_message.py index 486db122..2e95e350 100644 --- a/tests/test_image_message.py +++ b/tests/test_image_message.py @@ -11,8 +11,8 @@ from bub.builtin.hook_impl import BuiltinImpl from bub.channels.message import ChannelMessage, MediaItem from bub.channels.telegram import TelegramChannel, TelegramMessageParser, _extract_media_items +from bub.core.prompt import prompt_text, to_content, to_legacy_prompt from bub.framework import BubFramework -from bub.prompt import prompt_text, to_content, to_legacy_prompt # --------------------------------------------------------------------------- # MediaItem & ChannelMessage diff --git a/tests/test_otlp.py b/tests/test_otlp.py index caa563ab..d1db5599 100644 --- a/tests/test_otlp.py +++ b/tests/test_otlp.py @@ -74,7 +74,7 @@ def unexpected_configuration(*args, **kwargs): raise SystemExit('OTLP must not configure Logfire') logfire.configure = unexpected_configuration from bub.__main__ import _instrument_bub -from bub.tracing import Span +from bub.core.tracing import Span _instrument_bub('INFO') # CLI import already configured it; repeat must not duplicate exports. root = Span('invoke_agent bub', {'gen_ai.operation.name': 'invoke_agent'}) with root.activate(): @@ -125,7 +125,7 @@ def find_spec(self, fullname, path=None, target=None): run_python( blocker + """ -from bub.tracing import Span, configure_otlp +from bub.core.tracing import Span, configure_otlp configure_otlp() span = Span('noop') assert not span.recording @@ -144,7 +144,7 @@ def test_otlp_preserves_application_provider(tmp_path: Path) -> None: from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from bub.tracing import Span, configure_otlp +from bub.core.tracing import Span, configure_otlp provider = TracerProvider() exporter = InMemorySpanExporter() provider.add_span_processor(SimpleSpanProcessor(exporter)) @@ -163,7 +163,7 @@ def test_otlp_rejects_unsupported_protocol(tmp_path: Path) -> None: pytest.importorskip("opentelemetry.sdk.trace") run_python( """ -from bub.tracing import configure_otlp +from bub.core.tracing import configure_otlp try: configure_otlp() except ValueError as exc: diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index ad0f21fa..901e170c 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.errors import BubError, ErrorKind +from bub.core.errors import BubError, ErrorKind +from bub.core.prompt import to_content +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import Tool from bub.framework import BubFramework -from 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..95a5e818 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.streaming import AsyncStreamEvents, StreamEvent +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import Tool, ToolContext from bub.framework import BubFramework from bub.hooks import hookimpl -from 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..f14eaf27 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.prompt import to_content -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext +from bub.core.prompt import to_content +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.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..9d96c222 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -22,12 +22,12 @@ from bub.builtin.tools import render_tools_prompt from bub.core.context import default_tape_context from bub.core.events import AfterToolCall, EventBus +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore +from bub.core.tape import Tape +from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import PluginAgentHooks, ToolCall, ToolCallResult from bub.hooks.runtime import HookRuntime -from 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 @@ -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..005bd3d7 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.streaming import AsyncStreamEvents, StreamEvent -from bub.tools import REGISTRY, tool +from bub.core.streaming import AsyncStreamEvents, StreamEvent +from bub.core.tools import REGISTRY, tool class FakeContext: diff --git a/tests/test_tape.py b/tests/test_tape.py index 21fa8be2..2ad6e3d4 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -6,8 +6,8 @@ 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: 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..dd0e2fb9 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.errors import BubError from bub.core.events import AfterToolCall, BeforeToolCall, EventBus -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 +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 class EchoInput(BaseModel): diff --git a/tests/test_tracing.py b/tests/test_tracing.py index a23352d6..82d78c69 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -11,18 +11,17 @@ import pytest -from bub import tracing from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings +from bub.core import tracing from bub.core.context import default_tape_context -from bub.core.tape import session_tape_name +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.streaming import AsyncStreamEvents, StreamEvent +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import Tool, ToolContext, ToolExecutor from bub.framework import BubFramework from bub.hooks import hookimpl from bub.hooks.interception import LlmCallDecision, ToolCallDecision, ToolCallResult -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -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 @@ -321,9 +320,9 @@ def find_spec(self, fullname, path=None, target=None): if fullname.split('.')[0] in {'opentelemetry', 'logfire'}: raise ModuleNotFoundError(fullname) sys.meta_path.insert(0, BlockTelemetry()) -from bub import tracing +from bub.core 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/website/src/content/docs/docs/concepts/tape-and-context.mdx b/website/src/content/docs/docs/concepts/tape-and-context.mdx index 55ecedaf..6b58cd83 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 diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index e783ebf8..2402376b 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -11,6 +11,8 @@ The former `bub.types` compatibility module has been removed. Use `bub.envelope. ## Kernel vocabulary +Errors, prompt content, streaming, tracing and `TurnState` belong to the agent kernel in `bub.core`, which depends on nothing else in Bub. The former paths `bub.errors`, `bub.prompt`, `bub.streaming` and `bub.tracing` remain as aliases of the `bub.core` modules, and `bub.turn` re-exports `TurnState`. + ### Envelopes — `bub.envelope` ```python @@ -59,10 +61,10 @@ class ModelOptions: Channels and adapters may present these protocol-neutral choices to users. `BubFramework.get_model_options` merges values returned by `provide_model_options`; the caller owns selection UI and persistence. -### Streaming — `bub.streaming` +### Streaming — `bub.core.streaming` ```python -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState ``` `StreamEvent.kind` is one of `text`, `reasoning`, `tool_call`, `tool_result`, `usage`, `error`, or `final`. `AsyncStreamEvents` wraps an async iterator and exposes terminal `error` and `usage` state. @@ -71,10 +73,10 @@ Builtin `tool_call` events use Republic fields: `id`, `name`, `arguments`, and ` Builtin multipart prompts contain text blocks (`type: text`, `text`) or media blocks (`type: image|audio|video`, `media_type`, `url`). The URL can be remote or a base64 data URL. Bub converts these persisted blocks into native Republic parts; Republic owns provider encoding. Hooks supply these text and media blocks. -### Errors — `bub.errors` +### Errors — `bub.core.errors` ```python -from bub.errors import BubError, ErrorKind +from bub.core.errors import BubError, ErrorKind ``` `BubError` is the stable execution error object. Its `kind` is an `ErrorKind`, and `as_dict()` returns a transport-friendly payload. diff --git a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx index c0d730cc..9499e349 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 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..75f3a952 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -11,6 +11,8 @@ Bub 把契约放在拥有其行为的子系统旁边。请从定义它的模块 ## 内核词汇 +Error、prompt 内容、streaming、tracing 与 `TurnState` 属于 `bub.core` 中的 agent 内核,它不依赖 Bub 的其他部分。原路径 `bub.errors`、`bub.prompt`、`bub.streaming` 与 `bub.tracing` 作为 `bub.core` 对应模块的别名保留,`bub.turn` 也重新导出 `TurnState`。 + ### Envelope — `bub.envelope` ```python @@ -59,10 +61,10 @@ class ModelOptions: Channel 与 adapter 可向用户展示这些协议无关的选择项。`BubFramework.get_model_options` 合并 `provide_model_options` 返回的值;选择 UI 与持久化由调用方负责。 -### Streaming — `bub.streaming` +### Streaming — `bub.core.streaming` ```python -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState ``` `StreamEvent.kind` 可以是 `text`、`reasoning`、`tool_call`、`tool_result`、`usage`、`error` 或 `final`。`AsyncStreamEvents` 包装异步迭代器,并暴露最终 `error` 与 `usage` 状态。 @@ -71,10 +73,10 @@ builtin 的 `tool_call` 事件使用 Republic 字段:`id`、`name`、`argument builtin 的多部分 prompt 包含文本块(`type: text`、`text`)或媒体块(`type: image|audio|video`、`media_type`、`url`)。URL 可以是远程地址或 base64 data URL。Bub 将持久化内容块转换为 Republic 原生 part,provider 编码由 Republic 负责。hook 提供这些文本与媒体块。 -### Error — `bub.errors` +### Error — `bub.core.errors` ```python -from bub.errors import BubError, ErrorKind +from bub.core.errors import BubError, ErrorKind ``` `BubError` 是稳定的执行错误对象。其 `kind` 为 `ErrorKind`,`as_dict()` 返回适合传输的 payload。 From 004b9034b2c0c00d5181b9dce0ba96367b3f8c09 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 15:09:27 +0800 Subject: [PATCH 02/11] chore: enforce bub.core's import boundary with import-linter Add import-linter contracts that forbid bub.core from importing the rest of bub or application-only dependencies, and run them in `make check` and CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 3 ++ AGENTS.md | 3 +- Makefile | 4 +- pyproject.toml | 29 ++++++++++++ uv.lock | 97 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a59f1540..1cbd7145 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,6 +40,9 @@ jobs: - name: Check typing run: uv run mypy + - name: Check import contracts + run: uv run lint-imports + check-docs: diff --git a/AGENTS.md b/AGENTS.md index d76b34e1..7b85b992 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,10 @@ Tests live in `tests/`. The website and docs runtime lives in `website/`. - `uv run bub hooks`: inspect discovered hook bindings. - `uv run ruff check .`: run Ruff directly when you only want lint feedback. - `uv run mypy src`: run mypy directly against `src/`. +- `uv run lint-imports`: check the import contracts in `pyproject.toml` (`bub.core` must not import the rest of `bub` or application dependencies). - `uv run pytest -q`: run the main test suite without doctests. - `make test`: run pytest with doctests enabled. -- `make check`: lock validation, `prek`, and typing. +- `make check`: lock validation, `prek`, typing, and import contracts. - `make docs` / `make docs-test` / `make docs-preview`: serve, build, or preview the Astro website/docs in `website/`. ## Coding Style & Naming Conventions diff --git a/Makefile b/Makefile index 315de3ff..9e1be05c 100644 --- a/Makefile +++ b/Makefile @@ -13,13 +13,15 @@ install: ## Sync Python deps, install website deps, and install prek hooks @echo "==> Installing prek hooks" uv run prek install -check: ## Validate lockfile, run prek across the repo, and type-check src +check: ## Validate lockfile, run prek across the repo, type-check src, and check import contracts @echo "==> Verifying uv.lock matches pyproject.toml" uv lock --locked @echo "==> Running prek hooks" uv run prek run -a @echo "==> Running mypy on src" uv run mypy src + @echo "==> Checking import contracts" + uv run lint-imports vulture: ## Run the optional unused-code check @echo "==> Running vulture via prek" diff --git a/pyproject.toml b/pyproject.toml index 6235fc2c..c76b499b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dev = [ "ruff>=0.15.10", "prek>=0.3.8", "pytest-asyncio>=1.3.0", + "import-linter>=2.5", ] [build-system] @@ -107,6 +108,34 @@ exclude = [ [tool.pytest.ini_options] testpaths = ["tests"] +[tool.importlinter] +root_packages = ["bub"] +include_external_packages = true + +[[tool.importlinter.contracts]] +name = "bub.core is independent of the rest of bub" +type = "forbidden" +source_modules = ["bub.core"] +forbidden_modules = ["bub.*"] + +[[tool.importlinter.contracts]] +name = "bub.core does not use application dependencies" +type = "forbidden" +source_modules = ["bub.core"] +forbidden_modules = [ + "aiohttp", + "dotenv", + "inquirer_textual", + "pluggy", + "prompt_toolkit", + "pydantic_settings", + "rapidfuzz", + "rich", + "telegram", + "typer", + "yaml", +] + [tool.ruff] target-version = "py312" line-length = 120 diff --git a/uv.lock b/uv.lock index 6480ddab..b01875fe 100644 --- a/uv.lock +++ b/uv.lock @@ -217,6 +217,7 @@ trace = [ [package.dev-dependencies] dev = [ + { name = "import-linter" }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, @@ -255,6 +256,7 @@ provides-extras = ["logfire", "trace"] [package.metadata.requires-dev] dev = [ + { name = "import-linter", specifier = ">=2.5" }, { name = "mypy", specifier = ">=1.20.0" }, { name = "prek", specifier = ">=0.3.8" }, { name = "pytest", specifier = ">=9.0.3" }, @@ -611,6 +613,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, ] +[[package]] +name = "grimp" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/2e/95736b7c984705ff5ee376f07c6e71bb80fcbb22c8fa3b8c1cfbfa919d5c/grimp-3.17.tar.gz", hash = "sha256:5161e03c6f518fe4da43b06c97ff49a1e224ecb0e4b9fd6ef48235373d1b7b33", size = 832923, upload-time = "2026-09-04T11:28:54.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/ba/e2e37adeac007a42b074c9156b3a155692fc5a1a32008fb8385546e5c92f/grimp-3.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7375c3aea01495839360947df321d26dcebd037ef34a641627456f23fd91c63e", size = 2146861, upload-time = "2026-09-04T11:28:10.923Z" }, + { url = "https://files.pythonhosted.org/packages/97/d0/3ac947eb15b7c6fe615e01cdfef8152e8b2509c6d5439f7799fae1ca024c/grimp-3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7e75804d1d7f11813082b30a0cd7cf84f553c55d7c1edc28cde23656f1d1a40", size = 2090629, upload-time = "2026-09-04T11:28:03.234Z" }, + { url = "https://files.pythonhosted.org/packages/89/df/53d3eb18c3197ae2bb6c8be118026870cb89b2c33f99c7157345bc5e0734/grimp-3.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62f61d14aedfc0df57db6f7bc826df0f77f3c7115a2a5525aa76531ce2cb9fa6", size = 2277265, upload-time = "2026-09-04T11:27:00.481Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/6615a81f83d8ee31c96b1d8fa9a5d12fceb82acc17a9e71b28768b8185b5/grimp-3.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b1517920dd93df17f6cf0bb79a1ddf193993e87a60dec30bbe67a5139187f0", size = 2220699, upload-time = "2026-09-04T11:27:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/39/60/487bb910bb5092a392f8b23ae8c5fde6d766bfe02e2c7a064ae9a14da30a/grimp-3.17-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d55d03f5dc704b18fa7da04e7eebcc3b16b998b1e18818fb750e291b2991900", size = 2371257, upload-time = "2026-09-04T11:27:38.482Z" }, + { url = "https://files.pythonhosted.org/packages/73/52/5a280d6a9154b1f13a0c74082581cb699825535fdc194ae9ebc5d42c2252/grimp-3.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79b0be412bc19bf298290713e5a2b459fe2a518f921591046b85ba90425bff20", size = 2627111, upload-time = "2026-09-04T11:27:19.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5a/5ef678b3f89a887a9e045dbfb5505876678323f8cf5a26f8b98d9ace875a/grimp-3.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c03a30879a80950e268f03e81603b8c51dc141c944700a0a9d72b2d7484ab24", size = 2347083, upload-time = "2026-09-04T11:27:28.976Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/06e46d5682745570d16c051734a7edf7b3c045ad620440f635dbd86447c3/grimp-3.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf26d99a35ab7b5966af56118bb9344d1fd1b972d3c45dd5012effc7a68c844", size = 2284554, upload-time = "2026-09-04T11:27:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/eb/22/c00c7073a0fce1f9494c564e12705b42a1c421a50515dcf7ffd8e1543024/grimp-3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e205949a5fe6b83ac1ea28525bea2b4867f2b230a851e6dfcdfcfcee1e3dda09", size = 2455050, upload-time = "2026-09-04T11:28:18.529Z" }, + { url = "https://files.pythonhosted.org/packages/5b/23/f2b0bffa2a62a73ca8736b550161eab2b88f66e6a3c9f7def18310c2d087/grimp-3.17-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e2b4d8aebae7142c401231043113910773f289a8d6f9d719273eb719f732b83", size = 2496303, upload-time = "2026-09-04T11:28:28.147Z" }, + { url = "https://files.pythonhosted.org/packages/02/54/b4dfc51d8c628f700512953c78656305a21ead767956a7fd7aa46f1021b1/grimp-3.17-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5abd95dc0a8f816d2816e847adb0ab09a7e3571fd0942ea31cf5248505862bac", size = 2528584, upload-time = "2026-09-04T11:28:37.526Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/73c088e1bee1a95a61cc3523abff149b0c211405834ca74996c76e6a3afa/grimp-3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae6360f6b4f711abf97b8888c0269de3756ea484c829615e0c567c7bb1ce944", size = 2538538, upload-time = "2026-09-04T11:28:47.701Z" }, + { url = "https://files.pythonhosted.org/packages/9d/66/0ff96f79bb1c4ffff621f7b50babbea6151fbaad461327af961f1fa2e49e/grimp-3.17-cp312-cp312-win32.whl", hash = "sha256:b1ab648732d03fa2ec4d7619d934409399bf827009c4c50f429c8d0bdd0709fb", size = 1874539, upload-time = "2026-09-04T11:29:20.342Z" }, + { url = "https://files.pythonhosted.org/packages/a7/72/e2544f4ba4f42f7732bdc13d60c344fd9da30eb083eb8cba2e7c155d222e/grimp-3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d10ae67a1ee0af6dee937e032f3acf707741d866a67f3c71e5ec6df3b758e5c0", size = 1999174, upload-time = "2026-09-04T11:29:09.19Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/9b2da1ff2951e13b636673814ec7df699660f892f284bf2f3d53e991283c/grimp-3.17-cp312-cp312-win_arm64.whl", hash = "sha256:ff161c9aed2ca3db9bc592157a36914ae8440d8d8f3c6986843dec11523cd700", size = 1923198, upload-time = "2026-09-04T11:28:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/10/7a/b1c76982a239347bfccd2ae95b9f79d3390cd14170f62e7eb822a17d786d/grimp-3.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:eb8f09da4302f05fc92fe7564f27731c0b43789d8893ae5e6be5df2b01668923", size = 2145452, upload-time = "2026-09-04T11:28:12.121Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ab/2a1a9574b391c0ee62cb33a633c0d377dce1b7d19d04a7ac5d5e6872d395/grimp-3.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f11566bcf388db4c79f1c7f1051f6ef150f778306350bb7d86d0c9aab32d297", size = 2091654, upload-time = "2026-09-04T11:28:04.635Z" }, + { url = "https://files.pythonhosted.org/packages/e6/12/7dc086132ac10a2409f158f0b0d7cc528a2ebe0a4e95bca895afe7452fa6/grimp-3.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfda3e2ebb4e51beec4a3cb09bb771440f3192163107d9320e6d9ce8fe064e64", size = 2277300, upload-time = "2026-09-04T11:27:01.844Z" }, + { url = "https://files.pythonhosted.org/packages/5b/62/2e16a54580fa76fb7e64e8e8e2ebb899c709a764cb29149a88eb03f2eea7/grimp-3.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0ab1ab71d807fc6f56d82f1ab24acf2f2e0e5000e37b5540af9449e28c6830c", size = 2220417, upload-time = "2026-09-04T11:27:11.418Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/e1b0ad0d0b3d9dff996bad306542f3cf000cb86c368bfe3ac6a8bb1e2e2f/grimp-3.17-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:187b092664e882d10b9f2c3da99251d0aeb99186d407ed5d20f52005832045f6", size = 2370754, upload-time = "2026-09-04T11:27:39.995Z" }, + { url = "https://files.pythonhosted.org/packages/36/0f/015e48ad8029f788eaa3df71b66859b2b5dc63abb8d7a2014a2b5a581040/grimp-3.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75da770aeee97d26b202d7251fe7f6562e7f2793e348501b0a64ca5c50e881b5", size = 2628257, upload-time = "2026-09-04T11:27:20.491Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/66da78504a1731237ed8befd9c66fb91037a7b82816443ca772107acf8fe/grimp-3.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5cc64f891f71f538d3d032d6e7193f69c3a2e0ceac72c55114faee5aa161f9f", size = 2347569, upload-time = "2026-09-04T11:27:30.282Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a2/6474d008fa49cc650d626798cb3b6e3fa7b0daaad24884fd6bb4288f6154/grimp-3.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dbf2d93b0ba7f6c1efba9f5b3e07a3da0a157392c650316a5c1478b0ef7e503", size = 2284227, upload-time = "2026-09-04T11:27:52.462Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/91cd378c08494fd0130e5cc2e54d60d12f53b21850e0dbccf668b6e73d91/grimp-3.17-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a22b5dcbf55f1c348885e09cbe91e63baedf6ce0f89c60bb01a0475c7d0a90b", size = 2454655, upload-time = "2026-09-04T11:28:19.837Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d0/b582238247ce51857c7ac770c3be10203c992696f3dc9153f1a979c011dc/grimp-3.17-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:73aa9459a604554b1dc1618148312036679f3974bf8b0285588220307147060b", size = 2495852, upload-time = "2026-09-04T11:28:29.606Z" }, + { url = "https://files.pythonhosted.org/packages/8f/99/e0690b02be70f346aadfe2aa8bd7e496c773298ac58c0d0fa6376c1298b2/grimp-3.17-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c31660d30e79569faac05033637c1eef4595542f44ec2323af54e900030164a6", size = 2527887, upload-time = "2026-09-04T11:28:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/69/7a/33e41d9b08e63cf398d8194a9208b21788c76916b5ac6d90146e8e2ebf95/grimp-3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8f395c5ab2cf554504f46680b9269b769939ab5d7ea58f93e5c341e55b1affbd", size = 2539000, upload-time = "2026-09-04T11:28:49.037Z" }, + { url = "https://files.pythonhosted.org/packages/69/f5/7af1dd74b9608a85274414fc423a7162844492b198ece3fe31f4e1f88237/grimp-3.17-cp313-cp313-win32.whl", hash = "sha256:34369739ed293accbb674494ae932351516b316e8e85d1d333d4c51796af846a", size = 1874244, upload-time = "2026-09-04T11:29:21.693Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/af497adddfcf11f9bd4ab273625a49022bf5ba4f5fc389589f4e6edc67b9/grimp-3.17-cp313-cp313-win_amd64.whl", hash = "sha256:d21b859c50418bdb48b403a84450650fa7aab3102f3212d00d3c21c76a982167", size = 1998225, upload-time = "2026-09-04T11:29:10.5Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2a/07fd537997281049af2390fc8641de5ae063ae4972cb6fcec85873674595/grimp-3.17-cp313-cp313-win_arm64.whl", hash = "sha256:206cd9f7c757b97934f48ba2b8ec6d649725fd6314d6b182dd327b19b4f35707", size = 1922071, upload-time = "2026-09-04T11:28:58.34Z" }, + { url = "https://files.pythonhosted.org/packages/39/e0/e11ea5aeb62534c9706e17952d142aa844ce5659e2d66b4b8ba9377407d6/grimp-3.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0100ff6f08067c8eb8a77dd1433af5e173aea8dbf6a6b76f5372a3b49f2e0a43", size = 2161696, upload-time = "2026-09-11T10:36:49.104Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d6/ddf82f4428d8bdc54ced0db244cbf5d8e5638490c3ed6a8b9f5db55dfa51/grimp-3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:67ada40a5c1cf4321183c0b0cd0393658ccd04b845caed002ac81dc2fdef828e", size = 2107301, upload-time = "2026-09-11T10:36:46.459Z" }, + { url = "https://files.pythonhosted.org/packages/86/1a/1dd0d9d2751e8078547e1cb7131e13d9624a2ea60700d926b3d624c15825/grimp-3.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa385915a4c17948bfb25f77509cbbfb33795f321d1c5ca7cc602e34ed58e99", size = 2278433, upload-time = "2026-09-04T11:27:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/58/71/3768fd253e7455a83d668193055691c82d3a4d434047116c572a27d2bd39/grimp-3.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8322a435b5ee97ff3db823df36cbefea569faec505cc0ca66223c27b4b44cca6", size = 2221152, upload-time = "2026-09-04T11:27:12.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4f/907a130e87554d57be14c2a7cedeb818d0ef519064877297d19636735f6b/grimp-3.17-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb953c21beb6ac85a59b5b54128be7fdddd89396d34b3c5705cf59829a85b431", size = 2371546, upload-time = "2026-09-04T11:27:41.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/a3bf534aa7c582a47d8cfefab5fccb50e6f4dcd327ed6a8552d344a04ffe/grimp-3.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c34b904c25359f98e3f20771256d62329ed17f1e26a254a4f5c74bacc1f3bd21", size = 2628467, upload-time = "2026-09-04T11:27:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/d3/2b2ab7b3a4e2c17bb1e10ec37e7ee8eecce7f72b1bf6c8019dd439943884/grimp-3.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69ce8be301299f15ca99843e89991c240a231836cf45a11c6bfc54fb9fbeaa9", size = 2347520, upload-time = "2026-09-04T11:27:31.73Z" }, + { url = "https://files.pythonhosted.org/packages/72/da/6bc1f783a739afa51ffa2e235791487b5d09202501b25843ecae9833a3ff/grimp-3.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:566411d1ac97e1f3a3f00a13ae33aebb9abda9787d3d9914f62f73b589d84fe2", size = 2284007, upload-time = "2026-09-04T11:27:53.889Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/a08f1b16aec11f32bbc86f60e24986659ab4cfab79388c8d4b79f6a6723e/grimp-3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5f89948029f3c7339a20bab007977194d3dbdddb27edae5c74b948d8f13191c7", size = 2455301, upload-time = "2026-09-04T11:28:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/d55e9686f3797d1a2c6403d2ffec3020c67591c004304a414fe8d7a9755c/grimp-3.17-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5a895bc27105ada9b0e76a80bf9cc898409e5cc3689e73e64ca1cbd71dc87dce", size = 2497158, upload-time = "2026-09-04T11:28:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/7f/85/a2d2e70c4f272752c395cc35b3732d82ff92c55e229d7bbd6777fd9868ff/grimp-3.17-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e80b57a7d03f53e96eb0a5b0844340f99f17eede184042fb73e3ef010176ab35", size = 2528862, upload-time = "2026-09-04T11:28:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/fc3a58d9b22b011c1c3377c1ecee127a5f642de9b20de32d3ad7213ab71a/grimp-3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c0cac76283679fe9c1fd761e63deb9bbc7ba46b32988113ecef4b13742f31b9", size = 2538240, upload-time = "2026-09-04T11:28:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/13/d7666cf03c79c275020bad40ea1df8597af7877083ef5a84b5d8b566029a/grimp-3.17-cp314-cp314-win32.whl", hash = "sha256:c8982f4fa1276e059665947156740a55a4e8e6b5a4d7f40730a7511afb608762", size = 1874930, upload-time = "2026-09-04T11:29:23.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/ce/abb9ccb50972351461065ca72cd0f528837a2f398621147dfaf1013698f0/grimp-3.17-cp314-cp314-win_amd64.whl", hash = "sha256:0b97dc29bdca523932bbbcb425fc3df3547cf970396b293bfa960b910f3932d1", size = 1999405, upload-time = "2026-09-04T11:29:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1d/93c12ff4ce9ede9f91aa03df66128d223a4c824a13de6ab702c973fcae0d/grimp-3.17-cp314-cp314-win_arm64.whl", hash = "sha256:80b5b25b8d8ed6cc68dc39ae9e4c2fb64acd1a3e66434d9cf4d68c72e46d505d", size = 1923324, upload-time = "2026-09-04T11:29:00.004Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/f94dd90757a2195415736f0e2be667ac4cd375fe86d875d4298661751fce/grimp-3.17-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e15340ee11e34bfaaee496cff53f8287dc9f417a94e5bdf3d6b4204305d004cc", size = 2145199, upload-time = "2026-09-04T11:28:13.287Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/9d7cb22fc136ad6a61bff3b74cf0b31b13fdf94e224d46ff1edd9b5b8c86/grimp-3.17-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbccf2a6cd3f89bfdfd683d9b000bd61f6936e16b6d4d6619da592c54f79f272", size = 2090761, upload-time = "2026-09-04T11:28:05.984Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6b/b70add79e9e315d111bb1643b44e1b9e1ded82888856e1ad68bc484cd635/grimp-3.17-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5332893955688759a9f7868c3c9e56b19bc1694ee251852d3daf54876b35c115", size = 2279005, upload-time = "2026-09-04T11:27:04.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f5f001e592f8e8820086604db98373ca129e2831facad81ec0d1820a749f/grimp-3.17-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:848a92d8a63a38d79c89ab1e68fb5429d90079f39c07533ffd45ce756e7f210f", size = 2220430, upload-time = "2026-09-04T11:27:13.814Z" }, + { url = "https://files.pythonhosted.org/packages/83/13/cc57724307c22060e193a1dc9bc99fa14d46584c534a3936319ec579c599/grimp-3.17-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c89cfe49f822a2cc9c42cc7b0f3975ecb1281970735ea31610bfa06a7e04c460", size = 2370318, upload-time = "2026-09-04T11:27:42.535Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/05d9964d4b47ffe0ef826bfe04b3f4b713fd623c90e424ff002cb0d8a917/grimp-3.17-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:541c9a2b7f1ad5d4f5d1597740e51058fba8ca0c1a225d6ccc6d11a8364fa563", size = 2629127, upload-time = "2026-09-04T11:27:23.18Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/186a3087623f1556eb8b9d02baa00300003bb84b7b35677e3fcec34691b6/grimp-3.17-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66b4a13edb3fe63be6f7dbbc2576786f501af9d386a5a1f60b2938f270aef97c", size = 2348453, upload-time = "2026-09-04T11:27:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/e8/6c/a718328adc50529135510b515c5ff1d9f94ab26ab8df9057bb1ef5ab8993/grimp-3.17-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:160abad142e168545be8d4cc91b2a95b4565f7571fc56f7fced69ce3589e5111", size = 2287338, upload-time = "2026-09-04T11:27:55.16Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/bf67bc6749abee0a1e97f8f14ee2cb19ccc435b306e22bcaac6118d402a4/grimp-3.17-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cd76fb13a534209c1777c807092f51b1570854eb49948d65cd5e1d94d80e981d", size = 2455299, upload-time = "2026-09-04T11:28:22.505Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/6c954092306e21c93c744432c3d47491c3264d3cc4b465941de5164a1cb3/grimp-3.17-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1688e2dbe382b6644ecc3f55053e319de70034d9dfe0809d21d441a590c320e6", size = 2496813, upload-time = "2026-09-04T11:28:32.303Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/cf0aa245ace55dd2f6bdd3bb646ec50c294bdfe623fd51327bd27efaa585/grimp-3.17-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0433d177f327c31410f21845322282c832d887f1a42cbdfa176133d8422d84b2", size = 2527052, upload-time = "2026-09-04T11:28:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/75/b8/90cd11e346f12fb5269ff050ae825083410d3bdb54d82a3b8e955e4f9d26/grimp-3.17-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790d68157e034e7848257ef46b37550040c9e4692cea17372d3d9f87cef1d84b", size = 2541238, upload-time = "2026-09-04T11:28:51.786Z" }, + { url = "https://files.pythonhosted.org/packages/48/92/10cc37db6149030c8cfc80fa5287f739c1e9620c8a05d1ba8dfdfc0a6c4f/grimp-3.17-cp314-cp314t-win32.whl", hash = "sha256:37e6333ca54d4d80b5c4f2eae324774b223dbdc65994dc9bccda2985a9261dd8", size = 1872673, upload-time = "2026-09-04T11:29:24.514Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cc/b257d8da079a87e65d0f1b0e004fd50f31ba25e7136a3b36f896e0e338f8/grimp-3.17-cp314-cp314t-win_amd64.whl", hash = "sha256:91e4bbbed4d934369b83071380e1ad5f37b7f5c80b80ffa924627db8d66fea11", size = 2000197, upload-time = "2026-09-04T11:29:13.138Z" }, + { url = "https://files.pythonhosted.org/packages/29/d0/feebddf766448932f2f1e40324fe75cf50ce6d3a21bdf87fc0eae263218a/grimp-3.17-cp314-cp314t-win_arm64.whl", hash = "sha256:28ae2ff57a9ef535c2e7054dcb18baf0cdc2dee3982367512bff34eaecb85cf0", size = 1920856, upload-time = "2026-09-04T11:29:01.498Z" }, + { url = "https://files.pythonhosted.org/packages/83/39/bb07084f0b97da66b0aa38077bd656c4f831b4aa3f167b1c060f6a65ea5e/grimp-3.17-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe3e2285a2bd4e62ddb8825de37152d2dc842394d419786d3d5a803b07bb84eb", size = 2372594, upload-time = "2026-09-04T11:27:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a9/905a484659749f40ed797963147202cb74c3b68d8e90a7a600af55de47ee/grimp-3.17-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a377d5935ae9a62ccbb59e492e16624cec97cc7244c4a964b0c6f83940440626", size = 2284577, upload-time = "2026-09-04T11:27:56.428Z" }, + { url = "https://files.pythonhosted.org/packages/30/6a/40fb12b111c95b94db4930f35661a629532d9d12fb484db5b5ef1a6c3da3/grimp-3.17-cp315-cp315-win32.whl", hash = "sha256:65a1dc2741bef8a96ff3ff71252db8e99eac2a32e09d5e2b63597474706186ca", size = 1876437, upload-time = "2026-09-04T11:29:25.725Z" }, + { url = "https://files.pythonhosted.org/packages/71/98/79af8d51961611e13b3dabc7a3b356b27aa696e5549bb439f1d6002be31a/grimp-3.17-cp315-cp315-win_amd64.whl", hash = "sha256:d20fbe5aab8b013b351313f8eaa3a46f4ac57677e357cb1c3d1012c606b565b4", size = 1999947, upload-time = "2026-09-04T11:29:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/96/d6/e1922c4c7e001210f98e0cf89bd76fea8564f590be3e9ccfe99435cfcd59/grimp-3.17-cp315-cp315-win_arm64.whl", hash = "sha256:a0288dc0e8947dead5c02a246e16f3f5e5cb486348171bdf1289885a13de36c1", size = 1923774, upload-time = "2026-09-04T11:29:02.897Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/f4f84e44a9237350c41e7c77343498cb1ad843491c18f29d6811021c617d/grimp-3.17-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:1bf67cb05a80796be99d1bf87e12d4421de101950c5246a8220b6c839cbf27c2", size = 2145245, upload-time = "2026-09-04T11:28:14.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/98/822287270c41360f66c4b5638e040afc6bbf11e9f04cf5005bbd5f3e4830/grimp-3.17-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d058fdb980475eb9e0143792d63757ddff86c5776e792866e8208cfe5e8413f9", size = 2090367, upload-time = "2026-09-04T11:28:07.277Z" }, + { url = "https://files.pythonhosted.org/packages/ac/76/32d303603157a0d98cb8bde8757e6ae8fa02d41f1d62cdf843fb2e7fe727/grimp-3.17-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92f1989ce9f9ce26b7d45381df164c62aff783add84cd7a4216a6ebba0f9f03a", size = 2371543, upload-time = "2026-09-04T11:27:45.108Z" }, + { url = "https://files.pythonhosted.org/packages/7b/68/4551e23ab9422e2ead806c1fec9f4b3acd21a267e43bfe8e8cd7f4640dce/grimp-3.17-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e275cee07826cf36db1914427e68afac87588f759e1dec676870234d4a250ae", size = 2288003, upload-time = "2026-09-04T11:27:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/79161f05c900f7f7f1b0acd22a730b1be9f96680dd44efeef90aa45e6998/grimp-3.17-cp315-cp315t-win32.whl", hash = "sha256:a7235ccffb0350647ae2b01969f552a9859fea7027032f6cfed110801269a439", size = 1873249, upload-time = "2026-09-04T11:29:27.094Z" }, + { url = "https://files.pythonhosted.org/packages/94/b7/6f9cdeba2c1263fb91b791ddd5e924d6c91c2485f4f16b56698802fdd540/grimp-3.17-cp315-cp315t-win_amd64.whl", hash = "sha256:473d0e0c016ca429b9ec17f34d276db454ba4d467e3c915a46279d86ab004716", size = 2000579, upload-time = "2026-09-04T11:29:15.961Z" }, + { url = "https://files.pythonhosted.org/packages/ac/16/8ae9028f046bf8f48cc13f6de2de40adbcbf38fa79a3a5f265753b248da0/grimp-3.17-cp315-cp315t-win_arm64.whl", hash = "sha256:478aa0b696e5dd79373943e75b998fbb4ef3d146f24fe3569fa9d106ce84991b", size = 1921829, upload-time = "2026-09-04T11:29:04.403Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -701,6 +783,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] +[[package]] +name = "import-linter" +version = "2.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/81/1bb414f8c8bcd024499e91f112d317c36a86bc264da1fd43f9797fc2b7b0/import_linter-2.15.tar.gz", hash = "sha256:1da912bea5e172a82a3ce617b5543f75cf64dc0d8f4d9b46c5578b68ccb81590", size = 1405973, upload-time = "2026-09-04T14:47:56.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/31/4571c20652a4002ed9e500046386f3731a77e23d95a608ae8a87f1fbb2da/import_linter-2.15-py3-none-any.whl", hash = "sha256:9aaf16a88ac1e99d5a464cd7f66b6a05f7060bfa761162e0ed441773a267ed3b", size = 640512, upload-time = "2026-09-04T14:47:54.878Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" From b6b2db9ba9b525c2630975900d6ea8922307c1ab Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 15:36:31 +0800 Subject: [PATCH 03/11] chore: run import-linter as a prek hook Run the import contracts from .pre-commit-config.yaml instead of a separate `make check` / CI step, so they run on commit and in the existing prek CI job. The hook installs only import-linter and finds the sources through PYTHONPATH. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 2 - .pre-commit-config.yaml | 11 +++++ AGENTS.md | 4 +- Makefile | 4 +- pyproject.toml | 1 - uv.lock | 97 -------------------------------------- 6 files changed, 14 insertions(+), 105 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1cbd7145..a4fe6a1b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,8 +40,6 @@ jobs: - name: Check typing run: uv run mypy - - name: Check import contracts - run: uv run lint-imports diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0fefde1..824583c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,3 +26,14 @@ repos: - id: ruff args: [--exit-non-zero-on-fix] - id: ruff-format + + - repo: local + hooks: + - id: import-linter + name: import-linter + # Contracts live in pyproject.toml; grimp finds the packages through PYTHONPATH without installing them. + entry: env PYTHONPATH=src lint-imports + language: python + additional_dependencies: ["import-linter==2.15"] + pass_filenames: false + files: ^(src/.*\.py|pyproject\.toml)$ diff --git a/AGENTS.md b/AGENTS.md index 7b85b992..528bd144 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,10 +27,10 @@ Tests live in `tests/`. The website and docs runtime lives in `website/`. - `uv run bub hooks`: inspect discovered hook bindings. - `uv run ruff check .`: run Ruff directly when you only want lint feedback. - `uv run mypy src`: run mypy directly against `src/`. -- `uv run lint-imports`: check the import contracts in `pyproject.toml` (`bub.core` must not import the rest of `bub` or application dependencies). +- `uv run prek run import-linter -a`: check the import contracts in `pyproject.toml` (`bub.core` must not import the rest of `bub` or application dependencies); `prek` and `make check` run it too. - `uv run pytest -q`: run the main test suite without doctests. - `make test`: run pytest with doctests enabled. -- `make check`: lock validation, `prek`, typing, and import contracts. +- `make check`: lock validation, `prek` (including import contracts), and typing. - `make docs` / `make docs-test` / `make docs-preview`: serve, build, or preview the Astro website/docs in `website/`. ## Coding Style & Naming Conventions diff --git a/Makefile b/Makefile index 9e1be05c..315de3ff 100644 --- a/Makefile +++ b/Makefile @@ -13,15 +13,13 @@ install: ## Sync Python deps, install website deps, and install prek hooks @echo "==> Installing prek hooks" uv run prek install -check: ## Validate lockfile, run prek across the repo, type-check src, and check import contracts +check: ## Validate lockfile, run prek across the repo, and type-check src @echo "==> Verifying uv.lock matches pyproject.toml" uv lock --locked @echo "==> Running prek hooks" uv run prek run -a @echo "==> Running mypy on src" uv run mypy src - @echo "==> Checking import contracts" - uv run lint-imports vulture: ## Run the optional unused-code check @echo "==> Running vulture via prek" diff --git a/pyproject.toml b/pyproject.toml index c76b499b..8b92770a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,6 @@ dev = [ "ruff>=0.15.10", "prek>=0.3.8", "pytest-asyncio>=1.3.0", - "import-linter>=2.5", ] [build-system] diff --git a/uv.lock b/uv.lock index b01875fe..6480ddab 100644 --- a/uv.lock +++ b/uv.lock @@ -217,7 +217,6 @@ trace = [ [package.dev-dependencies] dev = [ - { name = "import-linter" }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, @@ -256,7 +255,6 @@ provides-extras = ["logfire", "trace"] [package.metadata.requires-dev] dev = [ - { name = "import-linter", specifier = ">=2.5" }, { name = "mypy", specifier = ">=1.20.0" }, { name = "prek", specifier = ">=0.3.8" }, { name = "pytest", specifier = ">=9.0.3" }, @@ -613,86 +611,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, ] -[[package]] -name = "grimp" -version = "3.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ad/2e/95736b7c984705ff5ee376f07c6e71bb80fcbb22c8fa3b8c1cfbfa919d5c/grimp-3.17.tar.gz", hash = "sha256:5161e03c6f518fe4da43b06c97ff49a1e224ecb0e4b9fd6ef48235373d1b7b33", size = 832923, upload-time = "2026-09-04T11:28:54.609Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/ba/e2e37adeac007a42b074c9156b3a155692fc5a1a32008fb8385546e5c92f/grimp-3.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7375c3aea01495839360947df321d26dcebd037ef34a641627456f23fd91c63e", size = 2146861, upload-time = "2026-09-04T11:28:10.923Z" }, - { url = "https://files.pythonhosted.org/packages/97/d0/3ac947eb15b7c6fe615e01cdfef8152e8b2509c6d5439f7799fae1ca024c/grimp-3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7e75804d1d7f11813082b30a0cd7cf84f553c55d7c1edc28cde23656f1d1a40", size = 2090629, upload-time = "2026-09-04T11:28:03.234Z" }, - { url = "https://files.pythonhosted.org/packages/89/df/53d3eb18c3197ae2bb6c8be118026870cb89b2c33f99c7157345bc5e0734/grimp-3.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62f61d14aedfc0df57db6f7bc826df0f77f3c7115a2a5525aa76531ce2cb9fa6", size = 2277265, upload-time = "2026-09-04T11:27:00.481Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a7/6615a81f83d8ee31c96b1d8fa9a5d12fceb82acc17a9e71b28768b8185b5/grimp-3.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b1517920dd93df17f6cf0bb79a1ddf193993e87a60dec30bbe67a5139187f0", size = 2220699, upload-time = "2026-09-04T11:27:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/39/60/487bb910bb5092a392f8b23ae8c5fde6d766bfe02e2c7a064ae9a14da30a/grimp-3.17-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d55d03f5dc704b18fa7da04e7eebcc3b16b998b1e18818fb750e291b2991900", size = 2371257, upload-time = "2026-09-04T11:27:38.482Z" }, - { url = "https://files.pythonhosted.org/packages/73/52/5a280d6a9154b1f13a0c74082581cb699825535fdc194ae9ebc5d42c2252/grimp-3.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79b0be412bc19bf298290713e5a2b459fe2a518f921591046b85ba90425bff20", size = 2627111, upload-time = "2026-09-04T11:27:19.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5a/5ef678b3f89a887a9e045dbfb5505876678323f8cf5a26f8b98d9ace875a/grimp-3.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c03a30879a80950e268f03e81603b8c51dc141c944700a0a9d72b2d7484ab24", size = 2347083, upload-time = "2026-09-04T11:27:28.976Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/06e46d5682745570d16c051734a7edf7b3c045ad620440f635dbd86447c3/grimp-3.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf26d99a35ab7b5966af56118bb9344d1fd1b972d3c45dd5012effc7a68c844", size = 2284554, upload-time = "2026-09-04T11:27:50.665Z" }, - { url = "https://files.pythonhosted.org/packages/eb/22/c00c7073a0fce1f9494c564e12705b42a1c421a50515dcf7ffd8e1543024/grimp-3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e205949a5fe6b83ac1ea28525bea2b4867f2b230a851e6dfcdfcfcee1e3dda09", size = 2455050, upload-time = "2026-09-04T11:28:18.529Z" }, - { url = "https://files.pythonhosted.org/packages/5b/23/f2b0bffa2a62a73ca8736b550161eab2b88f66e6a3c9f7def18310c2d087/grimp-3.17-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e2b4d8aebae7142c401231043113910773f289a8d6f9d719273eb719f732b83", size = 2496303, upload-time = "2026-09-04T11:28:28.147Z" }, - { url = "https://files.pythonhosted.org/packages/02/54/b4dfc51d8c628f700512953c78656305a21ead767956a7fd7aa46f1021b1/grimp-3.17-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5abd95dc0a8f816d2816e847adb0ab09a7e3571fd0942ea31cf5248505862bac", size = 2528584, upload-time = "2026-09-04T11:28:37.526Z" }, - { url = "https://files.pythonhosted.org/packages/b5/60/73c088e1bee1a95a61cc3523abff149b0c211405834ca74996c76e6a3afa/grimp-3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae6360f6b4f711abf97b8888c0269de3756ea484c829615e0c567c7bb1ce944", size = 2538538, upload-time = "2026-09-04T11:28:47.701Z" }, - { url = "https://files.pythonhosted.org/packages/9d/66/0ff96f79bb1c4ffff621f7b50babbea6151fbaad461327af961f1fa2e49e/grimp-3.17-cp312-cp312-win32.whl", hash = "sha256:b1ab648732d03fa2ec4d7619d934409399bf827009c4c50f429c8d0bdd0709fb", size = 1874539, upload-time = "2026-09-04T11:29:20.342Z" }, - { url = "https://files.pythonhosted.org/packages/a7/72/e2544f4ba4f42f7732bdc13d60c344fd9da30eb083eb8cba2e7c155d222e/grimp-3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d10ae67a1ee0af6dee937e032f3acf707741d866a67f3c71e5ec6df3b758e5c0", size = 1999174, upload-time = "2026-09-04T11:29:09.19Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9d/9b2da1ff2951e13b636673814ec7df699660f892f284bf2f3d53e991283c/grimp-3.17-cp312-cp312-win_arm64.whl", hash = "sha256:ff161c9aed2ca3db9bc592157a36914ae8440d8d8f3c6986843dec11523cd700", size = 1923198, upload-time = "2026-09-04T11:28:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/10/7a/b1c76982a239347bfccd2ae95b9f79d3390cd14170f62e7eb822a17d786d/grimp-3.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:eb8f09da4302f05fc92fe7564f27731c0b43789d8893ae5e6be5df2b01668923", size = 2145452, upload-time = "2026-09-04T11:28:12.121Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ab/2a1a9574b391c0ee62cb33a633c0d377dce1b7d19d04a7ac5d5e6872d395/grimp-3.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f11566bcf388db4c79f1c7f1051f6ef150f778306350bb7d86d0c9aab32d297", size = 2091654, upload-time = "2026-09-04T11:28:04.635Z" }, - { url = "https://files.pythonhosted.org/packages/e6/12/7dc086132ac10a2409f158f0b0d7cc528a2ebe0a4e95bca895afe7452fa6/grimp-3.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfda3e2ebb4e51beec4a3cb09bb771440f3192163107d9320e6d9ce8fe064e64", size = 2277300, upload-time = "2026-09-04T11:27:01.844Z" }, - { url = "https://files.pythonhosted.org/packages/5b/62/2e16a54580fa76fb7e64e8e8e2ebb899c709a764cb29149a88eb03f2eea7/grimp-3.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0ab1ab71d807fc6f56d82f1ab24acf2f2e0e5000e37b5540af9449e28c6830c", size = 2220417, upload-time = "2026-09-04T11:27:11.418Z" }, - { url = "https://files.pythonhosted.org/packages/73/c0/e1b0ad0d0b3d9dff996bad306542f3cf000cb86c368bfe3ac6a8bb1e2e2f/grimp-3.17-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:187b092664e882d10b9f2c3da99251d0aeb99186d407ed5d20f52005832045f6", size = 2370754, upload-time = "2026-09-04T11:27:39.995Z" }, - { url = "https://files.pythonhosted.org/packages/36/0f/015e48ad8029f788eaa3df71b66859b2b5dc63abb8d7a2014a2b5a581040/grimp-3.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75da770aeee97d26b202d7251fe7f6562e7f2793e348501b0a64ca5c50e881b5", size = 2628257, upload-time = "2026-09-04T11:27:20.491Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d3/66da78504a1731237ed8befd9c66fb91037a7b82816443ca772107acf8fe/grimp-3.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5cc64f891f71f538d3d032d6e7193f69c3a2e0ceac72c55114faee5aa161f9f", size = 2347569, upload-time = "2026-09-04T11:27:30.282Z" }, - { url = "https://files.pythonhosted.org/packages/cb/a2/6474d008fa49cc650d626798cb3b6e3fa7b0daaad24884fd6bb4288f6154/grimp-3.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dbf2d93b0ba7f6c1efba9f5b3e07a3da0a157392c650316a5c1478b0ef7e503", size = 2284227, upload-time = "2026-09-04T11:27:52.462Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/91cd378c08494fd0130e5cc2e54d60d12f53b21850e0dbccf668b6e73d91/grimp-3.17-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a22b5dcbf55f1c348885e09cbe91e63baedf6ce0f89c60bb01a0475c7d0a90b", size = 2454655, upload-time = "2026-09-04T11:28:19.837Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d0/b582238247ce51857c7ac770c3be10203c992696f3dc9153f1a979c011dc/grimp-3.17-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:73aa9459a604554b1dc1618148312036679f3974bf8b0285588220307147060b", size = 2495852, upload-time = "2026-09-04T11:28:29.606Z" }, - { url = "https://files.pythonhosted.org/packages/8f/99/e0690b02be70f346aadfe2aa8bd7e496c773298ac58c0d0fa6376c1298b2/grimp-3.17-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c31660d30e79569faac05033637c1eef4595542f44ec2323af54e900030164a6", size = 2527887, upload-time = "2026-09-04T11:28:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/69/7a/33e41d9b08e63cf398d8194a9208b21788c76916b5ac6d90146e8e2ebf95/grimp-3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8f395c5ab2cf554504f46680b9269b769939ab5d7ea58f93e5c341e55b1affbd", size = 2539000, upload-time = "2026-09-04T11:28:49.037Z" }, - { url = "https://files.pythonhosted.org/packages/69/f5/7af1dd74b9608a85274414fc423a7162844492b198ece3fe31f4e1f88237/grimp-3.17-cp313-cp313-win32.whl", hash = "sha256:34369739ed293accbb674494ae932351516b316e8e85d1d333d4c51796af846a", size = 1874244, upload-time = "2026-09-04T11:29:21.693Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/af497adddfcf11f9bd4ab273625a49022bf5ba4f5fc389589f4e6edc67b9/grimp-3.17-cp313-cp313-win_amd64.whl", hash = "sha256:d21b859c50418bdb48b403a84450650fa7aab3102f3212d00d3c21c76a982167", size = 1998225, upload-time = "2026-09-04T11:29:10.5Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2a/07fd537997281049af2390fc8641de5ae063ae4972cb6fcec85873674595/grimp-3.17-cp313-cp313-win_arm64.whl", hash = "sha256:206cd9f7c757b97934f48ba2b8ec6d649725fd6314d6b182dd327b19b4f35707", size = 1922071, upload-time = "2026-09-04T11:28:58.34Z" }, - { url = "https://files.pythonhosted.org/packages/39/e0/e11ea5aeb62534c9706e17952d142aa844ce5659e2d66b4b8ba9377407d6/grimp-3.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0100ff6f08067c8eb8a77dd1433af5e173aea8dbf6a6b76f5372a3b49f2e0a43", size = 2161696, upload-time = "2026-09-11T10:36:49.104Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d6/ddf82f4428d8bdc54ced0db244cbf5d8e5638490c3ed6a8b9f5db55dfa51/grimp-3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:67ada40a5c1cf4321183c0b0cd0393658ccd04b845caed002ac81dc2fdef828e", size = 2107301, upload-time = "2026-09-11T10:36:46.459Z" }, - { url = "https://files.pythonhosted.org/packages/86/1a/1dd0d9d2751e8078547e1cb7131e13d9624a2ea60700d926b3d624c15825/grimp-3.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa385915a4c17948bfb25f77509cbbfb33795f321d1c5ca7cc602e34ed58e99", size = 2278433, upload-time = "2026-09-04T11:27:03.075Z" }, - { url = "https://files.pythonhosted.org/packages/58/71/3768fd253e7455a83d668193055691c82d3a4d434047116c572a27d2bd39/grimp-3.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8322a435b5ee97ff3db823df36cbefea569faec505cc0ca66223c27b4b44cca6", size = 2221152, upload-time = "2026-09-04T11:27:12.642Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4f/907a130e87554d57be14c2a7cedeb818d0ef519064877297d19636735f6b/grimp-3.17-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb953c21beb6ac85a59b5b54128be7fdddd89396d34b3c5705cf59829a85b431", size = 2371546, upload-time = "2026-09-04T11:27:41.195Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/a3bf534aa7c582a47d8cfefab5fccb50e6f4dcd327ed6a8552d344a04ffe/grimp-3.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c34b904c25359f98e3f20771256d62329ed17f1e26a254a4f5c74bacc1f3bd21", size = 2628467, upload-time = "2026-09-04T11:27:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/32/d3/2b2ab7b3a4e2c17bb1e10ec37e7ee8eecce7f72b1bf6c8019dd439943884/grimp-3.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69ce8be301299f15ca99843e89991c240a231836cf45a11c6bfc54fb9fbeaa9", size = 2347520, upload-time = "2026-09-04T11:27:31.73Z" }, - { url = "https://files.pythonhosted.org/packages/72/da/6bc1f783a739afa51ffa2e235791487b5d09202501b25843ecae9833a3ff/grimp-3.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:566411d1ac97e1f3a3f00a13ae33aebb9abda9787d3d9914f62f73b589d84fe2", size = 2284007, upload-time = "2026-09-04T11:27:53.889Z" }, - { url = "https://files.pythonhosted.org/packages/63/06/a08f1b16aec11f32bbc86f60e24986659ab4cfab79388c8d4b79f6a6723e/grimp-3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5f89948029f3c7339a20bab007977194d3dbdddb27edae5c74b948d8f13191c7", size = 2455301, upload-time = "2026-09-04T11:28:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c1/d55e9686f3797d1a2c6403d2ffec3020c67591c004304a414fe8d7a9755c/grimp-3.17-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5a895bc27105ada9b0e76a80bf9cc898409e5cc3689e73e64ca1cbd71dc87dce", size = 2497158, upload-time = "2026-09-04T11:28:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/7f/85/a2d2e70c4f272752c395cc35b3732d82ff92c55e229d7bbd6777fd9868ff/grimp-3.17-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e80b57a7d03f53e96eb0a5b0844340f99f17eede184042fb73e3ef010176ab35", size = 2528862, upload-time = "2026-09-04T11:28:40.251Z" }, - { url = "https://files.pythonhosted.org/packages/cb/03/fc3a58d9b22b011c1c3377c1ecee127a5f642de9b20de32d3ad7213ab71a/grimp-3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c0cac76283679fe9c1fd761e63deb9bbc7ba46b32988113ecef4b13742f31b9", size = 2538240, upload-time = "2026-09-04T11:28:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/58/13/d7666cf03c79c275020bad40ea1df8597af7877083ef5a84b5d8b566029a/grimp-3.17-cp314-cp314-win32.whl", hash = "sha256:c8982f4fa1276e059665947156740a55a4e8e6b5a4d7f40730a7511afb608762", size = 1874930, upload-time = "2026-09-04T11:29:23.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/ce/abb9ccb50972351461065ca72cd0f528837a2f398621147dfaf1013698f0/grimp-3.17-cp314-cp314-win_amd64.whl", hash = "sha256:0b97dc29bdca523932bbbcb425fc3df3547cf970396b293bfa960b910f3932d1", size = 1999405, upload-time = "2026-09-04T11:29:11.832Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1d/93c12ff4ce9ede9f91aa03df66128d223a4c824a13de6ab702c973fcae0d/grimp-3.17-cp314-cp314-win_arm64.whl", hash = "sha256:80b5b25b8d8ed6cc68dc39ae9e4c2fb64acd1a3e66434d9cf4d68c72e46d505d", size = 1923324, upload-time = "2026-09-04T11:29:00.004Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/f94dd90757a2195415736f0e2be667ac4cd375fe86d875d4298661751fce/grimp-3.17-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e15340ee11e34bfaaee496cff53f8287dc9f417a94e5bdf3d6b4204305d004cc", size = 2145199, upload-time = "2026-09-04T11:28:13.287Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/9d7cb22fc136ad6a61bff3b74cf0b31b13fdf94e224d46ff1edd9b5b8c86/grimp-3.17-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbccf2a6cd3f89bfdfd683d9b000bd61f6936e16b6d4d6619da592c54f79f272", size = 2090761, upload-time = "2026-09-04T11:28:05.984Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6b/b70add79e9e315d111bb1643b44e1b9e1ded82888856e1ad68bc484cd635/grimp-3.17-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5332893955688759a9f7868c3c9e56b19bc1694ee251852d3daf54876b35c115", size = 2279005, upload-time = "2026-09-04T11:27:04.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/4c/f5f001e592f8e8820086604db98373ca129e2831facad81ec0d1820a749f/grimp-3.17-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:848a92d8a63a38d79c89ab1e68fb5429d90079f39c07533ffd45ce756e7f210f", size = 2220430, upload-time = "2026-09-04T11:27:13.814Z" }, - { url = "https://files.pythonhosted.org/packages/83/13/cc57724307c22060e193a1dc9bc99fa14d46584c534a3936319ec579c599/grimp-3.17-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c89cfe49f822a2cc9c42cc7b0f3975ecb1281970735ea31610bfa06a7e04c460", size = 2370318, upload-time = "2026-09-04T11:27:42.535Z" }, - { url = "https://files.pythonhosted.org/packages/50/1b/05d9964d4b47ffe0ef826bfe04b3f4b713fd623c90e424ff002cb0d8a917/grimp-3.17-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:541c9a2b7f1ad5d4f5d1597740e51058fba8ca0c1a225d6ccc6d11a8364fa563", size = 2629127, upload-time = "2026-09-04T11:27:23.18Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ea/186a3087623f1556eb8b9d02baa00300003bb84b7b35677e3fcec34691b6/grimp-3.17-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66b4a13edb3fe63be6f7dbbc2576786f501af9d386a5a1f60b2938f270aef97c", size = 2348453, upload-time = "2026-09-04T11:27:32.964Z" }, - { url = "https://files.pythonhosted.org/packages/e8/6c/a718328adc50529135510b515c5ff1d9f94ab26ab8df9057bb1ef5ab8993/grimp-3.17-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:160abad142e168545be8d4cc91b2a95b4565f7571fc56f7fced69ce3589e5111", size = 2287338, upload-time = "2026-09-04T11:27:55.16Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/bf67bc6749abee0a1e97f8f14ee2cb19ccc435b306e22bcaac6118d402a4/grimp-3.17-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cd76fb13a534209c1777c807092f51b1570854eb49948d65cd5e1d94d80e981d", size = 2455299, upload-time = "2026-09-04T11:28:22.505Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/6c954092306e21c93c744432c3d47491c3264d3cc4b465941de5164a1cb3/grimp-3.17-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1688e2dbe382b6644ecc3f55053e319de70034d9dfe0809d21d441a590c320e6", size = 2496813, upload-time = "2026-09-04T11:28:32.303Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a8/cf0aa245ace55dd2f6bdd3bb646ec50c294bdfe623fd51327bd27efaa585/grimp-3.17-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0433d177f327c31410f21845322282c832d887f1a42cbdfa176133d8422d84b2", size = 2527052, upload-time = "2026-09-04T11:28:41.869Z" }, - { url = "https://files.pythonhosted.org/packages/75/b8/90cd11e346f12fb5269ff050ae825083410d3bdb54d82a3b8e955e4f9d26/grimp-3.17-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790d68157e034e7848257ef46b37550040c9e4692cea17372d3d9f87cef1d84b", size = 2541238, upload-time = "2026-09-04T11:28:51.786Z" }, - { url = "https://files.pythonhosted.org/packages/48/92/10cc37db6149030c8cfc80fa5287f739c1e9620c8a05d1ba8dfdfc0a6c4f/grimp-3.17-cp314-cp314t-win32.whl", hash = "sha256:37e6333ca54d4d80b5c4f2eae324774b223dbdc65994dc9bccda2985a9261dd8", size = 1872673, upload-time = "2026-09-04T11:29:24.514Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cc/b257d8da079a87e65d0f1b0e004fd50f31ba25e7136a3b36f896e0e338f8/grimp-3.17-cp314-cp314t-win_amd64.whl", hash = "sha256:91e4bbbed4d934369b83071380e1ad5f37b7f5c80b80ffa924627db8d66fea11", size = 2000197, upload-time = "2026-09-04T11:29:13.138Z" }, - { url = "https://files.pythonhosted.org/packages/29/d0/feebddf766448932f2f1e40324fe75cf50ce6d3a21bdf87fc0eae263218a/grimp-3.17-cp314-cp314t-win_arm64.whl", hash = "sha256:28ae2ff57a9ef535c2e7054dcb18baf0cdc2dee3982367512bff34eaecb85cf0", size = 1920856, upload-time = "2026-09-04T11:29:01.498Z" }, - { url = "https://files.pythonhosted.org/packages/83/39/bb07084f0b97da66b0aa38077bd656c4f831b4aa3f167b1c060f6a65ea5e/grimp-3.17-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe3e2285a2bd4e62ddb8825de37152d2dc842394d419786d3d5a803b07bb84eb", size = 2372594, upload-time = "2026-09-04T11:27:43.884Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a9/905a484659749f40ed797963147202cb74c3b68d8e90a7a600af55de47ee/grimp-3.17-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a377d5935ae9a62ccbb59e492e16624cec97cc7244c4a964b0c6f83940440626", size = 2284577, upload-time = "2026-09-04T11:27:56.428Z" }, - { url = "https://files.pythonhosted.org/packages/30/6a/40fb12b111c95b94db4930f35661a629532d9d12fb484db5b5ef1a6c3da3/grimp-3.17-cp315-cp315-win32.whl", hash = "sha256:65a1dc2741bef8a96ff3ff71252db8e99eac2a32e09d5e2b63597474706186ca", size = 1876437, upload-time = "2026-09-04T11:29:25.725Z" }, - { url = "https://files.pythonhosted.org/packages/71/98/79af8d51961611e13b3dabc7a3b356b27aa696e5549bb439f1d6002be31a/grimp-3.17-cp315-cp315-win_amd64.whl", hash = "sha256:d20fbe5aab8b013b351313f8eaa3a46f4ac57677e357cb1c3d1012c606b565b4", size = 1999947, upload-time = "2026-09-04T11:29:14.623Z" }, - { url = "https://files.pythonhosted.org/packages/96/d6/e1922c4c7e001210f98e0cf89bd76fea8564f590be3e9ccfe99435cfcd59/grimp-3.17-cp315-cp315-win_arm64.whl", hash = "sha256:a0288dc0e8947dead5c02a246e16f3f5e5cb486348171bdf1289885a13de36c1", size = 1923774, upload-time = "2026-09-04T11:29:02.897Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/f4f84e44a9237350c41e7c77343498cb1ad843491c18f29d6811021c617d/grimp-3.17-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:1bf67cb05a80796be99d1bf87e12d4421de101950c5246a8220b6c839cbf27c2", size = 2145245, upload-time = "2026-09-04T11:28:14.537Z" }, - { url = "https://files.pythonhosted.org/packages/71/98/822287270c41360f66c4b5638e040afc6bbf11e9f04cf5005bbd5f3e4830/grimp-3.17-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d058fdb980475eb9e0143792d63757ddff86c5776e792866e8208cfe5e8413f9", size = 2090367, upload-time = "2026-09-04T11:28:07.277Z" }, - { url = "https://files.pythonhosted.org/packages/ac/76/32d303603157a0d98cb8bde8757e6ae8fa02d41f1d62cdf843fb2e7fe727/grimp-3.17-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92f1989ce9f9ce26b7d45381df164c62aff783add84cd7a4216a6ebba0f9f03a", size = 2371543, upload-time = "2026-09-04T11:27:45.108Z" }, - { url = "https://files.pythonhosted.org/packages/7b/68/4551e23ab9422e2ead806c1fec9f4b3acd21a267e43bfe8e8cd7f4640dce/grimp-3.17-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e275cee07826cf36db1914427e68afac87588f759e1dec676870234d4a250ae", size = 2288003, upload-time = "2026-09-04T11:27:57.605Z" }, - { url = "https://files.pythonhosted.org/packages/20/64/79161f05c900f7f7f1b0acd22a730b1be9f96680dd44efeef90aa45e6998/grimp-3.17-cp315-cp315t-win32.whl", hash = "sha256:a7235ccffb0350647ae2b01969f552a9859fea7027032f6cfed110801269a439", size = 1873249, upload-time = "2026-09-04T11:29:27.094Z" }, - { url = "https://files.pythonhosted.org/packages/94/b7/6f9cdeba2c1263fb91b791ddd5e924d6c91c2485f4f16b56698802fdd540/grimp-3.17-cp315-cp315t-win_amd64.whl", hash = "sha256:473d0e0c016ca429b9ec17f34d276db454ba4d467e3c915a46279d86ab004716", size = 2000579, upload-time = "2026-09-04T11:29:15.961Z" }, - { url = "https://files.pythonhosted.org/packages/ac/16/8ae9028f046bf8f48cc13f6de2de40adbcbf38fa79a3a5f265753b248da0/grimp-3.17-cp315-cp315t-win_arm64.whl", hash = "sha256:478aa0b696e5dd79373943e75b998fbb4ef3d146f24fe3569fa9d106ce84991b", size = 1921829, upload-time = "2026-09-04T11:29:04.403Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -783,21 +701,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] -[[package]] -name = "import-linter" -version = "2.15" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "grimp" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/81/1bb414f8c8bcd024499e91f112d317c36a86bc264da1fd43f9797fc2b7b0/import_linter-2.15.tar.gz", hash = "sha256:1da912bea5e172a82a3ce617b5543f75cf64dc0d8f4d9b46c5578b68ccb81590", size = 1405973, upload-time = "2026-09-04T14:47:56.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/31/4571c20652a4002ed9e500046386f3731a77e23d95a608ae8a87f1fbb2da/import_linter-2.15-py3-none-any.whl", hash = "sha256:9aaf16a88ac1e99d5a464cd7f66b6a05f7060bfa761162e0ed441773a267ed3b", size = 640512, upload-time = "2026-09-04T14:47:54.878Z" }, -] - [[package]] name = "importlib-metadata" version = "8.7.1" From 73a6f9088f33693f02b703ec0b2a08aacdf80311 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 16:19:34 +0800 Subject: [PATCH 04/11] feat!: install only bub.core by default and move the rest to bub[app] `pip install bub` now installs only the agent kernel's dependencies (republic, pydantic, loguru, typing-extensions). The framework, CLI, channels, hooks, skills and builtin agent need the new `app` extra. BREAKING CHANGE: install `bub[app]` (e.g. `uv tool install 'bub[app]'`) to get the `bub` command and runtime. Without it, the `bub` script exits with an install hint instead of an ImportError. Plugins should depend on `bub[app]`. - The `bub` script moves to `bub._cli:main`, which checks the app extra's requirements from bub's metadata before importing the CLI. - The dev group depends on `bub[app]`, so `uv sync` keeps a full environment; the Dockerfile, install scripts and `bub install` request the extra, and `logfire` implies it. - CI builds the wheel and checks that a plain install imports bub.core without app dependencies and that `bub` asks for the extra. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 34 ++++++++++ AGENTS.md | 4 +- Dockerfile | 2 +- README.md | 2 +- pyproject.toml | 32 +++++----- src/bub/_cli.py | 39 ++++++++++++ src/bub/builtin/cli.py | 2 +- tests/test_builtin_cli.py | 2 +- tests/test_cli_entry.py | 61 ++++++++++++++++++ tests/test_install_scripts.py | 2 +- uv.lock | 62 +++++++++++++------ website/public/install.ps1 | 2 +- website/public/install.sh | 2 +- .../src/content/docs/docs/build/plugins.mdx | 2 +- website/src/content/docs/docs/build/sdk.md | 2 + .../docs/getting-started/first-plugin.mdx | 2 +- .../docs/docs/getting-started/install.mdx | 6 +- .../src/content/docs/docs/operate/deploy.mdx | 2 +- .../docs/docs/tutorials/observability.mdx | 4 +- .../content/docs/zh-cn/docs/build/plugins.mdx | 2 +- .../src/content/docs/zh-cn/docs/build/sdk.md | 2 + .../docs/getting-started/first-plugin.mdx | 2 +- .../zh-cn/docs/getting-started/install.mdx | 6 +- .../docs/zh-cn/docs/operate/deploy.mdx | 2 +- .../zh-cn/docs/tutorials/observability.mdx | 4 +- 25 files changed, 228 insertions(+), 54 deletions(-) create mode 100644 src/bub/_cli.py create mode 100644 tests/test_cli_entry.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a4fe6a1b..fa0d6b6c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -43,6 +43,40 @@ 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.store, bub.tape, bub.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 528bd144..7389c24c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,11 @@ Core code lives under `src/`: 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._cli: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 8b92770a..5cfcf33b 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._cli: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/_cli.py b/src/bub/_cli.py new file mode 100644 index 00000000..1ff2b17d --- /dev/null +++ b/src/bub/_cli.py @@ -0,0 +1,39 @@ +"""Entry point of the ``bub`` command, which needs the ``app`` extra.""" + +from __future__ import annotations + +import re +import sys +from importlib.metadata import PackageNotFoundError, distribution, requires + +_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 main() -> None: + 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]'" + ) + + from bub.__main__ import app + + app() 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/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_cli_entry.py b/tests/test_cli_entry.py new file mode 100644 index 00000000..4e700694 --- /dev/null +++ b/tests/test_cli_entry.py @@ -0,0 +1,61 @@ +from importlib.metadata import PackageNotFoundError + +import pytest + +from bub import _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: + import bub.__main__ + + calls: list[str] = [] + monkeypatch.setattr(_cli, "missing_app_requirements", lambda: []) + monkeypatch.setattr(bub.__main__, "app", 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() == [] 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/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/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..165de7c1 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 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/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/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..b692d7b7 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 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/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: From fe5d2f30b20235d3d336782814139ad9631f1603 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 16:29:10 +0800 Subject: [PATCH 05/11] refactor: merge bub._cli into bub.__main__ The `bub` script is `bub.__main__:main` again. The module no longer imports typer or the framework at import time: `main` checks the app extra first, and `bub.__main__.app` is created on first access, so `from bub.__main__ import app` keeps working for distributions. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- pyproject.toml | 2 +- src/bub/__main__.py | 60 ++++++++++++++++--- src/bub/_cli.py | 39 ------------ tests/{test_cli_entry.py => test_cli_main.py} | 39 +++++++----- tests/test_otlp.py | 2 +- .../content/docs/docs/build/distribution.mdx | 6 +- .../docs/zh-cn/docs/build/distribution.mdx | 6 +- 8 files changed, 88 insertions(+), 70 deletions(-) delete mode 100644 src/bub/_cli.py rename tests/{test_cli_entry.py => test_cli_main.py} (51%) diff --git a/AGENTS.md b/AGENTS.md index 7389c24c..c753c52d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ 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, plus the kernel vocabulary it shares with the framework (`errors.py`, `prompt.py`, `streaming.py`, `tracing.py`, `turn.py`). Nothing in `bub.core` imports the rest of `bub`; the old top-level paths (`bub.errors`, `bub.store`, ...) are compatibility aliases. - `src/bub/framework.py`: turn orchestration and outbound routing. - `src/bub/hooks/`: hook specifications, execution helpers, and interception contracts. @@ -17,7 +17,7 @@ Core code lives under `src/`: 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._cli:main`) asks for it when it is missing. Add new non-kernel dependencies to `app`, not to `dependencies`. +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 diff --git a/pyproject.toml b/pyproject.toml index 5cfcf33b..6a52ad6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ Repository = "https://github.com/bubbuild/bub" Documentation = "https://bub.build" [project.scripts] -bub = "bub._cli:main" +bub = "bub.__main__:main" [project.optional-dependencies] app = [ diff --git a/src/bub/__main__.py b/src/bub/__main__.py index 4b275a05..20d0b703 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, Any -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,24 @@ def _help() -> None: return app -app = create_cli_app() +def __getattr__(name: str) -> Any: + # ``bub.__main__.app`` is the CLI that ``main`` runs, created on first access. + if name == "app": + app = globals()["app"] = create_cli_app() + return app + raise AttributeError(f"module {__name__} has no attribute {name}") + + +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]'" + ) + sys.modules[__name__].app() + if __name__ == "__main__": - app() + main() diff --git a/src/bub/_cli.py b/src/bub/_cli.py deleted file mode 100644 index 1ff2b17d..00000000 --- a/src/bub/_cli.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Entry point of the ``bub`` command, which needs the ``app`` extra.""" - -from __future__ import annotations - -import re -import sys -from importlib.metadata import PackageNotFoundError, distribution, requires - -_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 main() -> None: - 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]'" - ) - - from bub.__main__ import app - - app() diff --git a/tests/test_cli_entry.py b/tests/test_cli_main.py similarity index 51% rename from tests/test_cli_entry.py rename to tests/test_cli_main.py index 4e700694..70351043 100644 --- a/tests/test_cli_entry.py +++ b/tests/test_cli_main.py @@ -1,8 +1,10 @@ +import subprocess +import sys from importlib.metadata import PackageNotFoundError import pytest -from bub import _cli +import bub.__main__ as cli REQUIREMENTS = [ "republic>=0.6.0a1", @@ -22,18 +24,18 @@ def distribution(name: str) -> object: 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")) + monkeypatch.setattr(cli, "requires", lambda name: REQUIREMENTS) + monkeypatch.setattr(cli, "distribution", _installed("republic", "typer")) - assert _cli.missing_app_requirements() == ["httpx"] + 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()) + monkeypatch.setattr(cli, "requires", lambda name: REQUIREMENTS) + monkeypatch.setattr(cli, "distribution", _installed()) with pytest.raises(SystemExit) as exc_info: - _cli.main() + cli.main() message = str(exc_info.value.code) assert "missing: typer, httpx" in message @@ -41,13 +43,11 @@ def test_bub_command_without_app_extra_exits_with_install_hint(monkeypatch: pyte def test_bub_command_runs_the_cli_when_app_extra_is_installed(monkeypatch: pytest.MonkeyPatch) -> None: - import bub.__main__ - calls: list[str] = [] - monkeypatch.setattr(_cli, "missing_app_requirements", lambda: []) - monkeypatch.setattr(bub.__main__, "app", lambda: calls.append("app")) + monkeypatch.setattr(cli, "missing_app_requirements", lambda: []) + monkeypatch.setattr(cli, "app", lambda: calls.append("app"), raising=False) - _cli.main() + cli.main() assert calls == ["app"] @@ -56,6 +56,17 @@ def test_uninstalled_source_tree_skips_the_check(monkeypatch: pytest.MonkeyPatch def requires(name: str) -> list[str]: raise PackageNotFoundError(name) - monkeypatch.setattr(_cli, "requires", requires) + 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 _cli.missing_app_requirements() == [] + assert result.stdout.splitlines() == ["[]", "False"] diff --git a/tests/test_otlp.py b/tests/test_otlp.py index d1db5599..df97537c 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.core.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/website/src/content/docs/docs/build/distribution.mdx b/website/src/content/docs/docs/build/distribution.mdx index 9f33d11d..867c04aa 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 runs the Typer app `bub.__main__.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/zh-cn/docs/build/distribution.mdx b/website/src/content/docs/zh-cn/docs/build/distribution.mdx index c0ef09e3..e63fdcfe 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` 命令使用的是同一个入口,它运行 Typer 应用 `bub.__main__.app`,并在缺少 `bub[app]` extra 时给出提示。每个贡献 `register_cli_commands` 的插件都会自动接入。 把 CLI 接入脚本入口: From a35a6257d7525d8506e795bc920f53e386405b3a Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 16:30:15 +0800 Subject: [PATCH 06/11] refactor: build the CLI in bub.__main__.main Drop the lazily created `bub.__main__.app`; `main` checks the app extra and runs `create_cli_app()` directly. Co-Authored-By: Claude Opus 5.5 --- src/bub/__main__.py | 12 ++---------- tests/test_cli_main.py | 2 +- website/src/content/docs/docs/build/distribution.mdx | 2 +- .../content/docs/zh-cn/docs/build/distribution.mdx | 2 +- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/bub/__main__.py b/src/bub/__main__.py index 20d0b703..7c8f4978 100644 --- a/src/bub/__main__.py +++ b/src/bub/__main__.py @@ -10,7 +10,7 @@ import re import sys from importlib.metadata import PackageNotFoundError, distribution, requires -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING if TYPE_CHECKING: import typer @@ -86,14 +86,6 @@ def _help() -> None: return app -def __getattr__(name: str) -> Any: - # ``bub.__main__.app`` is the CLI that ``main`` runs, created on first access. - if name == "app": - app = globals()["app"] = create_cli_app() - return app - raise AttributeError(f"module {__name__} has no attribute {name}") - - def main() -> None: """Run the ``bub`` command.""" if missing := missing_app_requirements(): @@ -102,7 +94,7 @@ def main() -> None: f"(missing: {', '.join(missing)}).\n" "Install it with: uv tool install 'bub[app]' or pip install 'bub[app]'" ) - sys.modules[__name__].app() + create_cli_app()() if __name__ == "__main__": diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 70351043..e52405e7 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -45,7 +45,7 @@ def test_bub_command_without_app_extra_exits_with_install_hint(monkeypatch: pyte 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, "app", lambda: calls.append("app"), raising=False) + monkeypatch.setattr(cli, "create_cli_app", lambda: lambda: calls.append("app")) cli.main() diff --git a/website/src/content/docs/docs/build/distribution.mdx b/website/src/content/docs/docs/build/distribution.mdx index 867c04aa..1f47f5ed 100644 --- a/website/src/content/docs/docs/build/distribution.mdx +++ b/website/src/content/docs/docs/build/distribution.mdx @@ -63,7 +63,7 @@ def main() -> None: 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__.main` — the same entry point as the bare `bub` command, which runs the Typer app `bub.__main__.app` and reports a missing `bub[app]` extra. 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/zh-cn/docs/build/distribution.mdx b/website/src/content/docs/zh-cn/docs/build/distribution.mdx index e63fdcfe..47b6e219 100644 --- a/website/src/content/docs/zh-cn/docs/build/distribution.mdx +++ b/website/src/content/docs/zh-cn/docs/build/distribution.mdx @@ -63,7 +63,7 @@ def main() -> None: 两点值得指出: - 产品在导入 Bub 之前先解析出粘性工作区,然后 `chdir` 进去。[`BubFramework`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py) 在构造时把 `Path.cwd()` 快照为工作区,所以 `chdir` 必须先发生。 -- 之后产品把控制权交给 `bub.__main__.main` —— 与裸 `bub` 命令使用的是同一个入口,它运行 Typer 应用 `bub.__main__.app`,并在缺少 `bub[app]` extra 时给出提示。每个贡献 `register_cli_commands` 的插件都会自动接入。 +- 之后产品把控制权交给 `bub.__main__.main` —— 与裸 `bub` 命令使用的是同一个入口,它用 `create_cli_app()` 构建 Typer 应用,并在缺少 `bub[app]` extra 时给出提示。每个贡献 `register_cli_commands` 的插件都会自动接入。 把 CLI 接入脚本入口: From 746d7d366a9601a6169600f88f2bbeebf56d6cd5 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 17:03:55 +0800 Subject: [PATCH 07/11] refactor!: rename bub.core to bub.agent and keep the vocabulary in place - Rename the agent kernel package `bub.core` to `bub.agent`, without a compatibility alias. `bub.store`, `bub.tape`, `bub.tools` and `bub.sidecars` stay as aliases of the `bub.agent` modules. - Move `errors`, `prompt`, `tracing`, `streaming` and `TurnState` back to their original top-level modules and drop the aliases added for them; they need only the kernel's dependencies. - Remove the import-linter hook and contracts. A test still checks that importing `bub.agent` loads none of the `app` extra's dependencies. BREAKING CHANGE: `bub.core` (only in 0.6.0a1) is now `bub.agent`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 6 +- .pre-commit-config.yaml | 11 - AGENTS.md | 11 +- README.md | 2 +- pyproject.toml | 30 +- src/bub/__init__.py | 6 +- src/bub/__main__.py | 2 +- src/bub/{core => agent}/__init__.py | 16 +- src/bub/{core => agent}/agent.py | 34 +-- src/bub/{core => agent}/commands.py | 0 src/bub/{core => agent}/context.py | 4 +- src/bub/{core => agent}/events.py | 10 +- src/bub/{core => agent}/model.py | 0 src/bub/{core => agent}/recorder.py | 4 +- src/bub/{core => agent}/sidecars.py | 0 src/bub/{core => agent}/store.py | 4 +- src/bub/{core => agent}/system_prompt.py | 10 +- src/bub/{core => agent}/tape.py | 16 +- src/bub/{core => agent}/tools.py | 10 +- src/bub/builtin/agent.py | 25 +- src/bub/builtin/archive.py | 6 +- src/bub/builtin/codemode/__init__.py | 6 +- src/bub/builtin/codemode/code_runner.py | 2 +- src/bub/builtin/codemode/republic_bridge.py | 4 +- src/bub/builtin/hook_impl.py | 22 +- src/bub/builtin/model.py | 2 +- src/bub/builtin/settings.py | 6 +- src/bub/builtin/spill.py | 6 +- src/bub/builtin/store.py | 6 +- src/bub/builtin/tools.py | 4 +- src/bub/channels/base.py | 2 +- src/bub/channels/cli/__init__.py | 10 +- src/bub/channels/contracts.py | 2 +- src/bub/channels/manager.py | 2 +- src/bub/channels/telegram.py | 2 +- src/bub/core/errors.py | 40 --- src/bub/core/prompt.py | 58 ---- src/bub/core/streaming.py | 92 ------ src/bub/core/tracing.py | 259 ----------------- src/bub/core/turn.py | 5 - src/bub/errors.py | 42 ++- src/bub/framework.py | 12 +- src/bub/hooks/interception.py | 2 +- src/bub/hooks/runtime.py | 4 +- src/bub/hooks/specs.py | 18 +- src/bub/prompt.py | 60 +++- src/bub/sidecars.py | 6 +- src/bub/store.py | 6 +- src/bub/streaming.py | 94 ++++++- src/bub/tape.py | 6 +- src/bub/tools.py | 6 +- src/bub/tracing.py | 261 +++++++++++++++++- src/bub/turn.py | 7 +- src/bub/utils.py | 2 +- tests/model_fakes.py | 10 +- tests/test_agent_hooks.py | 10 +- tests/test_builtin_agent.py | 15 +- tests/test_builtin_cli.py | 2 +- tests/test_builtin_codex.py | 10 +- tests/test_builtin_hook_impl.py | 9 +- tests/test_builtin_tools.py | 8 +- tests/test_channels.py | 12 +- tests/test_codemode.py | 18 +- tests/test_core_agent.py | 10 +- tests/test_core_agent_step.py | 12 +- tests/test_core_boundary.py | 29 +- tests/test_environment.py | 6 +- tests/test_errors.py | 2 +- tests/test_file_tape_store_entry_ids.py | 6 +- tests/test_fork_store_merge_back.py | 4 +- tests/test_framework.py | 2 +- tests/test_hook_runtime.py | 2 +- tests/test_image_message.py | 2 +- tests/test_otlp.py | 8 +- tests/test_provider_messages.py | 18 +- tests/test_sdk_isolation.py | 13 +- tests/test_session_settings.py | 6 +- tests/test_settings.py | 6 +- tests/test_spill.py | 12 +- tests/test_subagent_tool.py | 4 +- tests/test_tape.py | 6 +- tests/test_tape_search_output.py | 2 +- tests/test_tool_loading.py | 2 +- tests/test_tools.py | 12 +- tests/test_tracing.py | 17 +- website/src/content/docs/docs/build/sdk.md | 22 +- .../docs/docs/concepts/tape-and-context.mdx | 2 +- .../docs/docs/concepts/turn-pipeline.mdx | 2 +- .../docs/docs/getting-started/install.mdx | 2 +- .../src/content/docs/docs/reference/hooks.mdx | 4 +- .../src/content/docs/docs/reference/types.mdx | 10 +- .../content/docs/docs/tutorials/fastapi.mdx | 8 +- .../src/content/docs/docs/tutorials/index.mdx | 2 +- .../src/content/docs/zh-cn/docs/build/sdk.md | 22 +- .../zh-cn/docs/concepts/tape-and-context.mdx | 2 +- .../zh-cn/docs/concepts/turn-pipeline.mdx | 2 +- .../zh-cn/docs/getting-started/install.mdx | 2 +- .../docs/zh-cn/docs/reference/hooks.mdx | 4 +- .../docs/zh-cn/docs/reference/types.mdx | 10 +- .../docs/zh-cn/docs/tutorials/fastapi.mdx | 8 +- .../docs/zh-cn/docs/tutorials/index.mdx | 2 +- 101 files changed, 774 insertions(+), 868 deletions(-) rename src/bub/{core => agent}/__init__.py (75%) rename src/bub/{core => agent}/agent.py (96%) rename src/bub/{core => agent}/commands.py (100%) rename src/bub/{core => agent}/context.py (96%) rename src/bub/{core => agent}/events.py (97%) rename src/bub/{core => agent}/model.py (100%) rename src/bub/{core => agent}/recorder.py (94%) rename src/bub/{core => agent}/sidecars.py (100%) rename src/bub/{core => agent}/store.py (99%) rename src/bub/{core => agent}/system_prompt.py (93%) rename src/bub/{core => agent}/tape.py (98%) rename src/bub/{core => agent}/tools.py (98%) delete mode 100644 src/bub/core/errors.py delete mode 100644 src/bub/core/prompt.py delete mode 100644 src/bub/core/streaming.py delete mode 100644 src/bub/core/tracing.py delete mode 100644 src/bub/core/turn.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fa0d6b6c..d0c2b6a1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,12 +56,12 @@ jobs: 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 + - name: Use bub.agent 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 + from bub.agent import Agent + from bub.agent.store import InMemoryTapeStore import bub.errors, bub.store, bub.tape, bub.tools Agent(store=InMemoryTapeStore()) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 824583c5..a0fefde1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,14 +26,3 @@ repos: - id: ruff args: [--exit-non-zero-on-fix] - id: ruff-format - - - repo: local - hooks: - - id: import-linter - name: import-linter - # Contracts live in pyproject.toml; grimp finds the packages through PYTHONPATH without installing them. - entry: env PYTHONPATH=src lint-imports - language: python - additional_dependencies: ["import-linter==2.15"] - pass_filenames: false - files: ^(src/.*\.py|pyproject\.toml)$ diff --git a/AGENTS.md b/AGENTS.md index c753c52d..8c962ed5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,19 +5,19 @@ Core code lives under `src/`: - `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, plus the kernel vocabulary it shares with the framework (`errors.py`, `prompt.py`, `streaming.py`, `tracing.py`, `turn.py`). Nothing in `bub.core` imports the rest of `bub`; the old top-level paths (`bub.errors`, `bub.store`, ...) are compatibility aliases. +- `src/bub/agent/`: 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,model_selection}.py`: small framework vocabulary owned by each concern. -- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.core.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, tape services, and the fuzzy-searching file tape store. +- `src/bub/{envelope,turn,streaming,errors,model_selection}.py`: small kernel vocabulary owned by each concern. +- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.agent.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, tape services, and the fuzzy-searching file tape store. - `src/bub/channels/`: channel abstractions plus CLI and Telegram adapters. - `src/bub/skills.py` / `src/bub/tools.py`: skill discovery and tool registry. - `src/skills/`: bundled skills shipped with Bub. 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`. +Packaging: `pip install bub` installs only the kernel's dependencies (republic, pydantic, loguru, typing-extensions). Everything outside `bub.agent` (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 @@ -29,10 +29,9 @@ Packaging: `pip install bub` installs only the kernel's dependencies (republic, - `uv run bub hooks`: inspect discovered hook bindings. - `uv run ruff check .`: run Ruff directly when you only want lint feedback. - `uv run mypy src`: run mypy directly against `src/`. -- `uv run prek run import-linter -a`: check the import contracts in `pyproject.toml` (`bub.core` must not import the rest of `bub` or application dependencies); `prek` and `make check` run it too. - `uv run pytest -q`: run the main test suite without doctests. - `make test`: run pytest with doctests enabled. -- `make check`: lock validation, `prek` (including import contracts), and typing. +- `make check`: lock validation, `prek`, and typing. - `make docs` / `make docs-test` / `make docs-preview`: serve, build, or preview the Astro website/docs in `website/`. ## Coding Style & Naming Conventions diff --git a/README.md b/README.md index 5884dbb2..7348d762 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.** `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. +- **Embeddable Python SDK.** `pip install bub` installs only `bub.agent`, 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 6a52ad6d..a9bb950b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] -# The agent kernel, bub.core. Everything else (framework, CLI, channels, builtin agent) is in the `app` extra. +# The agent kernel, bub.agent. Everything else (framework, CLI, channels, builtin agent) is in the `app` extra. dependencies = [ "republic>=0.6.0a1", "pydantic>=2.12.5", @@ -111,34 +111,6 @@ exclude = [ [tool.pytest.ini_options] testpaths = ["tests"] -[tool.importlinter] -root_packages = ["bub"] -include_external_packages = true - -[[tool.importlinter.contracts]] -name = "bub.core is independent of the rest of bub" -type = "forbidden" -source_modules = ["bub.core"] -forbidden_modules = ["bub.*"] - -[[tool.importlinter.contracts]] -name = "bub.core does not use application dependencies" -type = "forbidden" -source_modules = ["bub.core"] -forbidden_modules = [ - "aiohttp", - "dotenv", - "inquirer_textual", - "pluggy", - "prompt_toolkit", - "pydantic_settings", - "rapidfuzz", - "rich", - "telegram", - "typer", - "yaml", -] - [tool.ruff] target-version = "py312" line-length = 120 diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 429b1842..9b1a40c5 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,6 +1,6 @@ """Bub framework package. -The top-level names are imported on first access, so ``import bub.core`` does not load +The top-level names are imported on first access, so ``import bub.agent`` does not load the framework, its settings or the CLI. """ @@ -14,8 +14,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from bub.agent.tools import tool from bub.configure import Settings, config, ensure_config - from bub.core.tools import tool from bub.framework import BubFramework from bub.hooks import hookimpl @@ -50,7 +50,7 @@ def __getattr__(name: str) -> Any: if name == "tool": - from bub.core.tools import tool + from bub.agent.tools import tool return tool if name != "home" and name not in _EXPORTS: diff --git a/src/bub/__main__.py b/src/bub/__main__.py index 7c8f4978..e2942be4 100644 --- a/src/bub/__main__.py +++ b/src/bub/__main__.py @@ -48,7 +48,7 @@ def _instrument_bub(level: str) -> None: logger.add(sys.stderr, level=level, colorize=True, diagnose=False) if os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): - from bub.core.tracing import configure_otlp + from bub.tracing import configure_otlp try: configure_otlp() diff --git a/src/bub/core/__init__.py b/src/bub/agent/__init__.py similarity index 75% rename from src/bub/core/__init__.py rename to src/bub/agent/__init__.py index 10b135db..612f43a3 100644 --- a/src/bub/core/__init__.py +++ b/src/bub/agent/__init__.py @@ -4,9 +4,9 @@ to registered handlers; the framework and the builtin agent assemble these components. """ -from bub.core.agent import Agent, events_from -from bub.core.context import default_tape_context -from bub.core.events import ( +from bub.agent.agent import Agent, events_from +from bub.agent.context import default_tape_context +from bub.agent.events import ( AfterLlmCall, AfterToolCall, AgentEvent, @@ -27,11 +27,11 @@ ToolCallDecision, ToolCallResult, ) -from bub.core.model import ChatModelClient, Model, ModelClient -from bub.core.recorder import TapeRecorder -from bub.core.system_prompt import PromptContext, PromptSection, SystemPrompt -from bub.core.tape import Tape, TapeContext, session_tape_name -from bub.core.tools import Tool, ToolContext, tool +from bub.agent.model import ChatModelClient, Model, ModelClient +from bub.agent.recorder import TapeRecorder +from bub.agent.system_prompt import PromptContext, PromptSection, SystemPrompt +from bub.agent.tape import Tape, TapeContext, session_tape_name +from bub.agent.tools import Tool, ToolContext, tool __all__ = [ "AfterLlmCall", diff --git a/src/bub/core/agent.py b/src/bub/agent/agent.py similarity index 96% rename from src/bub/core/agent.py rename to src/bub/agent/agent.py index a5bf187b..c2c444f2 100644 --- a/src/bub/core/agent.py +++ b/src/bub/agent/agent.py @@ -16,10 +16,9 @@ from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady from republic.tools import BuiltinTool -from bub.core.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix -from bub.core.context import default_tape_context -from bub.core.errors import BubError, ErrorKind -from bub.core.events import ( +from bub.agent.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix +from bub.agent.context import default_tape_context +from bub.agent.events import ( AfterLlmCall, AgentEvent, BeforeLlmCall, @@ -33,17 +32,18 @@ StepStarted, TapeEvent, ) -from bub.core.model import Model, ModelClient, is_context_length_error, model_client -from bub.core.prompt import UserContent -from bub.core.recorder import TapeRecorder -from bub.core.sidecars import TapeSidecar -from bub.core.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.core.system_prompt import PromptContext, PromptSection, SystemPrompt -from bub.core.tape import Tape, TapeContext -from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools -from bub.core.tracing import Span, current_span -from bub.core.turn import TurnState +from bub.agent.model import Model, ModelClient, is_context_length_error, model_client +from bub.agent.recorder import TapeRecorder +from bub.agent.sidecars import TapeSidecar +from bub.agent.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store +from bub.agent.system_prompt import PromptContext, PromptSection, SystemPrompt +from bub.agent.tape import Tape, TapeContext +from bub.agent.tools import Tool, ToolContext, ToolExecutor, model_tools +from bub.errors import BubError, ErrorKind +from bub.prompt import UserContent +from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.tracing import Span, current_span +from bub.turn import TurnState MAX_AUTO_HANDOFF_RETRIES = 1 @@ -56,7 +56,7 @@ class Agent: The agent owns no configuration or session. A run calls the model given to it, or else the agent's ``model``: a ``republic.ChatModel`` or a - :class:`~bub.core.model.ModelClient`. The agent is assembled from its components: + :class:`~bub.agent.model.ModelClient`. The agent is assembled from its components: Args: model: Default model of runs not given one; None requires each run to give one. @@ -76,7 +76,7 @@ class Agent: command_prefix: When set, a text prompt starting with it runs as a command (see :meth:`run_command`) instead of the agent loop. None disables commands. - The loop reports its progress as events (:mod:`bub.core.events`) on ``events``; + The loop reports its progress as events (:mod:`bub.agent.events`) on ``events``; register handlers with ``agent.events.on``. A :class:`TapeRecorder`, registered first, records them on the run's tape. ``BeforeLlmCall`` and ``BeforeToolCall`` handlers may change or short-circuit the call. diff --git a/src/bub/core/commands.py b/src/bub/agent/commands.py similarity index 100% rename from src/bub/core/commands.py rename to src/bub/agent/commands.py diff --git a/src/bub/core/context.py b/src/bub/agent/context.py similarity index 96% rename from src/bub/core/context.py rename to src/bub/agent/context.py index 3e649c24..1464714e 100644 --- a/src/bub/core/context.py +++ b/src/bub/agent/context.py @@ -8,8 +8,8 @@ import republic -from bub.core.tape import TapeContext, TapeEntry, to_message -from bub.core.tools import result_content +from bub.agent.tape import TapeContext, TapeEntry, to_message +from bub.agent.tools import result_content def default_tape_context() -> TapeContext: diff --git a/src/bub/core/events.py b/src/bub/agent/events.py similarity index 97% rename from src/bub/core/events.py rename to src/bub/agent/events.py index 90cc2cf1..280a81e6 100644 --- a/src/bub/core/events.py +++ b/src/bub/agent/events.py @@ -10,13 +10,13 @@ import republic -from bub.core.errors import BubError -from bub.core.prompt import UserContent -from bub.core.turn import TurnState +from bub.errors import BubError +from bub.prompt import UserContent +from bub.turn import TurnState if TYPE_CHECKING: - from bub.core.tape import Tape - from bub.core.tools import ToolContext + from bub.agent.tape import Tape + from bub.agent.tools import ToolContext @dataclass(frozen=True) diff --git a/src/bub/core/model.py b/src/bub/agent/model.py similarity index 100% rename from src/bub/core/model.py rename to src/bub/agent/model.py diff --git a/src/bub/core/recorder.py b/src/bub/agent/recorder.py similarity index 94% rename from src/bub/core/recorder.py rename to src/bub/agent/recorder.py index ddc14f21..d5571248 100644 --- a/src/bub/core/recorder.py +++ b/src/bub/agent/recorder.py @@ -7,8 +7,8 @@ import republic -from bub.core.events import ChatCompleted, CommandFinished, EventBus, RunStarted, StepFinished, StepStarted -from bub.core.prompt import UserContent +from bub.agent.events import ChatCompleted, CommandFinished, EventBus, RunStarted, StepFinished, StepStarted +from bub.prompt import UserContent class TapeRecorder: diff --git a/src/bub/core/sidecars.py b/src/bub/agent/sidecars.py similarity index 100% rename from src/bub/core/sidecars.py rename to src/bub/agent/sidecars.py diff --git a/src/bub/core/store.py b/src/bub/agent/store.py similarity index 99% rename from src/bub/core/store.py rename to src/bub/agent/store.py index cd90dd1d..7f8f96df 100644 --- a/src/bub/core/store.py +++ b/src/bub/agent/store.py @@ -15,8 +15,8 @@ from loguru import logger from typing_extensions import TypeIs -from bub.core.errors import BubError, ErrorKind -from bub.core.tape import TapeEntry +from bub.agent.tape import TapeEntry +from bub.errors import BubError, ErrorKind class TapeStore(Protocol): diff --git a/src/bub/core/system_prompt.py b/src/bub/agent/system_prompt.py similarity index 93% rename from src/bub/core/system_prompt.py rename to src/bub/agent/system_prompt.py index 21b605b3..6d5acf6e 100644 --- a/src/bub/core/system_prompt.py +++ b/src/bub/agent/system_prompt.py @@ -8,13 +8,13 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, TypeVar -from bub.core.prompt import UserContent -from bub.core.turn import TurnState +from bub.prompt import UserContent +from bub.turn import TurnState if TYPE_CHECKING: - from bub.core.agent import Agent - from bub.core.tape import Tape - from bub.core.tools import Tool + from bub.agent.agent import Agent + from bub.agent.tape import Tape + from bub.agent.tools import Tool @dataclass(frozen=True) diff --git a/src/bub/core/tape.py b/src/bub/agent/tape.py similarity index 98% rename from src/bub/core/tape.py rename to src/bub/agent/tape.py index 6d5c4a33..85d09240 100644 --- a/src/bub/core/tape.py +++ b/src/bub/agent/tape.py @@ -14,9 +14,9 @@ import republic -from bub.core import tracing -from bub.core.errors import BubError, ErrorKind -from bub.core.sidecars import TapeSidecar, sidecar_tape_name +from bub import tracing +from bub.agent.sidecars import TapeSidecar, sidecar_tape_name +from bub.errors import BubError, ErrorKind __all__ = [ "LAST_ANCHOR", @@ -44,7 +44,7 @@ ] if TYPE_CHECKING: - from bub.core.store import ( + from bub.agent.store import ( AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryQueryMixin, @@ -72,7 +72,7 @@ def __getattr__(name: str) -> Any: if name not in _STORE_EXPORTS: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - from bub.core import store + from bub.agent import store return getattr(store, name) @@ -195,7 +195,7 @@ def to_message( data = dict(payload) raw_content = data.pop("content", None) if role == "tool" and raw_content is not None and not isinstance(raw_content, str | list): - from bub.core.tools import render_result + from bub.agent.tools import render_result raw_content = render_result(raw_content) content = _content_parts(raw_content) @@ -311,7 +311,7 @@ def scoped(self, name: str, context: TapeContext | None = None) -> Tape: return replace(self, context=context or self.context, _name=name) def query(self) -> TapeQuery[AsyncTapeStore]: - from bub.core.store import TapeQuery + from bub.agent.store import TapeQuery return TapeQuery(tape=self.name, store=self.store) @@ -509,7 +509,7 @@ def session_tape(self, session_id: str, workspace: Path, context: TapeContext | @contextlib.asynccontextmanager async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[Tape, None]: - from bub.core.store import ForkTapeStore + from bub.agent.store import ForkTapeStore managed_sidecars = tuple(sidecar_tape_name(self.name, sidecar.name) for sidecar in self.sidecars) fork_store = ForkTapeStore(self.store, self.name, sidecars=managed_sidecars) diff --git a/src/bub/core/tools.py b/src/bub/agent/tools.py similarity index 98% rename from src/bub/core/tools.py rename to src/bub/agent/tools.py index 7441a993..7809c315 100644 --- a/src/bub/core/tools.py +++ b/src/bub/agent/tools.py @@ -12,11 +12,11 @@ from loguru import logger from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call -from bub.core.errors import BubError, ErrorKind -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult -from bub.core.prompt import UserContent -from bub.core.tape import Tape -from bub.core.tracing import Span +from bub.agent.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult +from bub.agent.tape import Tape +from bub.errors import BubError, ErrorKind +from bub.prompt import UserContent +from bub.tracing import Span @dataclass(frozen=True) diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index c6a4006e..4d0acb30 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -7,16 +7,17 @@ from collections.abc import Collection from pathlib import Path -import bub.core as core +from bub.agent import Agent as CoreAgent +from bub.agent import PromptContext +from bub.agent.store import AsyncTapeStore, TapeStore +from bub.agent.tape import Tape, session_tape_name +from bub.agent.tools import REGISTRY, Tool, model_tools from bub.builtin.settings import load_settings -from bub.core.prompt import LegacyPrompt, UserContent, prompt_text, to_content -from bub.core.store import AsyncTapeStore, TapeStore -from bub.core.streaming import AsyncStreamEvents, StreamState -from bub.core.tape import Tape, session_tape_name -from bub.core.tools import REGISTRY, Tool, model_tools from bub.envelope import field_of from bub.framework import BubFramework +from bub.prompt import LegacyPrompt, UserContent, prompt_text, to_content from bub.skills import discover_skills, render_skills_prompt +from bub.streaming import AsyncStreamEvents, StreamState from bub.turn import TurnState from bub.utils import workspace_from_state @@ -24,11 +25,11 @@ ALLOWED_SKILLS_STATE_KEY = "allowed_skills" -class BubAgent(core.Agent): +class BubAgent(CoreAgent): """Bub's agent: the core loop configured by settings and the framework's hooks. Adds commands, skills, code mode, deferred tools, steering and the - ``continue_prompt`` hook to :class:`bub.core.Agent`. System prompt sections, in + ``continue_prompt`` hook to :class:`bub.agent.Agent`. System prompt sections, in order: the framework's ``system_prompt`` hooks, code mode, deferred tools, skills, then sections added with ``system_prompt.add``. The framework's interception hooks handle events before handlers added with ``events.on``. @@ -224,11 +225,11 @@ def _split_code_mode(self, state: TurnState) -> tuple[list[Tool], list[Tool] | N code_tools = [tool for tool in direct_tools if tool.exposure in ("auto", "code")] return [tool for tool in tools if tool.exposure == "direct"], code_tools - def _hook_prompt(self, context: core.PromptContext) -> str: + def _hook_prompt(self, context: PromptContext) -> str: return self.framework.get_system_prompt(prompt=context.prompt, state=context.state) @staticmethod - def _code_mode_prompt(context: core.PromptContext) -> str | None: + def _code_mode_prompt(context: PromptContext) -> str | None: from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, render_code_mode_prompt, write_tool_stub state = context.state @@ -239,13 +240,13 @@ def _code_mode_prompt(context: core.PromptContext) -> str | None: ) return render_code_mode_prompt(stub_path) - def _deferred_tools_prompt(self, context: core.PromptContext) -> str: + def _deferred_tools_prompt(self, context: PromptContext) -> str: from bub.builtin.tools import render_deferred_tools_prompt tools, _ = self._split_code_mode(context.state) return render_deferred_tools_prompt(_deferred_tools(tools)) - def _skills_prompt(self, context: core.PromptContext) -> str: + def _skills_prompt(self, context: PromptContext) -> str: allowed = context.state.get(ALLOWED_SKILLS_STATE_KEY) return self._load_skills_prompt( prompt_text(context.prompt), workspace_from_state(context.state), None if allowed is None else set(allowed) diff --git a/src/bub/builtin/archive.py b/src/bub/builtin/archive.py index 9e190514..cad6d9a4 100644 --- a/src/bub/builtin/archive.py +++ b/src/bub/builtin/archive.py @@ -8,9 +8,9 @@ from pathlib import Path from typing import Any -from bub.core.sidecars import sidecar_tape_name -from bub.core.store import TapeQuery -from bub.core.tape import Tape +from bub.agent.sidecars import sidecar_tape_name +from bub.agent.store import TapeQuery +from bub.agent.tape import Tape def archive_dir() -> Path: diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py index 02060a2f..56fbb0f5 100644 --- a/src/bub/builtin/codemode/__init__.py +++ b/src/bub/builtin/codemode/__init__.py @@ -16,13 +16,13 @@ import republic +from bub.agent.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool from bub.builtin.codemode.republic_bridge import REPUBLIC_CALL_PREFIX, RepublicSession, decode_value, encode_value from bub.builtin.environment import environment_from_state from bub.builtin.settings import set_session_setting -from bub.core.errors import BubError, ErrorKind -from bub.core.prompt import UserContent -from bub.core.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool from bub.environment import CallTool, CodeFailed +from bub.errors import BubError, ErrorKind +from bub.prompt import UserContent RUN_CODE_TOOL_NAME = "run_code" CODE_MODE_STATE_KEY = "code_mode" diff --git a/src/bub/builtin/codemode/code_runner.py b/src/bub/builtin/codemode/code_runner.py index 52eb0226..faffeb73 100644 --- a/src/bub/builtin/codemode/code_runner.py +++ b/src/bub/builtin/codemode/code_runner.py @@ -18,8 +18,8 @@ from pydantic_core import to_jsonable_python from bub.builtin.codemode import code_runner_child -from bub.core.errors import BubError, ErrorKind from bub.environment import CodeFailed +from bub.errors import BubError, ErrorKind if TYPE_CHECKING: from bub.environment import CallTool, Environment, Process diff --git a/src/bub/builtin/codemode/republic_bridge.py b/src/bub/builtin/codemode/republic_bridge.py index 8eb1406d..31a982de 100644 --- a/src/bub/builtin/codemode/republic_bridge.py +++ b/src/bub/builtin/codemode/republic_bridge.py @@ -29,8 +29,8 @@ UsageDelta, ) -from bub.core.errors import BubError, ErrorKind -from bub.core.tools import ToolContext +from bub.agent.tools import ToolContext +from bub.errors import BubError, ErrorKind REPUBLIC_CALL_PREFIX = "republic." REPUBLIC_KEY = "$republic" diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 1885f59b..7f0b770e 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -10,6 +10,13 @@ from loguru import logger from bub import inquirer as bub_inquirer +from bub.agent.commands import strip_command_prefix +from bub.agent.context import default_tape_context +from bub.agent.events import ToolCall, ToolCallDecision, ToolCallResult +from bub.agent.sidecars import TapeSidecar +from bub.agent.store import TapeStore +from bub.agent.tape import TapeContext, session_tape_name +from bub.agent.tools import is_content_result, render_result from bub.builtin.agent import BubAgent from bub.builtin.onboarding import collect_model_config from bub.builtin.settings import load_session_settings, load_settings @@ -18,21 +25,14 @@ from bub.channels.base import Channel from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage, MediaItem -from bub.core.commands import strip_command_prefix -from bub.core.context import default_tape_context -from bub.core.errors import BubError -from bub.core.events import ToolCall, ToolCallDecision, ToolCallResult -from bub.core.prompt import UserContent -from bub.core.sidecars import TapeSidecar -from bub.core.store import TapeStore -from bub.core.streaming import AsyncStreamEvents -from bub.core.tape import TapeContext, session_tape_name -from bub.core.tools import is_content_result, render_result from bub.envelope import Envelope, content_of, field_of from bub.environment import Environment +from bub.errors import BubError from bub.framework import BubFramework from bub.hooks import hookimpl from bub.model_selection import ModelChoice, ModelOptions +from bub.prompt import UserContent +from bub.streaming import AsyncStreamEvents from bub.turn import TurnState AGENTS_FILE_NAME = "AGENTS.md" @@ -338,9 +338,9 @@ async def before_tool_call( replace it with a guidance ``tool_result`` so the model can re-issue a valid call on the next step. Commands run the agent's tools directly and are left alone. """ + from bub.agent.tools import model_tools from bub.builtin.codemode import CODE_TOOLS_STATE_KEY from bub.builtin.tools import TOOL_DESCRIBE_TOOL_NAME - from bub.core.tools import model_tools if call.context is not None and call.context.command: return None diff --git a/src/bub/builtin/model.py b/src/bub/builtin/model.py index 2d1452cc..23e837d4 100644 --- a/src/bub/builtin/model.py +++ b/src/bub/builtin/model.py @@ -11,7 +11,7 @@ import republic from loguru import logger -from bub.core.tracing import current_span, event +from bub.tracing import current_span, event @dataclass(frozen=True) diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index ef11fb22..80e1f39d 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -12,12 +12,12 @@ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict from bub import Settings, __version__, config, ensure_config +from bub.agent.commands import validate_command_prefix from bub.builtin.model import BubModel, ModelCandidate -from bub.core.commands import validate_command_prefix if TYPE_CHECKING: - from bub.core.tape import Tape - from bub.core.tools import ToolContext + from bub.agent.tape import Tape + from bub.agent.tools import ToolContext DEFAULT_MODEL = "openrouter:openrouter/free" DEFAULT_MAX_TOKENS = 16384 diff --git a/src/bub/builtin/spill.py b/src/bub/builtin/spill.py index 46c614cb..93b5eb52 100644 --- a/src/bub/builtin/spill.py +++ b/src/bub/builtin/spill.py @@ -11,10 +11,10 @@ from pydantic_settings import SettingsConfigDict from bub import config +from bub.agent.tape import Tape, TapeEntry, TapeQuery +from bub.agent.tools import ToolContext, tool from bub.configure import Settings -from bub.core.errors import BubError, ErrorKind -from bub.core.tape import Tape, TapeEntry, TapeQuery -from bub.core.tools import ToolContext, tool +from bub.errors import BubError, ErrorKind SPILL_READ_TOOL_NAME = "spill.read" SPILL_READ_MODEL_NAME = SPILL_READ_TOOL_NAME.replace(".", "_") diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py index 2da18e2c..3e4694be 100644 --- a/src/bub/builtin/store.py +++ b/src/bub/builtin/store.py @@ -6,8 +6,8 @@ from collections.abc import Iterable from dataclasses import replace -from bub.core.store import FileTapeStore, TapeQuery -from bub.core.tape import TapeEntry +from bub.agent.store import FileTapeStore, TapeQuery +from bub.agent.tape import TapeEntry from bub.utils import get_entry_text WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") @@ -17,7 +17,7 @@ class FuzzyFileTapeStore(FileTapeStore): - """:class:`~bub.core.store.FileTapeStore` whose text queries also match approximately.""" + """:class:`~bub.agent.store.FileTapeStore` whose text queries also match approximately.""" def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: if not query._query: diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 770d9d1d..5adbb4ea 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -11,15 +11,15 @@ from pydantic import BaseModel, Field +from bub.agent.tools import REGISTRY, Tool, ToolContext, tool from bub.builtin.environment import environment_from_state from bub.builtin.settings import load_settings, set_session_setting from bub.builtin.shell_manager import shell_manager -from bub.core.tools import REGISTRY, Tool, ToolContext, tool from bub.skills import discover_skills if TYPE_CHECKING: + from bub.agent.tape import Tape from bub.builtin.agent import BubAgent - from bub.core.tape import Tape DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 DEFAULT_HEADERS = {"accept": "text/markdown"} diff --git a/src/bub/channels/base.py b/src/bub/channels/base.py index 36587f9a..6b0f6018 100644 --- a/src/bub/channels/base.py +++ b/src/bub/channels/base.py @@ -5,8 +5,8 @@ from bub.channels.admission import AdmitDecision, TurnSnapshot from bub.channels.message import ChannelMessage -from bub.core.streaming import StreamEvent from bub.envelope import Envelope +from bub.streaming import StreamEvent class Channel(ABC): diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 9d73b08b..049aae18 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -26,6 +26,10 @@ from rich.tree import Tree import bub +from bub.agent.commands import strip_command_prefix +from bub.agent.events import AfterToolCall, BeforeToolCall, EventBus +from bub.agent.tape import TapeInfo, session_tape_name +from bub.agent.tools import REGISTRY from bub.builtin.agent import BubAgent from bub.channels.admission import AdmitDecision, TurnSnapshot from bub.channels.base import Interface @@ -38,12 +42,8 @@ from bub.channels.cli.writers import MarkdownWriter from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage -from bub.core.commands import strip_command_prefix -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus -from bub.core.streaming import StreamEvent -from bub.core.tape import TapeInfo, session_tape_name -from bub.core.tools import REGISTRY from bub.envelope import Envelope, field_of +from bub.streaming import StreamEvent _GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment] _PROMPT_REFRESH_INTERVAL: float = SPINNERS["dots"]["interval"] / 1000.0 # type: ignore[operator] diff --git a/src/bub/channels/contracts.py b/src/bub/channels/contracts.py index 05e00bbc..76ed016a 100644 --- a/src/bub/channels/contracts.py +++ b/src/bub/channels/contracts.py @@ -5,8 +5,8 @@ from collections.abc import AsyncIterable, Callable, Coroutine from typing import TYPE_CHECKING, Any, Protocol -from bub.core.streaming import StreamEvent from bub.envelope import Envelope +from bub.streaming import StreamEvent if TYPE_CHECKING: from bub.channels.admission import AdmitDecision, TurnSnapshot diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index cf032b1d..06059e57 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -15,9 +15,9 @@ from bub.channels.handler import BufferedMessageHandler from bub.channels.message import ChannelMessage from bub.configure import Settings, ensure_config -from bub.core.streaming import StreamEvent from bub.envelope import Envelope, content_of, field_of from bub.framework import BubFramework +from bub.streaming import StreamEvent from bub.turn import TurnState from bub.utils import wait_until_stopped diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index c455de4a..7974a2c5 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -15,11 +15,11 @@ from telegram.request import HTTPXRequest from bub import config +from bub.agent.commands import strip_command_prefix, validate_command_prefix from bub.channels.base import Channel from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage, MediaItem, MediaType from bub.configure import Settings, ensure_config -from bub.core.commands import strip_command_prefix, validate_command_prefix from bub.utils import exclude_none diff --git a/src/bub/core/errors.py b/src/bub/core/errors.py deleted file mode 100644 index 141bd03f..00000000 --- a/src/bub/core/errors.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Errors reported by Bub's model and tool execution paths.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum -from typing import Any - - -class ErrorKind(StrEnum): - """Stable error kinds for execution decisions.""" - - INVALID_INPUT = "invalid_input" - CONFIG = "config" - PROVIDER = "provider" - TOOL = "tool" - TEMPORARY = "temporary" - NOT_FOUND = "not_found" - UNKNOWN = "unknown" - - -@dataclass -class BubError(Exception): - """Public error type; exception machinery must be able to assign traceback fields.""" - - kind: ErrorKind - message: str - details: dict[str, Any] | None = None - - def __str__(self) -> str: - return f"[{self.kind.value}] {self.message}" - - def as_dict(self) -> dict[str, Any]: - payload: dict[str, Any] = { - "kind": self.kind.value, - "message": self.message, - } - if self.details: - payload["details"] = self.details - return payload diff --git a/src/bub/core/prompt.py b/src/bub/core/prompt.py deleted file mode 100644 index 9b1a5378..00000000 --- a/src/bub/core/prompt.py +++ /dev/null @@ -1,58 +0,0 @@ -"""User prompt content and the legacy prompt forms accepted for compatibility.""" - -from __future__ import annotations - -from typing import Any - -import republic - -from bub.core.errors import BubError, ErrorKind - -type UserContent = str | republic.Image | republic.Audio | republic.Video -type LegacyPrompt = str | list[dict[str, Any]] -"""Prompt forms used before ``list[UserContent]``: text, or text and media content blocks.""" - -_MEDIA_TYPES = (republic.Image, republic.Audio, republic.Video) - - -def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: - """Return user content, converting text and legacy content blocks by their value.""" - from bub.core.tape import to_message - - if isinstance(prompt, str): - return [prompt] - if not isinstance(prompt, list): - raise BubError(ErrorKind.INVALID_INPUT, "Expected user content, text, or a list of content blocks.") - content: list[UserContent] = [] - for item in prompt: - if isinstance(item, str | republic.Image | republic.Audio | republic.Video): - content.append(item) - elif isinstance(item, dict): - for part in to_message({"role": "user", "content": [item]}).parts: - if isinstance(part, republic.Text): - content.append(part.text) - elif isinstance(part, _MEDIA_TYPES): - content.append(part) - else: - raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content block: {item.get('type')}") - else: - raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content: {type(item).__name__}") - return content - - -def to_legacy_prompt(content: list[UserContent]) -> LegacyPrompt: - """Return the legacy form: text without media, otherwise content blocks with media URLs.""" - if all(isinstance(item, str) for item in content): - return prompt_text(content) - blocks: list[dict[str, Any]] = [] - for item in content: - if isinstance(item, str): - blocks.append({"type": "text", "text": item}) - else: - blocks.append({"type": item.kind, "media_type": item.media_type, "url": item.data_url}) - return blocks - - -def prompt_text(content: list[UserContent]) -> str: - """Join the text items of a prompt.""" - return "\n".join(item for item in content if isinstance(item, str)) diff --git a/src/bub/core/streaming.py b/src/bub/core/streaming.py deleted file mode 100644 index 4ccf06c6..00000000 --- a/src/bub/core/streaming.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Transport-neutral events produced by streaming model runs.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import nullcontext -from dataclasses import dataclass -from typing import Any, Literal - -from bub.core.errors import BubError -from bub.core.tracing import Span - - -@dataclass -class StreamState: - error: BubError | None = None - usage: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class StreamEvent: - kind: Literal["text", "reasoning", "tool_call", "tool_result", "usage", "error", "final"] - data: dict[str, Any] - - -class AsyncStreamEvents: - def __init__( - self, - iterator: AsyncIterator[StreamEvent], - *, - state: StreamState | None = None, - on_close: Callable[[], Awaitable[None]] | None = None, - span: Span | None = None, - ) -> None: - self._iterator = iterator - self._state = state or StreamState() - self._on_close = on_close - self._span = span - self._closed = False - - def __aiter__(self) -> AsyncIterator[StreamEvent]: - return self - - async def __anext__(self) -> StreamEvent: - if self._closed: - raise StopAsyncIteration - try: - # Detach before returning an event to the consumer, even across tasks. - with self._span.activate() if self._span else nullcontext(): - return await anext(self._iterator) - except StopAsyncIteration: - await self._close() - raise - except BaseException as exc: - if self._span: - self._span.fail(exc) - await self._close() - raise - - async def aclose(self) -> None: - """Close the source and release resources, including before first iteration.""" - if not self._closed and self._span: - self._span.set(**{"bub.cancelled": True}) - await self._close() - - async def _close(self) -> None: - if self._closed: - return - self._closed = True - try: - with self._span.activate() if self._span else nullcontext(): - try: - if close := getattr(self._iterator, "aclose", None): - await close() - finally: - if self._on_close is not None: - await self._on_close() - except BaseException as exc: - if self._span: - self._span.fail(exc) - raise - finally: - if self._span: - self._span.end() - - @property - def error(self) -> BubError | None: - return self._state.error - - @property - def usage(self) -> dict[str, Any] | None: - return self._state.usage diff --git a/src/bub/core/tracing.py b/src/bub/core/tracing.py deleted file mode 100644 index d9b0fe11..00000000 --- a/src/bub/core/tracing.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Optional GenAI telemetry. Importing Bub never configures an exporter.""" - -from __future__ import annotations - -import asyncio -import importlib -import json -import os -from collections.abc import Iterator, Mapping, Sequence -from contextlib import contextmanager, suppress -from contextvars import ContextVar -from typing import TYPE_CHECKING, Any - -import republic - -if TYPE_CHECKING: - import opentelemetry.trace as otel -else: - try: - otel: Any = importlib.import_module("opentelemetry.trace") - except ImportError: - otel = None - -_OPENINFERENCE_ATTRIBUTES = { - "gen_ai.provider.name": "llm.provider", - "gen_ai.request.model": "llm.model_name", - "gen_ai.response.model": "llm.model_name", - "gen_ai.usage.input_tokens": "llm.token_count.prompt", - "gen_ai.usage.output_tokens": "llm.token_count.completion", - "gen_ai.tool.name": "tool.name", - "gen_ai.tool.call.arguments": "input.value", - "gen_ai.tool.call.result": "output.value", -} - - -def configure_otlp() -> None: - """Configure opt-in HTTP export; preserve application-owned providers.""" - if ( - otel is None - or not (os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) - or os.getenv("OTEL_SDK_DISABLED", "").lower() == "true" - or not isinstance(otel.get_tracer_provider(), otel.ProxyTracerProvider) - ): - return - try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - - protocol = os.getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") or os.getenv( - "OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf" - ) - if protocol != "http/protobuf": - raise ValueError("Bub's trace extra supports OTLP http/protobuf only.") - - exporter = OTLPSpanExporter() - # The SDK reads resource/sampler settings and drains the batch queue at process exit. - provider = TracerProvider() - provider.add_span_processor(BatchSpanProcessor(exporter)) - otel.set_tracer_provider(provider) - - -def _json(value: Any) -> str: - try: - return json.dumps(value, ensure_ascii=False, default=str) - except (TypeError, ValueError, RecursionError): - return '"[unserializable value]"' - - -def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]: - content = message.get("content") - if message.get("role") == "tool": - call_id = message.get("tool_call_id") or (message.get("tool_call") or {}).get("id", "") - return [{"type": "tool_call_response", "id": call_id, "response": content}] - parts: list[dict[str, Any]] = [] - if isinstance(content, str) and content: - parts.append({"type": "text", "content": content}) - elif isinstance(content, list): - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - parts.append({"type": "text", "content": part.get("text", "")}) - elif isinstance(part, dict): - # Preserve media structure without copying inline binary payloads. - parts.append({"type": "text", "content": f"[{part.get('type', 'media')} omitted]"}) - for call in message.get("tool_calls") or []: - arguments = call.get("arguments", {}) - if isinstance(arguments, str): - with suppress(ValueError): - arguments = json.loads(arguments) - parts.append({ - "type": "tool_call", - "id": call.get("id", ""), - "name": call.get("name", ""), - "arguments": arguments, - }) - return parts - - -class Span: - """A span whose lifetime is independent of its task-local activation.""" - - def __init__(self, name: str, attributes: Mapping[str, Any] | None = None) -> None: - self._operation = str((attributes or {}).get("gen_ai.operation.name", "")) - if otel is not None: - self._span = otel.get_tracer("bub").start_span( - name, - kind=otel.SpanKind.CLIENT if self._operation == "chat" else otel.SpanKind.INTERNAL, - attributes={k: v for k, v in (attributes or {}).items() if isinstance(v, str | bool | int | float)}, - ) - else: - self._span = None - self._ended = False - self.set(**(attributes or {})) - self.set(**{ - "openinference.span.kind": { - "invoke_agent": "AGENT", - "chat": "LLM", - "execute_tool": "TOOL", - }.get(self._operation) - }) - - @property - def recording(self) -> bool: - return self._span is not None and self._span.is_recording() - - def set(self, **attributes: Any) -> None: - if not self.recording: - return - for key, alias in _OPENINFERENCE_ATTRIBUTES.items(): - if (value := attributes.get(key)) is not None: - if alias in {"input.value", "output.value"}: - attributes[alias] = value if isinstance(value, str) else _json(value) - attributes[alias.replace("value", "mime_type")] = ( - "text/plain" if isinstance(value, str) else "application/json" - ) - else: - attributes[alias] = value - for key, value in attributes.items(): - if value is not None: - self._span.set_attribute( - key, - value - if isinstance(value, str | bool | int | float) - or ( - key == "gen_ai.response.finish_reasons" - and isinstance(value, list | tuple) - and all(isinstance(v, str) for v in value) - ) - else _json(value), - ) - - def rename(self, name: str) -> None: - if self.recording: - self._span.update_name(name) - - def messages(self, key: str, messages: Sequence[Mapping[str, Any] | republic.Message]) -> None: - if self.recording: - dicts = [m.to_dict() if isinstance(m, republic.Message) else m for m in messages] - normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in dicts] - direction = "input" if key == "gen_ai.input.messages" else "output" - self.set(**{ - key: _json(normalized), - f"{direction}.value": _json(normalized), - f"{direction}.mime_type": "application/json", - }) - if self._operation == "chat": - self.set(**_openinference_messages(direction, normalized)) - - def event(self, name: str, attributes: Mapping[str, Any]) -> None: - if self.recording: - self._span.add_event( - name, - { - k: v if isinstance(v, str | bool | int | float) else _json(v) - for k, v in attributes.items() - if v is not None - }, - ) - - def fail(self, error: BaseException) -> None: - if not self.recording: - return - if isinstance(error, asyncio.CancelledError | GeneratorExit): - self.set(**{"bub.cancelled": True}) - else: - self._span.record_exception(error) - self._span.set_status(otel.Status(otel.StatusCode.ERROR, str(error))) - self.set(**{"error.type": type(error).__name__}) - - def end(self) -> None: - if not self._ended: - self._ended = True - if self._span is not None: - self._span.end() - - @contextmanager - def activate(self) -> Iterator[None]: - token = _CURRENT.set(self) - try: - if self._span is None: - yield - else: - with otel.use_span( - self._span, end_on_exit=False, record_exception=False, set_status_on_exception=False - ): - yield - finally: - _CURRENT.reset(token) - - def correlation(self) -> dict[str, str]: - if self._span is None: - return {} - context = self._span.get_span_context() - if not context.is_valid: - return {} - return {"trace_id": f"{context.trace_id:032x}", "span_id": f"{context.span_id:016x}"} - - -_CURRENT: ContextVar[Span | None] = ContextVar("bub_trace_span", default=None) - - -def _openinference_messages(direction: str, messages: list[dict[str, Any]]) -> dict[str, Any]: - attributes: dict[str, Any] = {} - for index, message in enumerate(messages): - prefix = f"llm.{direction}_messages.{index}.message" - attributes[f"{prefix}.role"] = message["role"] - text: list[str] = [] - call_index = 0 - for part in message["parts"]: - if part["type"] == "text": - text.append(part["content"]) - elif part["type"] == "tool_call_response": - attributes[f"{prefix}.tool_call_id"] = part["id"] - text.append(part["response"] if isinstance(part["response"], str) else _json(part["response"])) - elif part["type"] == "tool_call": - call_prefix = f"{prefix}.tool_calls.{call_index}.tool_call" - attributes[f"{call_prefix}.id"] = part["id"] - attributes[f"{call_prefix}.function.name"] = part["name"] - attributes[f"{call_prefix}.function.arguments"] = _json(part["arguments"]) - call_index += 1 - if text: - attributes[f"{prefix}.content"] = "\n".join(text) - return attributes - - -def current_span() -> Span | None: - return _CURRENT.get() - - -def correlation() -> dict[str, str]: - span = current_span() - return span.correlation() if span else {} - - -def event(event_name: str, **attributes: Any) -> None: - if span := current_span(): - span.event(event_name, attributes) diff --git a/src/bub/core/turn.py b/src/bub/core/turn.py deleted file mode 100644 index 6f446a55..00000000 --- a/src/bub/core/turn.py +++ /dev/null @@ -1,5 +0,0 @@ -"""State shared by the hooks and handlers of one turn.""" - -from typing import Any - -type TurnState = dict[str, Any] diff --git a/src/bub/errors.py b/src/bub/errors.py index c5e7e8f2..141bd03f 100644 --- a/src/bub/errors.py +++ b/src/bub/errors.py @@ -1,8 +1,40 @@ -"""Compatibility alias for :mod:`bub.core.errors`.""" +"""Errors reported by Bub's model and tool execution paths.""" -import sys +from __future__ import annotations -from bub.core import errors as _module -from bub.core.errors import * # noqa: F403 +from dataclasses import dataclass +from enum import StrEnum +from typing import Any -sys.modules[__name__] = _module + +class ErrorKind(StrEnum): + """Stable error kinds for execution decisions.""" + + INVALID_INPUT = "invalid_input" + CONFIG = "config" + PROVIDER = "provider" + TOOL = "tool" + TEMPORARY = "temporary" + NOT_FOUND = "not_found" + UNKNOWN = "unknown" + + +@dataclass +class BubError(Exception): + """Public error type; exception machinery must be able to assign traceback fields.""" + + kind: ErrorKind + message: str + details: dict[str, Any] | None = None + + def __str__(self) -> str: + return f"[{self.kind.value}] {self.message}" + + def as_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "kind": self.kind.value, + "message": self.message, + } + if self.details: + payload["details"] = self.details + return payload diff --git a/src/bub/framework.py b/src/bub/framework.py index 3f8fd3f8..aeae40d0 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -15,20 +15,20 @@ from loguru import logger from bub import configure +from bub.agent.sidecars import TapeSidecar +from bub.agent.store import AsyncTapeStore, TapeStore +from bub.agent.tape import Tape, TapeContext from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import ChannelRouter, MessageHandler -from bub.core.errors import BubError, ErrorKind -from bub.core.prompt import UserContent, to_content, to_legacy_prompt -from bub.core.sidecars import TapeSidecar -from bub.core.store import AsyncTapeStore, TapeStore -from bub.core.streaming import StreamState -from bub.core.tape import Tape, TapeContext from bub.envelope import Envelope, content_of, field_of, unpack_batch from bub.environment import ENVIRONMENT_STATE_KEY, Environment +from bub.errors import BubError, ErrorKind from bub.hooks.interception import PluginAgentHooks from bub.hooks.runtime import _SKIP_VALUE, HookRuntime from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs from bub.model_selection import ModelOptions +from bub.prompt import UserContent, to_content, to_legacy_prompt +from bub.streaming import StreamState from bub.turn import TurnResult, TurnState from bub.utils import maybe_context_manager diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index a16c9c74..0bb69a8d 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -7,7 +7,7 @@ from loguru import logger -from bub.core.events import ( +from bub.agent.events import ( AfterLlmCall, AfterToolCall, BeforeLlmCall, diff --git a/src/bub/hooks/runtime.py b/src/bub/hooks/runtime.py index 8c3ce927..9a353d74 100644 --- a/src/bub/hooks/runtime.py +++ b/src/bub/hooks/runtime.py @@ -10,9 +10,9 @@ import pluggy from loguru import logger -from bub.core.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.envelope import Envelope +from bub.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt +from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.turn import TurnState diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index dbdf2126..7175715b 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -8,9 +8,7 @@ import pluggy -from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot -from bub.channels.contracts import MessageHandler -from bub.core.events import ( +from bub.agent.events import ( LlmCallDecision, LlmCallRequest, LlmCallResult, @@ -18,14 +16,16 @@ ToolCallDecision, ToolCallResult, ) -from bub.core.prompt import LegacyPrompt, UserContent -from bub.core.sidecars import TapeSidecar -from bub.core.store import AsyncTapeStore, TapeStore -from bub.core.streaming import AsyncStreamEvents, StreamState -from bub.core.tape import Tape, TapeContext +from bub.agent.sidecars import TapeSidecar +from bub.agent.store import AsyncTapeStore, TapeStore +from bub.agent.tape import Tape, TapeContext +from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot +from bub.channels.contracts import MessageHandler from bub.envelope import Envelope from bub.environment import Environment from bub.model_selection import ModelOptions +from bub.prompt import LegacyPrompt, UserContent +from bub.streaming import AsyncStreamEvents, StreamState from bub.turn import TurnState if TYPE_CHECKING: @@ -43,7 +43,7 @@ class BubHookSpecs: ``republic.Audio`` and ``republic.Video``; legacy text and content-block results are still converted by inspecting the returned value. Hooks that receive a prompt get the legacy form: text without media, otherwise content - blocks with media URLs (see ``bub.core.prompt.to_legacy_prompt``). + blocks with media URLs (see ``bub.prompt.to_legacy_prompt``). """ @hookspec(firstresult=True) diff --git a/src/bub/prompt.py b/src/bub/prompt.py index 68680793..c1e2abfa 100644 --- a/src/bub/prompt.py +++ b/src/bub/prompt.py @@ -1,8 +1,58 @@ -"""Compatibility alias for :mod:`bub.core.prompt`.""" +"""User prompt content and the legacy prompt forms accepted for compatibility.""" -import sys +from __future__ import annotations -from bub.core import prompt as _module -from bub.core.prompt import * # noqa: F403 +from typing import Any -sys.modules[__name__] = _module +import republic + +from bub.errors import BubError, ErrorKind + +type UserContent = str | republic.Image | republic.Audio | republic.Video +type LegacyPrompt = str | list[dict[str, Any]] +"""Prompt forms used before ``list[UserContent]``: text, or text and media content blocks.""" + +_MEDIA_TYPES = (republic.Image, republic.Audio, republic.Video) + + +def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: + """Return user content, converting text and legacy content blocks by their value.""" + from bub.agent.tape import to_message + + if isinstance(prompt, str): + return [prompt] + if not isinstance(prompt, list): + raise BubError(ErrorKind.INVALID_INPUT, "Expected user content, text, or a list of content blocks.") + content: list[UserContent] = [] + for item in prompt: + if isinstance(item, str | republic.Image | republic.Audio | republic.Video): + content.append(item) + elif isinstance(item, dict): + for part in to_message({"role": "user", "content": [item]}).parts: + if isinstance(part, republic.Text): + content.append(part.text) + elif isinstance(part, _MEDIA_TYPES): + content.append(part) + else: + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content block: {item.get('type')}") + else: + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content: {type(item).__name__}") + return content + + +def to_legacy_prompt(content: list[UserContent]) -> LegacyPrompt: + """Return the legacy form: text without media, otherwise content blocks with media URLs.""" + if all(isinstance(item, str) for item in content): + return prompt_text(content) + blocks: list[dict[str, Any]] = [] + for item in content: + if isinstance(item, str): + blocks.append({"type": "text", "text": item}) + else: + blocks.append({"type": item.kind, "media_type": item.media_type, "url": item.data_url}) + return blocks + + +def prompt_text(content: list[UserContent]) -> str: + """Join the text items of a prompt.""" + return "\n".join(item for item in content if isinstance(item, str)) diff --git a/src/bub/sidecars.py b/src/bub/sidecars.py index 88e59846..ca409663 100644 --- a/src/bub/sidecars.py +++ b/src/bub/sidecars.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.sidecars`.""" +"""Compatibility alias for :mod:`bub.agent.sidecars`.""" import sys -from bub.core import sidecars as _module -from bub.core.sidecars import * # noqa: F403 +from bub.agent import sidecars as _module +from bub.agent.sidecars import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/store.py b/src/bub/store.py index 3fd8aeca..46bd893f 100644 --- a/src/bub/store.py +++ b/src/bub/store.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.store`.""" +"""Compatibility alias for :mod:`bub.agent.store`.""" import sys -from bub.core import store as _module -from bub.core.store import * # noqa: F403 +from bub.agent import store as _module +from bub.agent.store import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/streaming.py b/src/bub/streaming.py index f3aff141..73392b6e 100644 --- a/src/bub/streaming.py +++ b/src/bub/streaming.py @@ -1,8 +1,92 @@ -"""Compatibility alias for :mod:`bub.core.streaming`.""" +"""Transport-neutral events produced by streaming model runs.""" -import sys +from __future__ import annotations -from bub.core import streaming as _module -from bub.core.streaming import * # noqa: F403 +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import nullcontext +from dataclasses import dataclass +from typing import Any, Literal -sys.modules[__name__] = _module +from bub.errors import BubError +from bub.tracing import Span + + +@dataclass +class StreamState: + error: BubError | None = None + usage: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class StreamEvent: + kind: Literal["text", "reasoning", "tool_call", "tool_result", "usage", "error", "final"] + data: dict[str, Any] + + +class AsyncStreamEvents: + def __init__( + self, + iterator: AsyncIterator[StreamEvent], + *, + state: StreamState | None = None, + on_close: Callable[[], Awaitable[None]] | None = None, + span: Span | None = None, + ) -> None: + self._iterator = iterator + self._state = state or StreamState() + self._on_close = on_close + self._span = span + self._closed = False + + def __aiter__(self) -> AsyncIterator[StreamEvent]: + return self + + async def __anext__(self) -> StreamEvent: + if self._closed: + raise StopAsyncIteration + try: + # Detach before returning an event to the consumer, even across tasks. + with self._span.activate() if self._span else nullcontext(): + return await anext(self._iterator) + except StopAsyncIteration: + await self._close() + raise + except BaseException as exc: + if self._span: + self._span.fail(exc) + await self._close() + raise + + async def aclose(self) -> None: + """Close the source and release resources, including before first iteration.""" + if not self._closed and self._span: + self._span.set(**{"bub.cancelled": True}) + await self._close() + + async def _close(self) -> None: + if self._closed: + return + self._closed = True + try: + with self._span.activate() if self._span else nullcontext(): + try: + if close := getattr(self._iterator, "aclose", None): + await close() + finally: + if self._on_close is not None: + await self._on_close() + except BaseException as exc: + if self._span: + self._span.fail(exc) + raise + finally: + if self._span: + self._span.end() + + @property + def error(self) -> BubError | None: + return self._state.error + + @property + def usage(self) -> dict[str, Any] | None: + return self._state.usage diff --git a/src/bub/tape.py b/src/bub/tape.py index 965d1d18..e25175f0 100644 --- a/src/bub/tape.py +++ b/src/bub/tape.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.tape`.""" +"""Compatibility alias for :mod:`bub.agent.tape`.""" import sys -from bub.core import tape as _module -from bub.core.tape import * # noqa: F403 +from bub.agent import tape as _module +from bub.agent.tape import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/tools.py b/src/bub/tools.py index 76d4ee66..04ac1fb7 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -1,8 +1,8 @@ -"""Compatibility alias for :mod:`bub.core.tools`.""" +"""Compatibility alias for :mod:`bub.agent.tools`.""" import sys -from bub.core import tools as _module -from bub.core.tools import * # noqa: F403 +from bub.agent import tools as _module +from bub.agent.tools import * # noqa: F403 sys.modules[__name__] = _module diff --git a/src/bub/tracing.py b/src/bub/tracing.py index a713df6f..d9b0fe11 100644 --- a/src/bub/tracing.py +++ b/src/bub/tracing.py @@ -1,8 +1,259 @@ -"""Compatibility alias for :mod:`bub.core.tracing`.""" +"""Optional GenAI telemetry. Importing Bub never configures an exporter.""" -import sys +from __future__ import annotations -from bub.core import tracing as _module -from bub.core.tracing import * # noqa: F403 +import asyncio +import importlib +import json +import os +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager, suppress +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any -sys.modules[__name__] = _module +import republic + +if TYPE_CHECKING: + import opentelemetry.trace as otel +else: + try: + otel: Any = importlib.import_module("opentelemetry.trace") + except ImportError: + otel = None + +_OPENINFERENCE_ATTRIBUTES = { + "gen_ai.provider.name": "llm.provider", + "gen_ai.request.model": "llm.model_name", + "gen_ai.response.model": "llm.model_name", + "gen_ai.usage.input_tokens": "llm.token_count.prompt", + "gen_ai.usage.output_tokens": "llm.token_count.completion", + "gen_ai.tool.name": "tool.name", + "gen_ai.tool.call.arguments": "input.value", + "gen_ai.tool.call.result": "output.value", +} + + +def configure_otlp() -> None: + """Configure opt-in HTTP export; preserve application-owned providers.""" + if ( + otel is None + or not (os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) + or os.getenv("OTEL_SDK_DISABLED", "").lower() == "true" + or not isinstance(otel.get_tracer_provider(), otel.ProxyTracerProvider) + ): + return + try: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + + protocol = os.getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") or os.getenv( + "OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf" + ) + if protocol != "http/protobuf": + raise ValueError("Bub's trace extra supports OTLP http/protobuf only.") + + exporter = OTLPSpanExporter() + # The SDK reads resource/sampler settings and drains the batch queue at process exit. + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(exporter)) + otel.set_tracer_provider(provider) + + +def _json(value: Any) -> str: + try: + return json.dumps(value, ensure_ascii=False, default=str) + except (TypeError, ValueError, RecursionError): + return '"[unserializable value]"' + + +def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]: + content = message.get("content") + if message.get("role") == "tool": + call_id = message.get("tool_call_id") or (message.get("tool_call") or {}).get("id", "") + return [{"type": "tool_call_response", "id": call_id, "response": content}] + parts: list[dict[str, Any]] = [] + if isinstance(content, str) and content: + parts.append({"type": "text", "content": content}) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append({"type": "text", "content": part.get("text", "")}) + elif isinstance(part, dict): + # Preserve media structure without copying inline binary payloads. + parts.append({"type": "text", "content": f"[{part.get('type', 'media')} omitted]"}) + for call in message.get("tool_calls") or []: + arguments = call.get("arguments", {}) + if isinstance(arguments, str): + with suppress(ValueError): + arguments = json.loads(arguments) + parts.append({ + "type": "tool_call", + "id": call.get("id", ""), + "name": call.get("name", ""), + "arguments": arguments, + }) + return parts + + +class Span: + """A span whose lifetime is independent of its task-local activation.""" + + def __init__(self, name: str, attributes: Mapping[str, Any] | None = None) -> None: + self._operation = str((attributes or {}).get("gen_ai.operation.name", "")) + if otel is not None: + self._span = otel.get_tracer("bub").start_span( + name, + kind=otel.SpanKind.CLIENT if self._operation == "chat" else otel.SpanKind.INTERNAL, + attributes={k: v for k, v in (attributes or {}).items() if isinstance(v, str | bool | int | float)}, + ) + else: + self._span = None + self._ended = False + self.set(**(attributes or {})) + self.set(**{ + "openinference.span.kind": { + "invoke_agent": "AGENT", + "chat": "LLM", + "execute_tool": "TOOL", + }.get(self._operation) + }) + + @property + def recording(self) -> bool: + return self._span is not None and self._span.is_recording() + + def set(self, **attributes: Any) -> None: + if not self.recording: + return + for key, alias in _OPENINFERENCE_ATTRIBUTES.items(): + if (value := attributes.get(key)) is not None: + if alias in {"input.value", "output.value"}: + attributes[alias] = value if isinstance(value, str) else _json(value) + attributes[alias.replace("value", "mime_type")] = ( + "text/plain" if isinstance(value, str) else "application/json" + ) + else: + attributes[alias] = value + for key, value in attributes.items(): + if value is not None: + self._span.set_attribute( + key, + value + if isinstance(value, str | bool | int | float) + or ( + key == "gen_ai.response.finish_reasons" + and isinstance(value, list | tuple) + and all(isinstance(v, str) for v in value) + ) + else _json(value), + ) + + def rename(self, name: str) -> None: + if self.recording: + self._span.update_name(name) + + def messages(self, key: str, messages: Sequence[Mapping[str, Any] | republic.Message]) -> None: + if self.recording: + dicts = [m.to_dict() if isinstance(m, republic.Message) else m for m in messages] + normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in dicts] + direction = "input" if key == "gen_ai.input.messages" else "output" + self.set(**{ + key: _json(normalized), + f"{direction}.value": _json(normalized), + f"{direction}.mime_type": "application/json", + }) + if self._operation == "chat": + self.set(**_openinference_messages(direction, normalized)) + + def event(self, name: str, attributes: Mapping[str, Any]) -> None: + if self.recording: + self._span.add_event( + name, + { + k: v if isinstance(v, str | bool | int | float) else _json(v) + for k, v in attributes.items() + if v is not None + }, + ) + + def fail(self, error: BaseException) -> None: + if not self.recording: + return + if isinstance(error, asyncio.CancelledError | GeneratorExit): + self.set(**{"bub.cancelled": True}) + else: + self._span.record_exception(error) + self._span.set_status(otel.Status(otel.StatusCode.ERROR, str(error))) + self.set(**{"error.type": type(error).__name__}) + + def end(self) -> None: + if not self._ended: + self._ended = True + if self._span is not None: + self._span.end() + + @contextmanager + def activate(self) -> Iterator[None]: + token = _CURRENT.set(self) + try: + if self._span is None: + yield + else: + with otel.use_span( + self._span, end_on_exit=False, record_exception=False, set_status_on_exception=False + ): + yield + finally: + _CURRENT.reset(token) + + def correlation(self) -> dict[str, str]: + if self._span is None: + return {} + context = self._span.get_span_context() + if not context.is_valid: + return {} + return {"trace_id": f"{context.trace_id:032x}", "span_id": f"{context.span_id:016x}"} + + +_CURRENT: ContextVar[Span | None] = ContextVar("bub_trace_span", default=None) + + +def _openinference_messages(direction: str, messages: list[dict[str, Any]]) -> dict[str, Any]: + attributes: dict[str, Any] = {} + for index, message in enumerate(messages): + prefix = f"llm.{direction}_messages.{index}.message" + attributes[f"{prefix}.role"] = message["role"] + text: list[str] = [] + call_index = 0 + for part in message["parts"]: + if part["type"] == "text": + text.append(part["content"]) + elif part["type"] == "tool_call_response": + attributes[f"{prefix}.tool_call_id"] = part["id"] + text.append(part["response"] if isinstance(part["response"], str) else _json(part["response"])) + elif part["type"] == "tool_call": + call_prefix = f"{prefix}.tool_calls.{call_index}.tool_call" + attributes[f"{call_prefix}.id"] = part["id"] + attributes[f"{call_prefix}.function.name"] = part["name"] + attributes[f"{call_prefix}.function.arguments"] = _json(part["arguments"]) + call_index += 1 + if text: + attributes[f"{prefix}.content"] = "\n".join(text) + return attributes + + +def current_span() -> Span | None: + return _CURRENT.get() + + +def correlation() -> dict[str, str]: + span = current_span() + return span.correlation() if span else {} + + +def event(event_name: str, **attributes: Any) -> None: + if span := current_span(): + span.event(event_name, attributes) diff --git a/src/bub/turn.py b/src/bub/turn.py index c759553b..8b90ed36 100644 --- a/src/bub/turn.py +++ b/src/bub/turn.py @@ -3,15 +3,14 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from bub.core.turn import TurnState from bub.envelope import Envelope if TYPE_CHECKING: - from bub.core.prompt import UserContent + from bub.prompt import UserContent -__all__ = ["TurnResult", "TurnState"] +type TurnState = dict[str, Any] @dataclass(frozen=True) diff --git a/src/bub/utils.py b/src/bub/utils.py index 73882cd3..c0f47348 100644 --- a/src/bub/utils.py +++ b/src/bub/utils.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any -from bub.core.tape import TapeEntry +from bub.agent.tape import TapeEntry from bub.turn import TurnState diff --git a/tests/model_fakes.py b/tests/model_fakes.py index df915e28..f9bcdda2 100644 --- a/tests/model_fakes.py +++ b/tests/model_fakes.py @@ -8,13 +8,13 @@ import httpx2 +from bub.agent import Agent as CoreAgent from bub.builtin.settings import AgentSettings -from bub.core import Agent as CoreAgent -from bub.core.prompt import UserContent -from bub.core.streaming import AsyncStreamEvents -from bub.core.tape import Tape -from bub.core.tools import Tool from bub.hooks.interception import PluginAgentHooks +from bub.prompt import UserContent +from bub.streaming import AsyncStreamEvents +from bub.tape import Tape +from bub.tools import Tool class StepRunner: diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 5b235bdb..59d65f5f 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -12,11 +12,7 @@ from republic.errors import StreamIncompleteError from bub.builtin.settings import AgentSettings -from bub.core.errors import BubError -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import Tool, ToolExecutor +from bub.errors import BubError from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import ( LlmCallDecision, @@ -28,6 +24,10 @@ ToolCallResult, ) 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 diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index cf3f11ae..c7a1c2f5 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -11,16 +11,17 @@ import bub.builtin.codemode import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`) +from bub.agent import PromptContext, StepFinished +from bub.agent.context import default_tape_context +from bub.agent.tape import session_tape_name from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox -from bub.core import PromptContext, StepFinished -from bub.core.context import default_tape_context -from bub.core.prompt import UserContent -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.core.tape import Tape, session_tape_name -from bub.core.tools import REGISTRY, tool +from 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 tests.model_fakes import ProviderService # --------------------------------------------------------------------------- diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index 877651bf..867d63b4 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -409,7 +409,7 @@ def test_run_help_uses_the_configured_prefix( framework.workspace = tmp_path framework.load_builtin_hooks() monkeypatch.setattr( - "bub.core.agent.Agent.run_once", + "bub.agent.agent.Agent.run_once", lambda *args, **kwargs: pytest.fail("help must not invoke the model"), ) diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 62ddefcc..4d624df1 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -8,15 +8,15 @@ import pytest from typer.testing import CliRunner +from bub.agent.context import default_tape_context from bub.builtin.auth import app from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem -from bub.core.context import default_tape_context -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext, TapeEntry -from bub.core.tools import Tool from bub.framework import BubFramework +from 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 5736c76e..fb055463 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -7,14 +7,15 @@ import pytest +from bub.agent.tape import session_tape_name from bub.builtin.hook_impl import AGENTS_FILE_NAME, DEFAULT_SYSTEM_PROMPT, BuiltinImpl from bub.builtin.store import FuzzyFileTapeStore from bub.channels.message import ChannelMessage -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.streaming import AsyncStreamEvents, StreamEvent -from bub.core.tape import Tape, TapeContext, session_tape_name -from bub.core.tools import REGISTRY from bub.framework import BubFramework +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.streaming import AsyncStreamEvents, StreamEvent +from bub.tape import Tape, TapeContext +from bub.tools import REGISTRY class RecordingLifespan: diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 75a2b5a9..209a502f 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -26,10 +26,10 @@ set_reasoning_effort, tape_info, ) -from bub.core.errors import ErrorKind -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool +from 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 4e912d4b..73f7c1db 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -26,7 +26,7 @@ from bub.channels.manager import ChannelManager from bub.channels.message import ChannelMessage from bub.channels.telegram import BubMessageFilter, TelegramChannel, TelegramMessageParser -from bub.core.streaming import StreamEvent +from bub.streaming import StreamEvent from bub.turn import TurnResult ANSI_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[()][A-Za-z])") @@ -1332,9 +1332,9 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp @pytest.mark.asyncio async def test_cli_tool_renderer_finishes_before_next_model_output() -> None: + from bub.agent.events import EventBus from bub.channels.cli import _CliToolCallRenderer - from bub.core.events import EventBus - from bub.core.tools import Tool, ToolExecutor + from bub.tools import Tool, ToolExecutor events: list[str] = [] @@ -1365,9 +1365,9 @@ def ordered_tool() -> str: @pytest.mark.asyncio async def test_cli_tool_renderer_shows_the_error_the_tool_raised() -> None: + from bub.agent.events import EventBus from bub.channels.cli import _CliToolCallRenderer - from bub.core.events import EventBus - from bub.core.tools import Tool, ToolExecutor + from bub.tools import Tool, ToolExecutor errors: list[BaseException] = [] @@ -1507,7 +1507,7 @@ def test_cli_stream_output_does_not_overlap_active_pty_prompt() -> None: import bub.channels.cli as cli_module from bub.channels.cli import CliChannel, _StreamPrinter from bub.channels.cli.terminal_output import TerminalPresenter, create_synchronized_output - from bub.core.streaming import StreamEvent + from bub.streaming import StreamEvent async def main(): diff --git a/tests/test_codemode.py b/tests/test_codemode.py index 118f5727..97529d69 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -14,6 +14,7 @@ import pytest from pydantic import BaseModel, Field +from bub.agent.events import BeforeToolCall, EventBus from bub.builtin.codemode import ( CODE_TOOLS_STATE_KEY, RUN_CODE_TOOL_NAME, @@ -23,13 +24,12 @@ write_tool_stub, ) from bub.builtin.environment import LocalEnvironment, LocalProcess -from bub.core.errors import BubError, ErrorKind -from bub.core.events import BeforeToolCall, EventBus -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, CodeFailed +from bub.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): @@ -469,7 +469,7 @@ async def test_run_code_attaches_media_to_its_result(tmp_path: Path) -> None: import republic from bub.builtin.codemode import attach_audio, attach_image, attach_video - from bub.core.tools import result_content + from bub.tools import result_content (tmp_path / "charts").mkdir() (tmp_path / "charts" / "chart.png").write_bytes(PNG_BYTES) @@ -526,9 +526,9 @@ async def test_media_tools_only_work_inside_run_code(tmp_path: Path) -> None: def test_media_tool_results_become_multimodal_tool_messages(tmp_path: Path) -> None: import republic - from bub.core.context import default_tape_context - from bub.core.tape import TapeEntry - from bub.core.tools import content_result + from bub.agent.context import default_tape_context + from bub.tape import TapeEntry + from bub.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 bd4452fd..11551472 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -8,8 +8,8 @@ import pytest import republic -import bub.core -from bub.core import ( +import bub.agent +from bub.agent import ( AfterLlmCall, AfterToolCall, Agent, @@ -26,8 +26,8 @@ Tool, ToolCallDecision, ) -from bub.core.errors import BubError -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.errors import BubError from tests.model_fakes import ProviderService, chat_events, sse, tool_events @@ -198,7 +198,7 @@ def reroute(event: BeforeLlmCall) -> None: def test_core_does_not_depend_on_settings_or_plugins() -> None: forbidden = ("bub.builtin", "bub.configure", "bub.framework", "bub.hooks", "bub.channels", "pluggy") - for path in Path(bub.core.__file__).parent.glob("*.py"): + for path in Path(bub.agent.__file__).parent.glob("*.py"): for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): if isinstance(node, ast.ImportFrom): names = [node.module or ""] diff --git a/tests/test_core_agent_step.py b/tests/test_core_agent_step.py index d76fb25a..32913fc5 100644 --- a/tests/test_core_agent_step.py +++ b/tests/test_core_agent_step.py @@ -9,13 +9,13 @@ import republic from republic.errors import APIStatusError, StreamIncompleteError +from bub.agent.context import default_tape_context from bub.builtin.settings import AgentSettings -from bub.core.context import default_tape_context -from bub.core.errors import BubError, ErrorKind -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext, TapeEntry -from bub.core.tools import Tool +from 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 index da6c01a6..418499c9 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_core_boundary.py @@ -1,40 +1,23 @@ import subprocess import sys -import pytest - +from bub.agent.store import FileTapeStore, TapeQuery +from bub.agent.tape import TapeEntry 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_framework_or_app_dependencies() -> None: +def test_importing_core_loads_no_app_dependencies() -> None: script = ( - "import sys, bub.core\n" + "import sys, bub.agent\n" "loaded = {name.split('.')[0] for name in sys.modules}\n" f"print(sorted(loaded & set({APP_MODULES!r})))\n" - "print(sorted(n for n in sys.modules if n.startswith('bub.') and not n.startswith('bub.core')))\n" + "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() == ["[]", "['bub._version']"] - - -@pytest.mark.parametrize("name", ["errors", "prompt", "tracing", "streaming", "store", "tape", "tools", "sidecars"]) -def test_legacy_top_level_modules_alias_core_modules(name: str) -> None: - __import__(f"bub.{name}") - __import__(f"bub.core.{name}") - - assert sys.modules[f"bub.{name}"] is sys.modules[f"bub.core.{name}"] - - -def test_turn_state_is_shared_with_core() -> None: - from bub.core.turn import TurnState as CoreTurnState - from bub.turn import TurnState - - assert TurnState is CoreTurnState + assert result.stdout.splitlines() == ["[]", "False"] def _search(store: FileTapeStore, text: str) -> list[str]: diff --git a/tests/test_environment.py b/tests/test_environment.py index 4d5b066d..1169996f 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_errors.py b/tests/test_errors.py index 78030580..bf3628eb 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -3,7 +3,7 @@ import pytest -from bub.core.errors import BubError, ErrorKind +from bub.errors import BubError, ErrorKind def test_error_survives_generator_context_manager_with_identity_and_details() -> None: diff --git a/tests/test_file_tape_store_entry_ids.py b/tests/test_file_tape_store_entry_ids.py index 1f046fe9..0f73b91e 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.core.sidecars import sidecar_tape_name -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore -from bub.core.tape import TapeEntry +from bub.sidecars import sidecar_tape_name +from bub.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore +from bub.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 8e756c70..e614900e 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.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery -from bub.core.tape import TapeEntry +from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery +from bub.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_framework.py b/tests/test_framework.py index f549b921..51243f2e 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -19,10 +19,10 @@ from bub.channels.message import ChannelMessage from bub.channels.telegram import TelegramSettings from bub.configure import ensure_config -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.framework import BubFramework from bub.hooks import hookimpl from bub.model_selection import ModelChoice, ModelOptions +from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState def make_named_channel(name: str, label: str) -> Channel: diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py index 1c839c37..3605d401 100644 --- a/tests/test_hook_runtime.py +++ b/tests/test_hook_runtime.py @@ -2,9 +2,9 @@ import pytest import republic -from bub.core.streaming import AsyncStreamEvents, StreamEvent from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.runtime import HookRuntime +from bub.streaming import AsyncStreamEvents, StreamEvent def _runtime_with_plugins(*plugins: tuple[str, object]) -> HookRuntime: diff --git a/tests/test_image_message.py b/tests/test_image_message.py index 2e95e350..486db122 100644 --- a/tests/test_image_message.py +++ b/tests/test_image_message.py @@ -11,8 +11,8 @@ from bub.builtin.hook_impl import BuiltinImpl from bub.channels.message import ChannelMessage, MediaItem from bub.channels.telegram import TelegramChannel, TelegramMessageParser, _extract_media_items -from bub.core.prompt import prompt_text, to_content, to_legacy_prompt from bub.framework import BubFramework +from bub.prompt import prompt_text, to_content, to_legacy_prompt # --------------------------------------------------------------------------- # MediaItem & ChannelMessage diff --git a/tests/test_otlp.py b/tests/test_otlp.py index df97537c..b852869b 100644 --- a/tests/test_otlp.py +++ b/tests/test_otlp.py @@ -74,7 +74,7 @@ def unexpected_configuration(*args, **kwargs): raise SystemExit('OTLP must not configure Logfire') logfire.configure = unexpected_configuration from bub.__main__ import _instrument_bub -from bub.core.tracing import Span +from bub.tracing import Span _instrument_bub('INFO') root = Span('invoke_agent bub', {'gen_ai.operation.name': 'invoke_agent'}) with root.activate(): @@ -125,7 +125,7 @@ def find_spec(self, fullname, path=None, target=None): run_python( blocker + """ -from bub.core.tracing import Span, configure_otlp +from bub.tracing import Span, configure_otlp configure_otlp() span = Span('noop') assert not span.recording @@ -144,7 +144,7 @@ def test_otlp_preserves_application_provider(tmp_path: Path) -> None: from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from bub.core.tracing import Span, configure_otlp +from bub.tracing import Span, configure_otlp provider = TracerProvider() exporter = InMemorySpanExporter() provider.add_span_processor(SimpleSpanProcessor(exporter)) @@ -163,7 +163,7 @@ def test_otlp_rejects_unsupported_protocol(tmp_path: Path) -> None: pytest.importorskip("opentelemetry.sdk.trace") run_python( """ -from bub.core.tracing import configure_otlp +from bub.tracing import configure_otlp try: configure_otlp() except ValueError as exc: diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index 901e170c..f401ddcb 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -9,15 +9,15 @@ import httpx2 import pytest +from bub.agent.context import default_tape_context from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem -from bub.core.context import default_tape_context -from bub.core.errors import BubError, ErrorKind -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import Tool +from bub.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 @@ -174,11 +174,11 @@ async def test_fresh_process_replays_tool_signatures_to_the_provider( import asyncio, json, sys from pathlib import Path import httpx2 -from bub.core.context import default_tape_context +from bub.agent.context import default_tape_context from tests.model_fakes import StepRunner from bub.builtin.settings import AgentSettings -from bub.core.store import FileTapeStore, AsyncTapeStoreAdapter -from bub.core.tape import Tape +from bub.store import FileTapeStore, AsyncTapeStoreAdapter +from bub.tape import Tape async def main(): bodies = [] def respond(request): diff --git a/tests/test_sdk_isolation.py b/tests/test_sdk_isolation.py index 95a5e818..7a0cd5ed 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -2,17 +2,18 @@ import pytest +from bub.agent import Agent as CoreAgent +from bub.agent.tape import session_tape_name from bub.builtin import BubAgent from bub.builtin.codemode import run_code, set_code_mode from bub.builtin.model import BubModel from bub.builtin.tools import resolve_tool_names, run_subagent, show_help -from bub.core import Agent as CoreAgent -from bub.core.store import InMemoryTapeStore -from bub.core.streaming import AsyncStreamEvents, StreamEvent -from bub.core.tape import Tape, session_tape_name -from bub.core.tools import Tool, ToolContext from bub.framework import BubFramework from bub.hooks import hookimpl +from bub.store import InMemoryTapeStore +from bub.streaming import AsyncStreamEvents, StreamEvent +from bub.tape import Tape +from bub.tools import Tool, ToolContext def _reply() -> AsyncStreamEvents: @@ -54,7 +55,7 @@ async def test_sdk_agents_execute_only_their_own_command_prefix( framework: BubFramework, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("BUB_COMMAND_PREFIX", "!") - monkeypatch.setattr("bub.core.agent.Agent.run_once", lambda *args, **kwargs: _reply()) + monkeypatch.setattr("bub.agent.agent.Agent.run_once", lambda *args, **kwargs: _reply()) custom = BubAgent(framework, tools=[show_help], skill_dirs=[], command_prefix="::") configured = BubAgent(framework, tools=[show_help], skill_dirs=[]) diff --git a/tests/test_session_settings.py b/tests/test_session_settings.py index c6c19886..bc49ab9f 100644 --- a/tests/test_session_settings.py +++ b/tests/test_session_settings.py @@ -5,9 +5,9 @@ import pytest from bub.builtin.settings import load_session_settings, set_session_setting -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import ToolContext +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.tape import Tape, TapeContext +from bub.tools import ToolContext def _context(tmp_path: Path) -> ToolContext: diff --git a/tests/test_settings.py b/tests/test_settings.py index f14eaf27..632fd546 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -11,9 +11,9 @@ from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, load_settings from bub.builtin.spill import SpillSettings from bub.configure import ensure_config -from bub.core.prompt import to_content -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext +from 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 9d96c222..242c4318 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -8,6 +8,8 @@ import pytest import republic +from bub.agent.context import default_tape_context +from bub.agent.events import AfterToolCall, EventBus from bub.builtin.archive import reset_tape from bub.builtin.hook_impl import BuiltinImpl from bub.builtin.settings import AgentSettings @@ -20,14 +22,12 @@ spill_read, ) from bub.builtin.tools import render_tools_prompt -from bub.core.context import default_tape_context -from bub.core.events import AfterToolCall, EventBus -from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore -from bub.core.tape import Tape -from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import PluginAgentHooks, ToolCall, ToolCallResult from bub.hooks.runtime import HookRuntime +from 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 @@ -438,7 +438,7 @@ async def test_result_spill_without_tool_availability_keeps_full_output(tmp_path async def test_spill_bounds_text_of_content_results_and_keeps_media(tmp_path: Path) -> None: import republic - from bub.core.tools import content_result, result_content + from bub.tools import content_result, result_content image = republic.Image("image/png", data=b"png") root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py index 005bd3d7..1826de24 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -6,8 +6,8 @@ import pytest from bub.builtin.tools import run_subagent -from bub.core.streaming import AsyncStreamEvents, StreamEvent -from bub.core.tools import REGISTRY, tool +from 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 2ad6e3d4..9b656cd9 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -5,9 +5,9 @@ import pytest import republic -from bub.core.context import default_tape_context -from bub.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext, TapeEntry, to_messages +from bub.agent.context import default_tape_context +from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore +from bub.tape import Tape, TapeContext, TapeEntry, to_messages def test_tape_reexports_legacy_store_objects() -> None: diff --git a/tests/test_tape_search_output.py b/tests/test_tape_search_output.py index c16fc9f1..f30e2403 100644 --- a/tests/test_tape_search_output.py +++ b/tests/test_tape_search_output.py @@ -6,7 +6,7 @@ import bub.builtin.tools as builtin_tools from bub.builtin.tools import tape_search -from bub.core.tools import ToolContext +from bub.tools import ToolContext @dataclass(frozen=True) diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py index e0658693..b84b0819 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 dd0e2fb9..8cfa2adb 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.errors import BubError -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.tape import Tape, TapeContext -from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool +from bub.agent.events import AfterToolCall, BeforeToolCall, EventBus +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): @@ -129,7 +129,7 @@ def info(self, message: str, *args: Any) -> None: def error(self, message: str, *args: Any) -> None: errors.append((message.format(*args), self.exception)) - monkeypatch.setattr("bub.core.tools.logger", Recorder()) + monkeypatch.setattr("bub.agent.tools.logger", Recorder()) def failing_tool() -> str: raise RuntimeError("boom") diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 82d78c69..6a831a57 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -11,17 +11,18 @@ import pytest +from bub import tracing +from bub.agent.context import default_tape_context +from bub.agent.tape import session_tape_name from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings -from bub.core import tracing -from bub.core.context import default_tape_context -from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.core.streaming import AsyncStreamEvents, StreamEvent -from bub.core.tape import Tape, session_tape_name -from bub.core.tools import Tool, ToolContext, ToolExecutor from bub.framework import BubFramework from bub.hooks import hookimpl from bub.hooks.interception import LlmCallDecision, ToolCallDecision, ToolCallResult +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +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 @@ -320,9 +321,9 @@ def find_spec(self, fullname, path=None, target=None): if fullname.split('.')[0] in {'opentelemetry', 'logfire'}: raise ModuleNotFoundError(fullname) sys.meta_path.insert(0, BlockTelemetry()) -from bub.core import tracing +from bub import tracing from bub.builtin.agent import BubAgent -from bub.core.tools import Tool, ToolExecutor +from bub.tools import Tool, ToolExecutor assert tracing.otel is None async def main(): span = tracing.Span('noop') diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index 165de7c1..b90af298 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -5,7 +5,7 @@ sidebar: order: 0 --- -`bub.core` is Bub's agent kernel. Its `Agent` runs the agent loop (call the model, execute the tools it asks for, +`bub.agent` is Bub's agent kernel. Its `Agent` runs the agent loop (call the model, execute the tools it asks for, repeat until it answers) over components you supply: tools, system prompt sections, and a tape store, plus the Republic model it calls, given to the agent or to each run. It reads no Bub settings and loads no plugins, so it embeds in any application. @@ -34,8 +34,8 @@ from pathlib import Path import republic -from bub.core import Agent, Tool -from bub.core.store import FileTapeStore +from bub.agent import Agent, Tool +from bub.agent.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: @@ -112,21 +112,21 @@ A `BeforeLlmCall` handler may redirect a call to another `provider:model` spec: the run model's client, and another provider gets a client configured from Republic's environment. For a policy of your own, such as fallbacks, per-provider credentials, or a timeout for the whole call, pass an -object implementing `bub.core.ModelClient` instead: a `spec` (the default `provider:model`), a default +object implementing `bub.agent.ModelClient` instead: a `spec` (the default `provider:model`), a default `max_tokens` (or `None`), and `open_stream(messages, spec=..., options=...)`, an async context manager yielding the provider name and a `republic.Stream`. Bub's own `bub.builtin.model.BubModel` is such a client. ## Tools `Tool.from_callable()` derives a schema from annotations and a description from the docstring, without registering -the tool globally. The `@tool` decorator from `bub.core` builds the same `Tool` and also adds it to Bub's global +the tool globally. The `@tool` decorator from `bub.agent` 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.agent import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -149,7 +149,7 @@ skips the section. `agent.system_prompt.add(section)` appends a section and retu decorator; `agent.system_prompt.remove(section)` removes it. ```python -from bub.core import PromptContext +from bub.agent import PromptContext @agent.system_prompt.add @@ -214,10 +214,10 @@ Prefixes must be non-empty and contain no whitespace. Prompts with media are nev ## Events -The loop reports its progress as events from `bub.core.events`; register handlers to observe or steer it. +The loop reports its progress as events from `bub.agent.events`; register handlers to observe or steer it. ```python -from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision +from bub.agent import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision @agent.events.on(BeforeToolCall) @@ -263,7 +263,7 @@ Inside a tool, the run's bus is `context.events`: ```python from dataclasses import dataclass -from bub.core import AgentEvent, ToolContext +from bub.agent import AgentEvent, ToolContext @dataclass(kw_only=True) @@ -353,7 +353,7 @@ the turn state: | `continue_prompt(tape, prompt, state)` | `None` | Return a user message for the next step after tool calls. | ```python -from bub.core import Tape +from bub.agent import Tape class TriageAgent(Agent): diff --git a/website/src/content/docs/docs/concepts/tape-and-context.mdx b/website/src/content/docs/docs/concepts/tape-and-context.mdx index 6b58cd83..b6013f0b 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -46,7 +46,7 @@ tape_name = f"{workspace_hash}__{session_hash}" This means the same `session_id` produces a different tape in a different workspace, so two repos do not share state by accident. -The default `provide_tape_store` returns a `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `bub.core.store.FileTapeStore` whose text queries return the newest unique matches first and also match approximately. Plugins replace it by providing their own `provide_tape_store` (for example, a SQLite or HTTP-backed store). +The default `provide_tape_store` returns a `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `bub.agent.store.FileTapeStore` whose text queries return the newest unique matches first and also match approximately. Plugins replace it by providing their own `provide_tape_store` (for example, a SQLite or HTTP-backed store). ### Spill sidecar diff --git a/website/src/content/docs/docs/concepts/turn-pipeline.mdx b/website/src/content/docs/docs/concepts/turn-pipeline.mdx index 1c14cd82..8556e6fb 100644 --- a/website/src/content/docs/docs/concepts/turn-pipeline.mdx +++ b/website/src/content/docs/docs/concepts/turn-pipeline.mdx @@ -86,7 +86,7 @@ The default observer (`BuiltinImpl.on_error`) sends an error envelope through `d - `resolve_session` — uses the envelope's `session_id` if present, otherwise `f"{channel}:{chat_id}"`. - `load_state` — opens the inbound's optional `lifespan`, seeds `session_id` and an internal `_runtime_agent`, and restores session settings (model, reasoning effort, code mode) recorded on the session tape. - `build_prompt` — marks command prompts (`,help`, `,skill`, ...) and passes them through unchanged for the agent to run, wraps other text with timestamp and context, and emits multimodal parts when media is attached. -- `run_model_stream` — runs the turn with `BubAgent.run_stream()`, built on the `bub.core` agent loop with [Republic](https://github.com/bubbuild/republic) models, tool use, commands, and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)). The builtin plugin does not implement `run_model`; non-streaming callers get the collected text through the adapter described above. +- `run_model_stream` — runs the turn with `BubAgent.run_stream()`, built on the `bub.agent` agent loop with [Republic](https://github.com/bubbuild/republic) models, tool use, commands, and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)). The builtin plugin does not implement `run_model`; non-streaming callers get the collected text through the adapter described above. - `save_state` — closes the per-message lifespan with the captured exception info. - `render_outbound` — wraps `model_output` into one `ChannelMessage` carrying the inbound's routing fields. - `dispatch_outbound` — forwards through the bound `ChannelRouter`. diff --git a/website/src/content/docs/docs/getting-started/install.mdx b/website/src/content/docs/docs/getting-started/install.mdx index 8e1eddda..b3048d2e 100644 --- a/website/src/content/docs/docs/getting-started/install.mdx +++ b/website/src/content/docs/docs/getting-started/install.mdx @@ -86,7 +86,7 @@ If you prefer `pip`, the equivalent is: 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. +The `app` extra brings the `bub` command, channels, plugins and the builtin agent. A plain `bub` install contains only the agent kernel `bub.agent` for [embedding in Python](/docs/build/sdk/); its `bub` command asks for the extra. ## 3. Install from source diff --git a/website/src/content/docs/docs/reference/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx index 5c6f6dc0..b551bd10 100644 --- a/website/src/content/docs/docs/reference/hooks.mdx +++ b/website/src/content/docs/docs/reference/hooks.mdx @@ -128,13 +128,13 @@ Each `provide_tape_sidecar` implementation contributes one named capability back ### Agent-loop hooks (`PluginAgentHooks`) -The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` events (`bub.core.events`), not `call_first`/`call_many`, with three deliberate differences: +The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` events (`bub.agent.events`), not `call_first`/`call_many`, with three deliberate differences: - **Chaining** — `before_llm_call` and `before_tool_call` run every implementation in LIFO order and *fold* modifications: each impl receives the request/call as modified by the impls before it. The first short-circuiting decision (`LlmCallDecision.finish`, `ToolCallDecision.replace`/`deny`) stops the chain. - **Fault isolation** — a raising implementation is logged (`hook.agent_hook_failed`) and skipped, never fatal to the turn. Blocking is only expressible through decision objects, so a broken policy plugin cannot veto by crashing. - **Exactly-once terminal handling** — `after_llm_call` and `after_tool_call` fire exactly once per call for real completions: success or `Exception` failure. Cancellation and consumer close (`BaseException`) intentionally bypass after hooks. `result.error` carries the **original exception object** (a `BubError` for tool failures with `kind`/`details` intact). `after_tool_call` implementations share one outcome, so assigning `result.result` changes the model-facing value seen by later implementations while leaving any failure in `result.error` intact. -Payload dataclasses (`LlmCallRequest`, `LlmCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LlmCallDecision`) live in `src/bub/core/events.py` and are re-exported by `bub.hooks.interception`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. +Payload dataclasses (`LlmCallRequest`, `LlmCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LlmCallDecision`) live in `src/bub/agent/events.py` and are re-exported by `bub.hooks.interception`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. A continuation-style `wrap_tool_call` (retry / human-in-the-loop / caching) was prototyped in the original PR and removed by review direction; if those use cases materialize it will return as a separate proposal. diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index 2402376b..e783ebf8 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -11,8 +11,6 @@ The former `bub.types` compatibility module has been removed. Use `bub.envelope. ## Kernel vocabulary -Errors, prompt content, streaming, tracing and `TurnState` belong to the agent kernel in `bub.core`, which depends on nothing else in Bub. The former paths `bub.errors`, `bub.prompt`, `bub.streaming` and `bub.tracing` remain as aliases of the `bub.core` modules, and `bub.turn` re-exports `TurnState`. - ### Envelopes — `bub.envelope` ```python @@ -61,10 +59,10 @@ class ModelOptions: Channels and adapters may present these protocol-neutral choices to users. `BubFramework.get_model_options` merges values returned by `provide_model_options`; the caller owns selection UI and persistence. -### Streaming — `bub.core.streaming` +### Streaming — `bub.streaming` ```python -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState ``` `StreamEvent.kind` is one of `text`, `reasoning`, `tool_call`, `tool_result`, `usage`, `error`, or `final`. `AsyncStreamEvents` wraps an async iterator and exposes terminal `error` and `usage` state. @@ -73,10 +71,10 @@ Builtin `tool_call` events use Republic fields: `id`, `name`, `arguments`, and ` Builtin multipart prompts contain text blocks (`type: text`, `text`) or media blocks (`type: image|audio|video`, `media_type`, `url`). The URL can be remote or a base64 data URL. Bub converts these persisted blocks into native Republic parts; Republic owns provider encoding. Hooks supply these text and media blocks. -### Errors — `bub.core.errors` +### Errors — `bub.errors` ```python -from bub.core.errors import BubError, ErrorKind +from bub.errors import BubError, ErrorKind ``` `BubError` is the stable execution error object. Its `kind` is an `ErrorKind`, and `as_dict()` returns a transport-friendly payload. diff --git a/website/src/content/docs/docs/tutorials/fastapi.mdx b/website/src/content/docs/docs/tutorials/fastapi.mdx index 738fe60d..829f136c 100644 --- a/website/src/content/docs/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/docs/tutorials/fastapi.mdx @@ -1,11 +1,11 @@ --- title: Serve a Bub agent with FastAPI -description: Expose a bub.core agent over HTTP with FastAPI, with one conversation per session. +description: Expose a bub.agent agent over HTTP with FastAPI, with one conversation per session. sidebar: order: 7 --- -This tutorial serves an agent built from `bub.core` as an HTTP API. Each request names a session; requests with +This tutorial serves an agent built from `bub.agent` as an HTTP API. Each request names a session; requests with the same session continue one conversation, stored as a tape on disk. You need Python 3.12+ and an API key for a model provider. The example uses OpenAI through @@ -32,8 +32,8 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.core import Agent, Tool -from bub.core.store import FileTapeStore +from bub.agent import Agent, Tool +from bub.agent.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: diff --git a/website/src/content/docs/docs/tutorials/index.mdx b/website/src/content/docs/docs/tutorials/index.mdx index d06fdd4a..ffcf5d60 100644 --- a/website/src/content/docs/docs/tutorials/index.mdx +++ b/website/src/content/docs/docs/tutorials/index.mdx @@ -16,7 +16,7 @@ Tutorials are hands-on lessons. Use this section when you want to learn a workfl 5. [Load Agent Plugins](/docs/tutorials/agent-plugins/) — load a portable skill and MCP server alongside existing Bub integrations. 6. [Experimental: Extend Bub with Other Languages](/docs/tutorials/language-extensions/) — keep Bub in Python, move selected hooks into Go, Rust, or other languages, and wire them back through pluggy, WebAssembly, and Extism. 7. [Use Bub as an ACP agent](/docs/tutorials/acp-server/) — connect Bub to ACP-compatible graphical clients such as Zed. -8. [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/) — expose a `bub.core` agent over HTTP, with one conversation per session. +8. [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/) — expose a `bub.agent` agent over HTTP, with one conversation per session. ## Next steps diff --git a/website/src/content/docs/zh-cn/docs/build/sdk.md b/website/src/content/docs/zh-cn/docs/build/sdk.md index b692d7b7..66879105 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -5,7 +5,7 @@ sidebar: order: 0 --- -`bub.core` 是 Bub 的 agent 内核。它的 `Agent` 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答), +`bub.agent` 是 Bub 的 agent 内核。它的 `Agent` 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答), 所用的组件都由你提供:工具、system prompt section 和 tape store,以及传给 agent 或每次运行的 Republic 模型。 它不读取 Bub 的 settings,也不加载插件,因此可以嵌入任何应用。 @@ -33,8 +33,8 @@ from pathlib import Path import republic -from bub.core import Agent, Tool -from bub.core.store import FileTapeStore +from bub.agent import Agent, Tool +from bub.agent.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: @@ -109,20 +109,20 @@ async with agent.run(prompt, tape="t") as stream: 处理器可以把一次调用重定向到其他 `provider:model`:同一 provider 的模型复用运行模型的客户端,其他 provider 使用从 Republic 环境变量配置的客户端。 -如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 `bub.core.ModelClient` +如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 `bub.agent.ModelClient` 的对象:提供 `spec`(默认的 `provider:model`)、默认 `max_tokens`(或 `None`),以及 `open_stream(messages, spec=..., options=...)`——一个产出 provider 名称和 `republic.Stream` 的异步上下文管理器。 Bub 自己的 `bub.builtin.model.BubModel` 就是这样的客户端。 ## 工具 -`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core` 中的 +`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.agent` 中的 `@tool` 装饰器生成同样的 `Tool`,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。 以 `context=True` 创建的工具会收到 `ToolContext`,其中包含本次运行的 `tape`、turn `state`、`run_id` 和事件总线: ```python -from bub.core import ToolContext +from bub.agent import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -144,7 +144,7 @@ system prompt 是有序的 section 列表,每个模型步骤渲染一次,以 追加一个 section 并返回它,因此也可以用作装饰器;`agent.system_prompt.remove(section)` 将其移除。 ```python -from bub.core import PromptContext +from bub.agent import PromptContext @agent.system_prompt.add @@ -205,10 +205,10 @@ async with agent.run(",lookup_order order_id=A123", model=model, tape="customer- ## 事件 -循环通过 `bub.core.events` 中的事件报告进度;注册处理器即可观察或干预它。 +循环通过 `bub.agent.events` 中的事件报告进度;注册处理器即可观察或干预它。 ```python -from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision +from bub.agent import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision @agent.events.on(BeforeToolCall) @@ -252,7 +252,7 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat ```python from dataclasses import dataclass -from bub.core import AgentEvent, ToolContext +from bub.agent import AgentEvent, ToolContext @dataclass(kw_only=True) @@ -337,7 +337,7 @@ async with agent.run("Check order A123", model=model, tape="customer-42") as str | `continue_prompt(tape, prompt, state)` | `None` | 在工具调用后返回下一步的用户消息。 | ```python -from bub.core import Tape +from bub.agent import Tape class TriageAgent(Agent): diff --git a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx index 9499e349..77712751 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx @@ -46,7 +46,7 @@ tape_name = f"{workspace_hash}__{session_hash}" 这意味着同一个 `session_id` 在不同 workspace 会得到不同 tape,两个仓库不会互相串 state。 -默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bub.core.store.FileTapeStore` 的子类,文本查询按从新到旧返回去重后的结果,并支持近似匹配。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。 +默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bub.agent.store.FileTapeStore` 的子类,文本查询按从新到旧返回去重后的结果,并支持近似匹配。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。 ### spill sidecar diff --git a/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx b/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx index ccaae096..59f19161 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx @@ -86,7 +86,7 @@ hook 运行时会在两者间互相适配:若插件只实现 `run_model_stream - `resolve_session` — 优先使用 envelope 的 `session_id`,否则 `f"{channel}:{chat_id}"`。 - `load_state` — 打开 inbound 的可选 `lifespan`,初始化 `session_id` 与内部 `_runtime_agent`,并恢复 session tape 上记录的会话设置(模型、reasoning effort、code mode)。 - `build_prompt` — 标记命令 prompt(`,help`、`,skill` 等)并原样传给 agent 执行,用时间戳与上下文包裹其他文本,并在附带媒体时输出多模态片段。 -- `run_model_stream` — 通过 `BubAgent.run_stream()` 运行 turn,它构建在 `bub.core` 的 agent loop 之上,使用 [Republic](https://github.com/bubbuild/republic) 模型,支持工具调用、命令和 auto-handoff(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。builtin 插件不实现 `run_model`;非流式调用方通过上文描述的适配获得拼接后的文本。 +- `run_model_stream` — 通过 `BubAgent.run_stream()` 运行 turn,它构建在 `bub.agent` 的 agent loop 之上,使用 [Republic](https://github.com/bubbuild/republic) 模型,支持工具调用、命令和 auto-handoff(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。builtin 插件不实现 `run_model`;非流式调用方通过上文描述的适配获得拼接后的文本。 - `save_state` — 用捕获的异常信息关闭 per-message lifespan。 - `render_outbound` — 将 `model_output` 包装为携带 inbound 路由字段的 `ChannelMessage`。 - `dispatch_outbound` — 转发到绑定的 `ChannelRouter`。 diff --git a/website/src/content/docs/zh-cn/docs/getting-started/install.mdx b/website/src/content/docs/zh-cn/docs/getting-started/install.mdx index 1646d5f8..48ea2743 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 @@ -85,7 +85,7 @@ uv tool update-shell pip install "bub[app]" ``` -`app` extra 提供 `bub` 命令、channel、插件和 builtin agent。不带 extra 安装的 `bub` 只包含 agent 内核 `bub.core`,用于[在 Python 中嵌入](/zh-cn/docs/build/sdk/);此时运行 `bub` 命令会提示安装该 extra。 +`app` extra 提供 `bub` 命令、channel、插件和 builtin agent。不带 extra 安装的 `bub` 只包含 agent 内核 `bub.agent`,用于[在 Python 中嵌入](/zh-cn/docs/build/sdk/);此时运行 `bub` 命令会提示安装该 extra。 ## 3. 从源码安装 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..65b147f9 100644 --- a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx @@ -127,13 +127,13 @@ Windows 当前只清理直接 shell 进程。 ### Agent-loop 钩子(`PluginAgentHooks`) -四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` 事件,见 `bub.core.events`)分发,而不是 `call_first`/`call_many`,有三处刻意差异: +四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` 事件,见 `bub.agent.events`)分发,而不是 `call_first`/`call_many`,有三处刻意差异: - **链式** — `before_llm_call` 与 `before_tool_call` 按 LIFO 顺序执行全部实现并**折叠**修改:每个实现收到的是前序实现修改后的 request/call。第一个短路决定(`LlmCallDecision.finish`、`ToolCallDecision.replace`/`deny`)终止链。 - **错误隔离** — 实现抛异常只记日志(`hook.agent_hook_failed`)并跳过,绝不炸掉回合。阻断只能通过 decision 对象表达,坏掉的策略插件无法靠 crash 否决。 - **终态处理恰好一次** — `after_llm_call` 与 `after_tool_call` 对真实完成的调用恰好触发一次:成功或 `Exception` 失败。取消与消费方关闭(`BaseException`)刻意不进入 after hook。`result.error` 携带**原始异常对象**(工具失败为完整 `BubError`,保留 `kind`/`details`)。所有 `after_tool_call` 实现共享同一个 outcome,因此赋值 `result.result` 会改变后续实现看到的模型可见结果,同时保留 `result.error` 中的失败状态。 -载荷数据类(`LlmCallRequest`、`LlmCallResult`、`ToolCall`、`ToolCallDecision`、`ToolCallResult`、`LlmCallDecision`)位于 `src/bub/core/events.py`,并由 `bub.hooks.interception` 重新导出。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 +载荷数据类(`LlmCallRequest`、`LlmCallResult`、`ToolCall`、`ToolCallDecision`、`ToolCallResult`、`LlmCallDecision`)位于 `src/bub/agent/events.py`,并由 `bub.hooks.interception` 重新导出。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 continuation 风格的 `wrap_tool_call`(重试 / human-in-the-loop / 缓存)曾在原 PR 中实现,按 review 方向移除;若相关用例成熟,将作为独立提案回归。 diff --git a/website/src/content/docs/zh-cn/docs/reference/types.mdx b/website/src/content/docs/zh-cn/docs/reference/types.mdx index 75f3a952..edd76252 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -11,8 +11,6 @@ Bub 把契约放在拥有其行为的子系统旁边。请从定义它的模块 ## 内核词汇 -Error、prompt 内容、streaming、tracing 与 `TurnState` 属于 `bub.core` 中的 agent 内核,它不依赖 Bub 的其他部分。原路径 `bub.errors`、`bub.prompt`、`bub.streaming` 与 `bub.tracing` 作为 `bub.core` 对应模块的别名保留,`bub.turn` 也重新导出 `TurnState`。 - ### Envelope — `bub.envelope` ```python @@ -61,10 +59,10 @@ class ModelOptions: Channel 与 adapter 可向用户展示这些协议无关的选择项。`BubFramework.get_model_options` 合并 `provide_model_options` 返回的值;选择 UI 与持久化由调用方负责。 -### Streaming — `bub.core.streaming` +### Streaming — `bub.streaming` ```python -from bub.core.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState ``` `StreamEvent.kind` 可以是 `text`、`reasoning`、`tool_call`、`tool_result`、`usage`、`error` 或 `final`。`AsyncStreamEvents` 包装异步迭代器,并暴露最终 `error` 与 `usage` 状态。 @@ -73,10 +71,10 @@ builtin 的 `tool_call` 事件使用 Republic 字段:`id`、`name`、`argument builtin 的多部分 prompt 包含文本块(`type: text`、`text`)或媒体块(`type: image|audio|video`、`media_type`、`url`)。URL 可以是远程地址或 base64 data URL。Bub 将持久化内容块转换为 Republic 原生 part,provider 编码由 Republic 负责。hook 提供这些文本与媒体块。 -### Error — `bub.core.errors` +### Error — `bub.errors` ```python -from bub.core.errors import BubError, ErrorKind +from bub.errors import BubError, ErrorKind ``` `BubError` 是稳定的执行错误对象。其 `kind` 为 `ErrorKind`,`as_dict()` 返回适合传输的 payload。 diff --git a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx index b425630f..57d6c8e5 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx @@ -1,11 +1,11 @@ --- title: 用 FastAPI 提供 Bub agent 服务 -description: 用 FastAPI 把 bub.core agent 暴露为 HTTP API,每个会话对应一段对话。 +description: 用 FastAPI 把 bub.agent agent 暴露为 HTTP API,每个会话对应一段对话。 sidebar: order: 7 --- -本教程把基于 `bub.core` 构建的 agent 作为 HTTP API 提供服务。每个请求都指定一个会话;同一会话的请求会延续同一段对话, +本教程把基于 `bub.agent` 构建的 agent 作为 HTTP API 提供服务。每个请求都指定一个会话;同一会话的请求会延续同一段对话, 对话以 tape 的形式保存在磁盘上。 你需要 Python 3.12+ 和某个模型提供方的 API key。示例通过 [Republic](https://github.com/bubbuild/republic) 使用 OpenAI; @@ -32,8 +32,8 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.core import Agent, Tool -from bub.core.store import FileTapeStore +from bub.agent import Agent, Tool +from bub.agent.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: diff --git a/website/src/content/docs/zh-cn/docs/tutorials/index.mdx b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx index 12058ca2..99457a89 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/index.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx @@ -16,7 +16,7 @@ sidebar: 5. [加载 Agent Plugins](/zh-cn/docs/tutorials/agent-plugins/) — 加载可移植的 skill 和 MCP 服务器,与现有 Bub 扩展一起使用。 6. [实验性:使用其他语言扩展 Bub](/zh-cn/docs/tutorials/language-extensions/) — 保持 Bub 作为 Python 宿主,把部分 hooks 放到 Go、Rust 或其他语言中实现,并通过 pluggy、WebAssembly 和 Extism 接回 Bub。 7. [将 Bub 作为 ACP agent 使用](/zh-cn/docs/tutorials/acp-server/) — 把 Bub 接入 Zed 等支持 ACP 的图形客户端。 -8. [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/) — 把 `bub.core` agent 暴露为 HTTP API,每个会话对应一段对话。 +8. [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/) — 把 `bub.agent` agent 暴露为 HTTP API,每个会话对应一段对话。 ## 下一步 From 31d159b84d4dc358a5f84e83d991107273c12745 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 17:07:23 +0800 Subject: [PATCH 08/11] refactor!: drop the bub.store/tape/tools/sidecars aliases Import these modules from `bub.agent` instead. BREAKING CHANGE: `bub.store`, `bub.tape`, `bub.tools` and `bub.sidecars` are removed; use `bub.agent.store`, `bub.agent.tape`, `bub.agent.tools` and `bub.agent.sidecars`. `bub.tool` is unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 2 +- AGENTS.md | 2 +- src/bub/sidecars.py | 8 -------- src/bub/store.py | 8 -------- src/bub/tape.py | 8 -------- src/bub/tools.py | 8 -------- tests/model_fakes.py | 4 ++-- tests/test_agent_hooks.py | 6 +++--- tests/test_builtin_agent.py | 7 +++---- tests/test_builtin_codex.py | 6 +++--- tests/test_builtin_hook_impl.py | 7 +++---- tests/test_builtin_tools.py | 6 +++--- tests/test_channels.py | 4 ++-- tests/test_codemode.py | 12 ++++++------ tests/test_core_agent_step.py | 6 +++--- tests/test_environment.py | 6 +++--- tests/test_file_tape_store_entry_ids.py | 6 +++--- tests/test_fork_store_merge_back.py | 4 ++-- tests/test_provider_messages.py | 10 +++++----- tests/test_sdk_isolation.py | 7 +++---- tests/test_session_settings.py | 6 +++--- tests/test_settings.py | 4 ++-- tests/test_spill.py | 8 ++++---- tests/test_subagent_tool.py | 2 +- tests/test_tape.py | 6 +++--- tests/test_tape_search_output.py | 2 +- tests/test_tool_loading.py | 2 +- tests/test_tools.py | 6 +++--- tests/test_tracing.py | 9 ++++----- website/src/content/docs/docs/build/tools.mdx | 10 +++++----- .../content/docs/docs/concepts/tape-and-context.mdx | 4 ++-- website/src/content/docs/docs/reference/types.mdx | 2 +- website/src/content/docs/zh-cn/docs/build/tools.mdx | 10 +++++----- .../docs/zh-cn/docs/concepts/tape-and-context.mdx | 4 ++-- .../src/content/docs/zh-cn/docs/reference/types.mdx | 2 +- 35 files changed, 84 insertions(+), 120 deletions(-) delete mode 100644 src/bub/sidecars.py delete mode 100644 src/bub/store.py delete mode 100644 src/bub/tape.py delete mode 100644 src/bub/tools.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d0c2b6a1..5a3e3064 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -62,7 +62,7 @@ jobs: import importlib.util from bub.agent import Agent from bub.agent.store import InMemoryTapeStore - import bub.errors, bub.store, bub.tape, bub.tools + import bub.errors, bub.agent.store, bub.agent.tape, bub.agent.tools Agent(store=InMemoryTapeStore()) app_modules = ("typer", "pluggy", "pydantic_settings", "yaml", "rapidfuzz", "telegram", "rich") diff --git a/AGENTS.md b/AGENTS.md index 8c962ed5..cafc2f09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Core code lives under `src/`: - `src/bub/{envelope,turn,streaming,errors,model_selection}.py`: small kernel vocabulary owned by each concern. - `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.agent.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/agent/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/`. diff --git a/src/bub/sidecars.py b/src/bub/sidecars.py deleted file mode 100644 index ca409663..00000000 --- a/src/bub/sidecars.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.agent.sidecars`.""" - -import sys - -from bub.agent import sidecars as _module -from bub.agent.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 46bd893f..00000000 --- a/src/bub/store.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.agent.store`.""" - -import sys - -from bub.agent import store as _module -from bub.agent.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 e25175f0..00000000 --- a/src/bub/tape.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.agent.tape`.""" - -import sys - -from bub.agent import tape as _module -from bub.agent.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 04ac1fb7..00000000 --- a/src/bub/tools.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.agent.tools`.""" - -import sys - -from bub.agent import tools as _module -from bub.agent.tools import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/tests/model_fakes.py b/tests/model_fakes.py index f9bcdda2..daa16449 100644 --- a/tests/model_fakes.py +++ b/tests/model_fakes.py @@ -9,12 +9,12 @@ import httpx2 from bub.agent import Agent as CoreAgent +from bub.agent.tape import Tape +from bub.agent.tools import Tool from bub.builtin.settings import AgentSettings 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 class StepRunner: diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 59d65f5f..619d5224 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -11,6 +11,9 @@ import republic from republic.errors import StreamIncompleteError +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext +from bub.agent.tools import Tool, ToolExecutor from bub.builtin.settings import AgentSettings from bub.errors import BubError from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl @@ -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 diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index c7a1c2f5..e1e93d1e 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -13,15 +13,14 @@ import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`) from bub.agent import PromptContext, StepFinished from bub.agent.context import default_tape_context -from bub.agent.tape import session_tape_name +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, session_tape_name +from bub.agent.tools import REGISTRY, tool from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox 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 tests.model_fakes import ProviderService # --------------------------------------------------------------------------- diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 4d624df1..9227c66b 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -9,14 +9,14 @@ from typer.testing import CliRunner from bub.agent.context import default_tape_context +from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext, TapeEntry +from bub.agent.tools import Tool from bub.builtin.auth import app from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem 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 fb055463..5fbfe97c 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -7,15 +7,14 @@ import pytest -from bub.agent.tape import session_tape_name +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext, session_tape_name +from bub.agent.tools import REGISTRY 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.framework import BubFramework -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tape import Tape, TapeContext -from bub.tools import REGISTRY class RecordingLifespan: diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 209a502f..49bb9b4c 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -14,6 +14,9 @@ import pytest import bub.builtin.tools as builtin_tools +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext +from bub.agent.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool from bub.builtin.shell_manager import ShellManager from bub.builtin.tools import ( bash, @@ -27,9 +30,6 @@ tape_info, ) 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 73f7c1db..c94f7da1 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1333,8 +1333,8 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp @pytest.mark.asyncio async def test_cli_tool_renderer_finishes_before_next_model_output() -> None: from bub.agent.events import EventBus + from bub.agent.tools import Tool, ToolExecutor from bub.channels.cli import _CliToolCallRenderer - from bub.tools import Tool, ToolExecutor events: list[str] = [] @@ -1366,8 +1366,8 @@ def ordered_tool() -> str: @pytest.mark.asyncio async def test_cli_tool_renderer_shows_the_error_the_tool_raised() -> None: from bub.agent.events import EventBus + from bub.agent.tools import Tool, ToolExecutor from bub.channels.cli import _CliToolCallRenderer - from bub.tools import Tool, ToolExecutor errors: list[BaseException] = [] diff --git a/tests/test_codemode.py b/tests/test_codemode.py index 97529d69..2e5dae0c 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -15,6 +15,9 @@ from pydantic import BaseModel, Field from bub.agent.events import BeforeToolCall, EventBus +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext +from bub.agent.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool from bub.builtin.codemode import ( CODE_TOOLS_STATE_KEY, RUN_CODE_TOOL_NAME, @@ -27,9 +30,6 @@ 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): @@ -468,8 +468,8 @@ def test_stub_declares_media_tools_and_documents_republic() -> None: async def test_run_code_attaches_media_to_its_result(tmp_path: Path) -> None: import republic + from bub.agent.tools import result_content from bub.builtin.codemode import attach_audio, attach_image, attach_video - from bub.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.agent.context import default_tape_context - from bub.tape import TapeEntry - from bub.tools import content_result + from bub.agent.tape import TapeEntry + from bub.agent.tools import content_result image = republic.Image("image/png", data=PNG_BYTES) entries = [ diff --git a/tests/test_core_agent_step.py b/tests/test_core_agent_step.py index 32913fc5..ddb14d9d 100644 --- a/tests/test_core_agent_step.py +++ b/tests/test_core_agent_step.py @@ -10,12 +10,12 @@ from republic.errors import APIStatusError, StreamIncompleteError from bub.agent.context import default_tape_context +from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext, TapeEntry +from bub.agent.tools import Tool from bub.builtin.settings import AgentSettings 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_environment.py b/tests/test_environment.py index 1169996f..0a265f6c 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -6,14 +6,14 @@ import pytest import bub.builtin.tools as builtin_tools +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext +from bub.agent.tools import ToolContext from bub.builtin.environment import LocalEnvironment, LocalProcess, environment_from_state from bub.builtin.shell_manager import ShellManager 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..7d158618 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.agent.sidecars import sidecar_tape_name +from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore +from bub.agent.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..e600642b 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.agent.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery +from bub.agent.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index f401ddcb..7609ba4c 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -10,14 +10,14 @@ import pytest from bub.agent.context import default_tape_context +from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext +from bub.agent.tools import Tool from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem 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.agent.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.agent.store import FileTapeStore, AsyncTapeStoreAdapter +from bub.agent.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 7a0cd5ed..39c9ebf5 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -3,17 +3,16 @@ import pytest from bub.agent import Agent as CoreAgent -from bub.agent.tape import session_tape_name +from bub.agent.store import InMemoryTapeStore +from bub.agent.tape import Tape, session_tape_name +from bub.agent.tools import Tool, ToolContext from bub.builtin import BubAgent from bub.builtin.codemode import run_code, set_code_mode from bub.builtin.model import BubModel from bub.builtin.tools import resolve_tool_names, run_subagent, show_help from bub.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..40be3b2b 100644 --- a/tests/test_session_settings.py +++ b/tests/test_session_settings.py @@ -4,10 +4,10 @@ import pytest +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext +from bub.agent.tools import ToolContext 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 def _context(tmp_path: Path) -> ToolContext: diff --git a/tests/test_settings.py b/tests/test_settings.py index 632fd546..e5620f76 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,12 +8,12 @@ import pytest from pydantic import ValidationError +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, load_settings from bub.builtin.spill import SpillSettings from bub.configure import ensure_config 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 242c4318..16163416 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -10,6 +10,9 @@ from bub.agent.context import default_tape_context from bub.agent.events import AfterToolCall, EventBus +from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore +from bub.agent.tape import Tape +from bub.agent.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.builtin.archive import reset_tape from bub.builtin.hook_impl import BuiltinImpl from bub.builtin.settings import AgentSettings @@ -25,9 +28,6 @@ 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 @@ -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.agent.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..56ec1cb5 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -5,9 +5,9 @@ import pytest +from bub.agent.tools import REGISTRY, tool from bub.builtin.tools import run_subagent 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 9b656cd9..419a69e3 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -6,12 +6,12 @@ import republic from bub.agent.context import default_tape_context -from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry, to_messages +from bub.agent.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext, TapeEntry, to_messages def test_tape_reexports_legacy_store_objects() -> None: - from bub import store, tape + from bub.agent import store, tape expected_exports = { "AsyncTapeStore", diff --git a/tests/test_tape_search_output.py b/tests/test_tape_search_output.py index f30e2403..00baf511 100644 --- a/tests/test_tape_search_output.py +++ b/tests/test_tape_search_output.py @@ -5,8 +5,8 @@ import pytest import bub.builtin.tools as builtin_tools +from bub.agent.tools import ToolContext from bub.builtin.tools import tape_search -from bub.tools import ToolContext @dataclass(frozen=True) diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py index b84b0819..2be47428 100644 --- a/tests/test_tool_loading.py +++ b/tests/test_tool_loading.py @@ -6,9 +6,9 @@ import pytest +from bub.agent.tools import Tool from bub.builtin.agent import BubAgent 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 8cfa2adb..fad23e81 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,10 +8,10 @@ from pydantic import BaseModel from bub.agent.events import AfterToolCall, BeforeToolCall, EventBus +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, TapeContext +from bub.agent.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): diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 6a831a57..b992966c 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -13,16 +13,15 @@ from bub import tracing from bub.agent.context import default_tape_context -from bub.agent.tape import session_tape_name +from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.agent.tape import Tape, session_tape_name +from bub.agent.tools import Tool, ToolContext, ToolExecutor from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings 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.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 @@ -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.agent.tools import Tool, ToolExecutor assert tracing.otel is None async def main(): span = tracing.Span('noop') diff --git a/website/src/content/docs/docs/build/tools.mdx b/website/src/content/docs/docs/build/tools.mdx index 1cf8b08d..02d3df56 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/agent/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.agent.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.agent.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/agent/tools.py): ```python -# from src/bub/tools.py +# from src/bub/agent/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.agent.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 b6013f0b..1604826c 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -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.agent.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.agent.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/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index e783ebf8..ed304c13 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -95,7 +95,7 @@ from bub.hooks.interception import ( ToolCallResult, ) from bub.hooks.runtime import HookRuntime -from bub.sidecars import TapeSidecar, sidecar_tape_name +from bub.agent.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/zh-cn/docs/build/tools.mdx b/website/src/content/docs/zh-cn/docs/build/tools.mdx index e7b82f38..8866c173 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/agent/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.agent.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.agent.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/agent/tools.py): ```python -# 来自 src/bub/tools.py +# 来自 src/bub/agent/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.agent.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 77712751..4ec2b82a 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 @@ -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.agent.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.agent.tape.to_messages` 可转换已存储的消息 payload。插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 带有 `context=False` 标记的 entry 会在 selector 运行前被移除。spill 运维 event 因此仍可在 tape 上查询,但不会进入默认 context 或插件自定义 context。 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..cd8f1a70 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -95,7 +95,7 @@ from bub.hooks.interception import ( ToolCallResult, ) from bub.hooks.runtime import HookRuntime -from bub.sidecars import TapeSidecar, sidecar_tape_name +from bub.agent.sidecars import TapeSidecar, sidecar_tape_name ``` 插件作者通常只需要 `hookimpl` 与对应 hook 使用的 payload 类型。`TapeSidecar` 是 `provide_tape_sidecar` 返回的最小具名契约;`sidecar_tape_name` 用于解析 sibling tape 名称。分发和故障隔离语义见 [Hook 参考](/zh-cn/docs/reference/hooks/)。 From da97954c24123de9de69aa011b3099b49e80d04f Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 17:14:04 +0800 Subject: [PATCH 09/11] refactor: keep store, tape, tools and sidecars at the top level Restore `bub.store`, `bub.tape`, `bub.tools` and `bub.sidecars` as real modules at their original location, and have `bub.agent` import them, so only the new agent kernel lives in `bub.agent`. `bub.agent` exports its names lazily: `bub.tools` imports `bub.agent.events`, and loading the whole package there would import the agent loop back into `bub.tools`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 4 +- AGENTS.md | 2 +- src/bub/__init__.py | 4 +- src/bub/agent/__init__.py | 108 +++++++++++++----- src/bub/agent/agent.py | 8 +- src/bub/agent/context.py | 4 +- src/bub/agent/events.py | 4 +- src/bub/agent/system_prompt.py | 4 +- src/bub/builtin/agent.py | 6 +- src/bub/builtin/archive.py | 6 +- src/bub/builtin/codemode/__init__.py | 2 +- src/bub/builtin/codemode/republic_bridge.py | 2 +- src/bub/builtin/hook_impl.py | 10 +- src/bub/builtin/settings.py | 4 +- src/bub/builtin/spill.py | 4 +- src/bub/builtin/store.py | 6 +- src/bub/builtin/tools.py | 4 +- src/bub/channels/cli/__init__.py | 4 +- src/bub/framework.py | 6 +- src/bub/hooks/specs.py | 6 +- src/bub/prompt.py | 2 +- src/bub/{agent => }/sidecars.py | 0 src/bub/{agent => }/store.py | 2 +- src/bub/{agent => }/tape.py | 12 +- src/bub/{agent => }/tools.py | 2 +- src/bub/utils.py | 2 +- tests/model_fakes.py | 4 +- tests/test_agent_hooks.py | 6 +- tests/test_builtin_agent.py | 6 +- tests/test_builtin_codex.py | 6 +- tests/test_builtin_hook_impl.py | 6 +- tests/test_builtin_tools.py | 6 +- tests/test_channels.py | 4 +- tests/test_codemode.py | 12 +- tests/test_core_agent.py | 2 +- tests/test_core_agent_step.py | 6 +- tests/test_core_boundary.py | 4 +- tests/test_environment.py | 6 +- tests/test_file_tape_store_entry_ids.py | 6 +- tests/test_fork_store_merge_back.py | 4 +- tests/test_provider_messages.py | 10 +- tests/test_sdk_isolation.py | 6 +- tests/test_session_settings.py | 6 +- tests/test_settings.py | 4 +- tests/test_spill.py | 8 +- tests/test_subagent_tool.py | 2 +- tests/test_tape.py | 6 +- tests/test_tape_search_output.py | 2 +- tests/test_tool_loading.py | 2 +- tests/test_tools.py | 8 +- tests/test_tracing.py | 8 +- website/src/content/docs/docs/build/sdk.md | 2 +- website/src/content/docs/docs/build/tools.mdx | 10 +- .../docs/docs/concepts/tape-and-context.mdx | 6 +- .../src/content/docs/docs/reference/types.mdx | 2 +- .../content/docs/docs/tutorials/fastapi.mdx | 2 +- .../src/content/docs/zh-cn/docs/build/sdk.md | 2 +- .../content/docs/zh-cn/docs/build/tools.mdx | 10 +- .../zh-cn/docs/concepts/tape-and-context.mdx | 6 +- .../docs/zh-cn/docs/reference/types.mdx | 2 +- .../docs/zh-cn/docs/tutorials/fastapi.mdx | 2 +- 61 files changed, 227 insertions(+), 175 deletions(-) rename src/bub/{agent => }/sidecars.py (100%) rename src/bub/{agent => }/store.py (99%) rename src/bub/{agent => }/tape.py (98%) rename src/bub/{agent => }/tools.py (99%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5a3e3064..ba1ca577 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -61,8 +61,8 @@ jobs: uv run --isolated --no-project --with dist/bub-*.whl python - <<'EOF' import importlib.util from bub.agent import Agent - from bub.agent.store import InMemoryTapeStore - import bub.errors, bub.agent.store, bub.agent.tape, bub.agent.tools + from bub.store import InMemoryTapeStore + import bub.errors, bub.store, bub.tape, bub.tools Agent(store=InMemoryTapeStore()) app_modules = ("typer", "pluggy", "pydantic_settings", "yaml", "rapidfuzz", "telegram", "rich") diff --git a/AGENTS.md b/AGENTS.md index cafc2f09..8c962ed5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Core code lives under `src/`: - `src/bub/{envelope,turn,streaming,errors,model_selection}.py`: small kernel vocabulary owned by each concern. - `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.agent.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/agent/tools.py`: skill discovery and tool registry. +- `src/bub/skills.py` / `src/bub/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/`. diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 9b1a40c5..c54c59cf 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -14,10 +14,10 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from bub.agent.tools import tool from bub.configure import Settings, config, ensure_config from bub.framework import BubFramework from bub.hooks import hookimpl + from bub.tools import tool home: Path @@ -50,7 +50,7 @@ def __getattr__(name: str) -> Any: if name == "tool": - from bub.agent.tools import tool + from bub.tools import tool return tool if name != "home" and name not in _EXPORTS: diff --git a/src/bub/agent/__init__.py b/src/bub/agent/__init__.py index 612f43a3..890f6ace 100644 --- a/src/bub/agent/__init__.py +++ b/src/bub/agent/__init__.py @@ -4,34 +4,38 @@ to registered handlers; the framework and the builtin agent assemble these components. """ -from bub.agent.agent import Agent, events_from -from bub.agent.context import default_tape_context -from bub.agent.events import ( - AfterLlmCall, - AfterToolCall, - AgentEvent, - BeforeLlmCall, - BeforeToolCall, - ChatCompleted, - CommandFinished, - EventBus, - EventHandler, - LlmCallDecision, - LlmCallRequest, - LlmCallResult, - RunStarted, - StepFinished, - StepStarted, - TapeEvent, - ToolCall, - ToolCallDecision, - ToolCallResult, -) -from bub.agent.model import ChatModelClient, Model, ModelClient -from bub.agent.recorder import TapeRecorder -from bub.agent.system_prompt import PromptContext, PromptSection, SystemPrompt -from bub.agent.tape import Tape, TapeContext, session_tape_name -from bub.agent.tools import Tool, ToolContext, tool +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from bub.agent.agent import Agent, events_from + from bub.agent.context import default_tape_context + from bub.agent.events import ( + AfterLlmCall, + AfterToolCall, + AgentEvent, + BeforeLlmCall, + BeforeToolCall, + ChatCompleted, + CommandFinished, + EventBus, + EventHandler, + LlmCallDecision, + LlmCallRequest, + LlmCallResult, + RunStarted, + StepFinished, + StepStarted, + TapeEvent, + ToolCall, + ToolCallDecision, + ToolCallResult, + ) + from bub.agent.model import ChatModelClient, Model, ModelClient + from bub.agent.recorder import TapeRecorder + from bub.agent.system_prompt import PromptContext, PromptSection, SystemPrompt + from bub.tape import Tape, TapeContext, session_tape_name + from bub.tools import Tool, ToolContext, tool __all__ = [ "AfterLlmCall", @@ -70,3 +74,51 @@ "session_tape_name", "tool", ] + +# Exports are imported on first access, so importing one submodule (such as ``bub.agent.events`` +# from ``bub.tools``) does not import the agent loop and the modules it depends on. +_EXPORTS = { + "AfterLlmCall": "bub.agent.events", + "AfterToolCall": "bub.agent.events", + "Agent": "bub.agent.agent", + "AgentEvent": "bub.agent.events", + "BeforeLlmCall": "bub.agent.events", + "BeforeToolCall": "bub.agent.events", + "ChatCompleted": "bub.agent.events", + "ChatModelClient": "bub.agent.model", + "CommandFinished": "bub.agent.events", + "EventBus": "bub.agent.events", + "EventHandler": "bub.agent.events", + "LlmCallDecision": "bub.agent.events", + "LlmCallRequest": "bub.agent.events", + "LlmCallResult": "bub.agent.events", + "Model": "bub.agent.model", + "ModelClient": "bub.agent.model", + "PromptContext": "bub.agent.system_prompt", + "PromptSection": "bub.agent.system_prompt", + "RunStarted": "bub.agent.events", + "StepFinished": "bub.agent.events", + "StepStarted": "bub.agent.events", + "SystemPrompt": "bub.agent.system_prompt", + "Tape": "bub.tape", + "TapeContext": "bub.tape", + "TapeEvent": "bub.agent.events", + "TapeRecorder": "bub.agent.recorder", + "Tool": "bub.tools", + "ToolCall": "bub.agent.events", + "ToolCallDecision": "bub.agent.events", + "ToolCallResult": "bub.agent.events", + "ToolContext": "bub.tools", + "default_tape_context": "bub.agent.context", + "events_from": "bub.agent.agent", + "session_tape_name": "bub.tape", + "tool": "bub.tools", +} + + +def __getattr__(name: str) -> Any: + if name not in _EXPORTS: + raise AttributeError(f"module {__name__} has no attribute {name}") + value = getattr(import_module(_EXPORTS[name]), name) + globals()[name] = value + return value diff --git a/src/bub/agent/agent.py b/src/bub/agent/agent.py index c2c444f2..752d1f2b 100644 --- a/src/bub/agent/agent.py +++ b/src/bub/agent/agent.py @@ -34,14 +34,14 @@ ) from bub.agent.model import Model, ModelClient, is_context_length_error, model_client from bub.agent.recorder import TapeRecorder -from bub.agent.sidecars import TapeSidecar -from bub.agent.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store from bub.agent.system_prompt import PromptContext, PromptSection, SystemPrompt -from bub.agent.tape import Tape, TapeContext -from bub.agent.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.errors import BubError, ErrorKind from bub.prompt import UserContent +from bub.sidecars import TapeSidecar +from bub.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.tape import Tape, TapeContext +from bub.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.tracing import Span, current_span from bub.turn import TurnState diff --git a/src/bub/agent/context.py b/src/bub/agent/context.py index 1464714e..18d77f69 100644 --- a/src/bub/agent/context.py +++ b/src/bub/agent/context.py @@ -8,8 +8,8 @@ import republic -from bub.agent.tape import TapeContext, TapeEntry, to_message -from bub.agent.tools import result_content +from bub.tape import TapeContext, TapeEntry, to_message +from bub.tools import result_content def default_tape_context() -> TapeContext: diff --git a/src/bub/agent/events.py b/src/bub/agent/events.py index 280a81e6..e2e3a5de 100644 --- a/src/bub/agent/events.py +++ b/src/bub/agent/events.py @@ -15,8 +15,8 @@ from bub.turn import TurnState if TYPE_CHECKING: - from bub.agent.tape import Tape - from bub.agent.tools import ToolContext + from bub.tape import Tape + from bub.tools import ToolContext @dataclass(frozen=True) diff --git a/src/bub/agent/system_prompt.py b/src/bub/agent/system_prompt.py index 6d5acf6e..54702f8a 100644 --- a/src/bub/agent/system_prompt.py +++ b/src/bub/agent/system_prompt.py @@ -13,8 +13,8 @@ if TYPE_CHECKING: from bub.agent.agent import Agent - from bub.agent.tape import Tape - from bub.agent.tools import Tool + from bub.tape import Tape + from bub.tools import Tool @dataclass(frozen=True) diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 4d0acb30..ee960af7 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -9,15 +9,15 @@ from bub.agent import Agent as CoreAgent from bub.agent import PromptContext -from bub.agent.store import AsyncTapeStore, TapeStore -from bub.agent.tape import Tape, session_tape_name -from bub.agent.tools import REGISTRY, Tool, model_tools from bub.builtin.settings import load_settings from bub.envelope import field_of from bub.framework import BubFramework from bub.prompt import LegacyPrompt, UserContent, prompt_text, to_content from bub.skills import discover_skills, render_skills_prompt +from bub.store import AsyncTapeStore, TapeStore from bub.streaming import AsyncStreamEvents, StreamState +from bub.tape import Tape, session_tape_name +from bub.tools import REGISTRY, Tool, model_tools from bub.turn import TurnState from bub.utils import workspace_from_state diff --git a/src/bub/builtin/archive.py b/src/bub/builtin/archive.py index cad6d9a4..b92e06d8 100644 --- a/src/bub/builtin/archive.py +++ b/src/bub/builtin/archive.py @@ -8,9 +8,9 @@ from pathlib import Path from typing import Any -from bub.agent.sidecars import sidecar_tape_name -from bub.agent.store import TapeQuery -from bub.agent.tape import Tape +from bub.sidecars import sidecar_tape_name +from bub.store import TapeQuery +from bub.tape import Tape def archive_dir() -> Path: diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py index 56fbb0f5..8d136b8e 100644 --- a/src/bub/builtin/codemode/__init__.py +++ b/src/bub/builtin/codemode/__init__.py @@ -16,13 +16,13 @@ import republic -from bub.agent.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool from bub.builtin.codemode.republic_bridge import REPUBLIC_CALL_PREFIX, RepublicSession, decode_value, encode_value from bub.builtin.environment import environment_from_state from bub.builtin.settings import set_session_setting from bub.environment import CallTool, CodeFailed from bub.errors import BubError, ErrorKind from bub.prompt import UserContent +from bub.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool RUN_CODE_TOOL_NAME = "run_code" CODE_MODE_STATE_KEY = "code_mode" diff --git a/src/bub/builtin/codemode/republic_bridge.py b/src/bub/builtin/codemode/republic_bridge.py index 31a982de..b7302abf 100644 --- a/src/bub/builtin/codemode/republic_bridge.py +++ b/src/bub/builtin/codemode/republic_bridge.py @@ -29,8 +29,8 @@ UsageDelta, ) -from bub.agent.tools import ToolContext from bub.errors import BubError, ErrorKind +from bub.tools import ToolContext REPUBLIC_CALL_PREFIX = "republic." REPUBLIC_KEY = "$republic" diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 7f0b770e..9eced762 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -13,10 +13,6 @@ from bub.agent.commands import strip_command_prefix from bub.agent.context import default_tape_context from bub.agent.events import ToolCall, ToolCallDecision, ToolCallResult -from bub.agent.sidecars import TapeSidecar -from bub.agent.store import TapeStore -from bub.agent.tape import TapeContext, session_tape_name -from bub.agent.tools import is_content_result, render_result from bub.builtin.agent import BubAgent from bub.builtin.onboarding import collect_model_config from bub.builtin.settings import load_session_settings, load_settings @@ -32,7 +28,11 @@ from bub.hooks import hookimpl from bub.model_selection import ModelChoice, ModelOptions from bub.prompt import UserContent +from bub.sidecars import TapeSidecar +from bub.store import TapeStore from bub.streaming import AsyncStreamEvents +from bub.tape import TapeContext, session_tape_name +from bub.tools import is_content_result, render_result from bub.turn import TurnState AGENTS_FILE_NAME = "AGENTS.md" @@ -338,9 +338,9 @@ async def before_tool_call( replace it with a guidance ``tool_result`` so the model can re-issue a valid call on the next step. Commands run the agent's tools directly and are left alone. """ - from bub.agent.tools import model_tools from bub.builtin.codemode import CODE_TOOLS_STATE_KEY from bub.builtin.tools import TOOL_DESCRIBE_TOOL_NAME + from bub.tools import model_tools if call.context is not None and call.context.command: return None diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 80e1f39d..d676f3e6 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -16,8 +16,8 @@ from bub.builtin.model import BubModel, ModelCandidate if TYPE_CHECKING: - from bub.agent.tape import Tape - from bub.agent.tools import ToolContext + from bub.tape import Tape + from bub.tools import ToolContext DEFAULT_MODEL = "openrouter:openrouter/free" DEFAULT_MAX_TOKENS = 16384 diff --git a/src/bub/builtin/spill.py b/src/bub/builtin/spill.py index 93b5eb52..c024e243 100644 --- a/src/bub/builtin/spill.py +++ b/src/bub/builtin/spill.py @@ -11,10 +11,10 @@ from pydantic_settings import SettingsConfigDict from bub import config -from bub.agent.tape import Tape, TapeEntry, TapeQuery -from bub.agent.tools import ToolContext, tool from bub.configure import Settings from bub.errors import BubError, ErrorKind +from bub.tape import Tape, TapeEntry, TapeQuery +from bub.tools import ToolContext, tool SPILL_READ_TOOL_NAME = "spill.read" SPILL_READ_MODEL_NAME = SPILL_READ_TOOL_NAME.replace(".", "_") diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py index 3e4694be..7df41000 100644 --- a/src/bub/builtin/store.py +++ b/src/bub/builtin/store.py @@ -6,8 +6,8 @@ from collections.abc import Iterable from dataclasses import replace -from bub.agent.store import FileTapeStore, TapeQuery -from bub.agent.tape import TapeEntry +from bub.store import FileTapeStore, TapeQuery +from bub.tape import TapeEntry from bub.utils import get_entry_text WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") @@ -17,7 +17,7 @@ class FuzzyFileTapeStore(FileTapeStore): - """:class:`~bub.agent.store.FileTapeStore` whose text queries also match approximately.""" + """:class:`~bub.store.FileTapeStore` whose text queries also match approximately.""" def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: if not query._query: diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 5adbb4ea..a0fe203b 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -11,15 +11,15 @@ from pydantic import BaseModel, Field -from bub.agent.tools import REGISTRY, Tool, ToolContext, tool from bub.builtin.environment import environment_from_state from bub.builtin.settings import load_settings, set_session_setting from bub.builtin.shell_manager import shell_manager from bub.skills import discover_skills +from bub.tools import REGISTRY, Tool, ToolContext, tool if TYPE_CHECKING: - from bub.agent.tape import Tape from bub.builtin.agent import BubAgent + from bub.tape import Tape DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 DEFAULT_HEADERS = {"accept": "text/markdown"} diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 049aae18..25790826 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -28,8 +28,6 @@ import bub from bub.agent.commands import strip_command_prefix from bub.agent.events import AfterToolCall, BeforeToolCall, EventBus -from bub.agent.tape import TapeInfo, session_tape_name -from bub.agent.tools import REGISTRY from bub.builtin.agent import BubAgent from bub.channels.admission import AdmitDecision, TurnSnapshot from bub.channels.base import Interface @@ -44,6 +42,8 @@ from bub.channels.message import ChannelMessage from bub.envelope import Envelope, field_of from bub.streaming import StreamEvent +from bub.tape import TapeInfo, session_tape_name +from bub.tools import REGISTRY _GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment] _PROMPT_REFRESH_INTERVAL: float = SPINNERS["dots"]["interval"] / 1000.0 # type: ignore[operator] diff --git a/src/bub/framework.py b/src/bub/framework.py index aeae40d0..8ffeed6c 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -15,9 +15,6 @@ from loguru import logger from bub import configure -from bub.agent.sidecars import TapeSidecar -from bub.agent.store import AsyncTapeStore, TapeStore -from bub.agent.tape import Tape, TapeContext from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import ChannelRouter, MessageHandler from bub.envelope import Envelope, content_of, field_of, unpack_batch @@ -28,7 +25,10 @@ from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs from bub.model_selection import ModelOptions from bub.prompt import UserContent, to_content, to_legacy_prompt +from bub.sidecars import TapeSidecar +from bub.store import AsyncTapeStore, TapeStore from bub.streaming import StreamState +from bub.tape import Tape, TapeContext from bub.turn import TurnResult, TurnState from bub.utils import maybe_context_manager diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index 7175715b..53cdc75e 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -16,16 +16,16 @@ ToolCallDecision, ToolCallResult, ) -from bub.agent.sidecars import TapeSidecar -from bub.agent.store import AsyncTapeStore, TapeStore -from bub.agent.tape import Tape, TapeContext from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import MessageHandler from bub.envelope import Envelope from bub.environment import Environment from bub.model_selection import ModelOptions from bub.prompt import LegacyPrompt, UserContent +from bub.sidecars import TapeSidecar +from bub.store import AsyncTapeStore, TapeStore from bub.streaming import AsyncStreamEvents, StreamState +from bub.tape import Tape, TapeContext from bub.turn import TurnState if TYPE_CHECKING: diff --git a/src/bub/prompt.py b/src/bub/prompt.py index c1e2abfa..d0f23aa8 100644 --- a/src/bub/prompt.py +++ b/src/bub/prompt.py @@ -17,7 +17,7 @@ def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: """Return user content, converting text and legacy content blocks by their value.""" - from bub.agent.tape import to_message + from bub.tape import to_message if isinstance(prompt, str): return [prompt] diff --git a/src/bub/agent/sidecars.py b/src/bub/sidecars.py similarity index 100% rename from src/bub/agent/sidecars.py rename to src/bub/sidecars.py diff --git a/src/bub/agent/store.py b/src/bub/store.py similarity index 99% rename from src/bub/agent/store.py rename to src/bub/store.py index 7f8f96df..8f8e7598 100644 --- a/src/bub/agent/store.py +++ b/src/bub/store.py @@ -15,8 +15,8 @@ from loguru import logger from typing_extensions import TypeIs -from bub.agent.tape import TapeEntry from bub.errors import BubError, ErrorKind +from bub.tape import TapeEntry class TapeStore(Protocol): diff --git a/src/bub/agent/tape.py b/src/bub/tape.py similarity index 98% rename from src/bub/agent/tape.py rename to src/bub/tape.py index 85d09240..a740c6e3 100644 --- a/src/bub/agent/tape.py +++ b/src/bub/tape.py @@ -15,8 +15,8 @@ import republic from bub import tracing -from bub.agent.sidecars import TapeSidecar, sidecar_tape_name from bub.errors import BubError, ErrorKind +from bub.sidecars import TapeSidecar, sidecar_tape_name __all__ = [ "LAST_ANCHOR", @@ -44,7 +44,7 @@ ] if TYPE_CHECKING: - from bub.agent.store import ( + from bub.store import ( AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryQueryMixin, @@ -72,7 +72,7 @@ def __getattr__(name: str) -> Any: if name not in _STORE_EXPORTS: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - from bub.agent import store + from bub import store return getattr(store, name) @@ -195,7 +195,7 @@ def to_message( data = dict(payload) raw_content = data.pop("content", None) if role == "tool" and raw_content is not None and not isinstance(raw_content, str | list): - from bub.agent.tools import render_result + from bub.tools import render_result raw_content = render_result(raw_content) content = _content_parts(raw_content) @@ -311,7 +311,7 @@ def scoped(self, name: str, context: TapeContext | None = None) -> Tape: return replace(self, context=context or self.context, _name=name) def query(self) -> TapeQuery[AsyncTapeStore]: - from bub.agent.store import TapeQuery + from bub.store import TapeQuery return TapeQuery(tape=self.name, store=self.store) @@ -509,7 +509,7 @@ def session_tape(self, session_id: str, workspace: Path, context: TapeContext | @contextlib.asynccontextmanager async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[Tape, None]: - from bub.agent.store import ForkTapeStore + from bub.store import ForkTapeStore managed_sidecars = tuple(sidecar_tape_name(self.name, sidecar.name) for sidecar in self.sidecars) fork_store = ForkTapeStore(self.store, self.name, sidecars=managed_sidecars) diff --git a/src/bub/agent/tools.py b/src/bub/tools.py similarity index 99% rename from src/bub/agent/tools.py rename to src/bub/tools.py index 7809c315..c80ec5a0 100644 --- a/src/bub/agent/tools.py +++ b/src/bub/tools.py @@ -13,9 +13,9 @@ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call from bub.agent.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult -from bub.agent.tape import Tape from bub.errors import BubError, ErrorKind from bub.prompt import UserContent +from bub.tape import Tape from bub.tracing import Span diff --git a/src/bub/utils.py b/src/bub/utils.py index c0f47348..aab1af4a 100644 --- a/src/bub/utils.py +++ b/src/bub/utils.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any -from bub.agent.tape import TapeEntry +from bub.tape import TapeEntry from bub.turn import TurnState diff --git a/tests/model_fakes.py b/tests/model_fakes.py index daa16449..f9bcdda2 100644 --- a/tests/model_fakes.py +++ b/tests/model_fakes.py @@ -9,12 +9,12 @@ import httpx2 from bub.agent import Agent as CoreAgent -from bub.agent.tape import Tape -from bub.agent.tools import Tool from bub.builtin.settings import AgentSettings 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 class StepRunner: diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 619d5224..59d65f5f 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -11,9 +11,6 @@ import republic from republic.errors import StreamIncompleteError -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext -from bub.agent.tools import Tool, ToolExecutor from bub.builtin.settings import AgentSettings from bub.errors import BubError from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl @@ -28,6 +25,9 @@ ) 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 diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index e1e93d1e..721ad1d5 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -13,14 +13,14 @@ import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`) from bub.agent import PromptContext, StepFinished from bub.agent.context import default_tape_context -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, session_tape_name -from bub.agent.tools import REGISTRY, tool from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox from bub.prompt import UserContent +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.tape import Tape, session_tape_name +from bub.tools import REGISTRY, tool from tests.model_fakes import ProviderService # --------------------------------------------------------------------------- diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 9227c66b..4d624df1 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -9,14 +9,14 @@ from typer.testing import CliRunner from bub.agent.context import default_tape_context -from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext, TapeEntry -from bub.agent.tools import Tool from bub.builtin.auth import app from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem 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 5fbfe97c..d41fa142 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -7,14 +7,14 @@ import pytest -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext, session_tape_name -from bub.agent.tools import REGISTRY 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.framework import BubFramework +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent +from bub.tape import Tape, TapeContext, session_tape_name +from bub.tools import REGISTRY class RecordingLifespan: diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 49bb9b4c..209a502f 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -14,9 +14,6 @@ import pytest import bub.builtin.tools as builtin_tools -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext -from bub.agent.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool from bub.builtin.shell_manager import ShellManager from bub.builtin.tools import ( bash, @@ -30,6 +27,9 @@ tape_info, ) 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 c94f7da1..73f7c1db 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1333,8 +1333,8 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp @pytest.mark.asyncio async def test_cli_tool_renderer_finishes_before_next_model_output() -> None: from bub.agent.events import EventBus - from bub.agent.tools import Tool, ToolExecutor from bub.channels.cli import _CliToolCallRenderer + from bub.tools import Tool, ToolExecutor events: list[str] = [] @@ -1366,8 +1366,8 @@ def ordered_tool() -> str: @pytest.mark.asyncio async def test_cli_tool_renderer_shows_the_error_the_tool_raised() -> None: from bub.agent.events import EventBus - from bub.agent.tools import Tool, ToolExecutor from bub.channels.cli import _CliToolCallRenderer + from bub.tools import Tool, ToolExecutor errors: list[BaseException] = [] diff --git a/tests/test_codemode.py b/tests/test_codemode.py index 2e5dae0c..97529d69 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -15,9 +15,6 @@ from pydantic import BaseModel, Field from bub.agent.events import BeforeToolCall, EventBus -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext -from bub.agent.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool from bub.builtin.codemode import ( CODE_TOOLS_STATE_KEY, RUN_CODE_TOOL_NAME, @@ -30,6 +27,9 @@ 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): @@ -468,8 +468,8 @@ def test_stub_declares_media_tools_and_documents_republic() -> None: async def test_run_code_attaches_media_to_its_result(tmp_path: Path) -> None: import republic - from bub.agent.tools import result_content from bub.builtin.codemode import attach_audio, attach_image, attach_video + from bub.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.agent.context import default_tape_context - from bub.agent.tape import TapeEntry - from bub.agent.tools import content_result + from bub.tape import TapeEntry + from bub.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 11551472..2da23916 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -26,8 +26,8 @@ Tool, ToolCallDecision, ) -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore from bub.errors import BubError +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore from tests.model_fakes import ProviderService, chat_events, sse, tool_events diff --git a/tests/test_core_agent_step.py b/tests/test_core_agent_step.py index ddb14d9d..32913fc5 100644 --- a/tests/test_core_agent_step.py +++ b/tests/test_core_agent_step.py @@ -10,12 +10,12 @@ from republic.errors import APIStatusError, StreamIncompleteError from bub.agent.context import default_tape_context -from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext, TapeEntry -from bub.agent.tools import Tool from bub.builtin.settings import AgentSettings 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 index 418499c9..dd91bfec 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_core_boundary.py @@ -1,9 +1,9 @@ import subprocess import sys -from bub.agent.store import FileTapeStore, TapeQuery -from bub.agent.tape import TapeEntry from bub.builtin.store import FuzzyFileTapeStore +from bub.store import FileTapeStore, TapeQuery +from bub.tape import TapeEntry APP_MODULES = ("typer", "pluggy", "pydantic_settings", "dotenv", "yaml", "rapidfuzz", "telegram", "rich") diff --git a/tests/test_environment.py b/tests/test_environment.py index 0a265f6c..1169996f 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -6,14 +6,14 @@ import pytest import bub.builtin.tools as builtin_tools -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext -from bub.agent.tools import ToolContext from bub.builtin.environment import LocalEnvironment, LocalProcess, environment_from_state from bub.builtin.shell_manager import ShellManager 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 7d158618..0f73b91e 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.agent.sidecars import sidecar_tape_name -from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore -from bub.agent.tape import TapeEntry +from bub.sidecars import sidecar_tape_name +from bub.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore +from bub.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 e600642b..e614900e 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.agent.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery -from bub.agent.tape import TapeEntry +from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery +from bub.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index 7609ba4c..f401ddcb 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -10,14 +10,14 @@ import pytest from bub.agent.context import default_tape_context -from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext -from bub.agent.tools import Tool from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem 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.agent.context import default_tape_context from tests.model_fakes import StepRunner from bub.builtin.settings import AgentSettings -from bub.agent.store import FileTapeStore, AsyncTapeStoreAdapter -from bub.agent.tape import Tape +from bub.store import FileTapeStore, AsyncTapeStoreAdapter +from bub.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 39c9ebf5..9c13f7d5 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -3,16 +3,16 @@ import pytest from bub.agent import Agent as CoreAgent -from bub.agent.store import InMemoryTapeStore -from bub.agent.tape import Tape, session_tape_name -from bub.agent.tools import Tool, ToolContext from bub.builtin import BubAgent from bub.builtin.codemode import run_code, set_code_mode from bub.builtin.model import BubModel from bub.builtin.tools import resolve_tool_names, run_subagent, show_help from bub.framework import BubFramework from bub.hooks import hookimpl +from bub.store import InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent +from bub.tape import Tape, session_tape_name +from bub.tools import Tool, ToolContext def _reply() -> AsyncStreamEvents: diff --git a/tests/test_session_settings.py b/tests/test_session_settings.py index 40be3b2b..bc49ab9f 100644 --- a/tests/test_session_settings.py +++ b/tests/test_session_settings.py @@ -4,10 +4,10 @@ import pytest -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext -from bub.agent.tools import ToolContext 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 def _context(tmp_path: Path) -> ToolContext: diff --git a/tests/test_settings.py b/tests/test_settings.py index e5620f76..632fd546 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,12 +8,12 @@ import pytest from pydantic import ValidationError -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, load_settings from bub.builtin.spill import SpillSettings from bub.configure import ensure_config 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 16163416..242c4318 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -10,9 +10,6 @@ from bub.agent.context import default_tape_context from bub.agent.events import AfterToolCall, EventBus -from bub.agent.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore -from bub.agent.tape import Tape -from bub.agent.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.builtin.archive import reset_tape from bub.builtin.hook_impl import BuiltinImpl from bub.builtin.settings import AgentSettings @@ -28,6 +25,9 @@ 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 @@ -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.agent.tools import content_result, result_content + from bub.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 56ec1cb5..1826de24 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -5,9 +5,9 @@ import pytest -from bub.agent.tools import REGISTRY, tool from bub.builtin.tools import run_subagent 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 419a69e3..9b656cd9 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -6,12 +6,12 @@ import republic from bub.agent.context import default_tape_context -from bub.agent.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext, TapeEntry, to_messages +from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore +from bub.tape import Tape, TapeContext, TapeEntry, to_messages def test_tape_reexports_legacy_store_objects() -> None: - from bub.agent import store, tape + from bub import store, tape expected_exports = { "AsyncTapeStore", diff --git a/tests/test_tape_search_output.py b/tests/test_tape_search_output.py index 00baf511..f30e2403 100644 --- a/tests/test_tape_search_output.py +++ b/tests/test_tape_search_output.py @@ -5,8 +5,8 @@ import pytest import bub.builtin.tools as builtin_tools -from bub.agent.tools import ToolContext from bub.builtin.tools import tape_search +from bub.tools import ToolContext @dataclass(frozen=True) diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py index 2be47428..b84b0819 100644 --- a/tests/test_tool_loading.py +++ b/tests/test_tool_loading.py @@ -6,9 +6,9 @@ import pytest -from bub.agent.tools import Tool from bub.builtin.agent import BubAgent 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 fad23e81..907669aa 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,10 +8,10 @@ from pydantic import BaseModel from bub.agent.events import AfterToolCall, BeforeToolCall, EventBus -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, TapeContext -from bub.agent.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): @@ -129,7 +129,7 @@ def info(self, message: str, *args: Any) -> None: def error(self, message: str, *args: Any) -> None: errors.append((message.format(*args), self.exception)) - monkeypatch.setattr("bub.agent.tools.logger", Recorder()) + monkeypatch.setattr("bub.tools.logger", Recorder()) def failing_tool() -> str: raise RuntimeError("boom") diff --git a/tests/test_tracing.py b/tests/test_tracing.py index b992966c..1878cfa2 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -13,15 +13,15 @@ from bub import tracing from bub.agent.context import default_tape_context -from bub.agent.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.agent.tape import Tape, session_tape_name -from bub.agent.tools import Tool, ToolContext, ToolExecutor from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings 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.streaming import AsyncStreamEvents, StreamEvent +from bub.tape import Tape, session_tape_name +from bub.tools import Tool, ToolContext, ToolExecutor from bub.utils import workspace_from_state from tests.model_fakes import ProviderService, chat_events, sse @@ -322,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.agent.tools import Tool, ToolExecutor +from bub.tools import Tool, ToolExecutor assert tracing.otel is None async def main(): span = tracing.Span('noop') diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index b90af298..a1eb1506 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -35,7 +35,7 @@ from pathlib import Path import republic from bub.agent import Agent, Tool -from bub.agent.store import FileTapeStore +from bub.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: diff --git a/website/src/content/docs/docs/build/tools.mdx b/website/src/content/docs/docs/build/tools.mdx index 02d3df56..1cf8b08d 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/agent/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/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.agent.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.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.agent.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/agent/tools.py): +The `REGISTRY` lives in [`bub.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py): ```python -# from src/bub/agent/tools.py +# from src/bub/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.agent.tools import REGISTRY; print(sorted(REGISTRY))" +uv run python -c "import bub_myplugin.plugin; from bub.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 1604826c..e37c0de3 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -46,7 +46,7 @@ tape_name = f"{workspace_hash}__{session_hash}" This means the same `session_id` produces a different tape in a different workspace, so two repos do not share state by accident. -The default `provide_tape_store` returns a `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `bub.agent.store.FileTapeStore` whose text queries return the newest unique matches first and also match approximately. Plugins replace it by providing their own `provide_tape_store` (for example, a SQLite or HTTP-backed store). +The default `provide_tape_store` returns a `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `bub.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.agent.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.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.agent.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.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/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index ed304c13..e783ebf8 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -95,7 +95,7 @@ from bub.hooks.interception import ( ToolCallResult, ) from bub.hooks.runtime import HookRuntime -from bub.agent.sidecars import TapeSidecar, sidecar_tape_name +from bub.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 829f136c..8a3b55f9 100644 --- a/website/src/content/docs/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/docs/tutorials/fastapi.mdx @@ -33,7 +33,7 @@ from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel from bub.agent import Agent, Tool -from bub.agent.store import FileTapeStore +from bub.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: 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 66879105..a0b707ae 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -34,7 +34,7 @@ from pathlib import Path import republic from bub.agent import Agent, Tool -from bub.agent.store import FileTapeStore +from bub.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: 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 8866c173..e7b82f38 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/agent/tools.py) 对象,套上计时日志,并写入中央 `REGISTRY`: +用 `@tool` 装饰任意函数。装饰器会构造一个 [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/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.agent.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.tools.content_result` 构造),并作为多模态工具消息发给模型;spill 只截断其中的文本。 如果 `run_code` 不在允许范围内(例如被 `allowed_tools` 限制的 subagent),该 turn 会退回直接调用工具的方式。 @@ -102,10 +102,10 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 ## 工具如何变得可用 -`REGISTRY` 位于 [`bub.agent.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/agent/tools.py): +`REGISTRY` 位于 [`bub.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py): ```python -# 来自 src/bub/agent/tools.py +# 来自 src/bub/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.agent.tools import REGISTRY; print(sorted(REGISTRY))" +uv run python -c "import bub_myplugin.plugin; from bub.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 4ec2b82a..415a63cb 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx @@ -46,7 +46,7 @@ tape_name = f"{workspace_hash}__{session_hash}" 这意味着同一个 `session_id` 在不同 workspace 会得到不同 tape,两个仓库不会互相串 state。 -默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bub.agent.store.FileTapeStore` 的子类,文本查询按从新到旧返回去重后的结果,并支持近似匹配。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。 +默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bub.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.agent.tape.to_message` 转换 entry payload。新的用户消息以 `republic.Message.to_dict()` 格式存储;旧版 Bub payload(媒体 URL、`tool_call_id`、`reasoning`、`provider_data`)仍可读取。 +- `message` → 用 `bub.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.agent.tape.to_messages` 可转换已存储的消息 payload。插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 +context selector 本身是个 hook(`build_tape_context`)。自定义 `select` 必须返回 Republic 消息(或返回它们的 awaitable);`bub.tape.to_messages` 可转换已存储的消息 payload。插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 带有 `context=False` 标记的 entry 会在 selector 运行前被移除。spill 运维 event 因此仍可在 tape 上查询,但不会进入默认 context 或插件自定义 context。 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 cd8f1a70..edd76252 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -95,7 +95,7 @@ from bub.hooks.interception import ( ToolCallResult, ) from bub.hooks.runtime import HookRuntime -from bub.agent.sidecars import TapeSidecar, sidecar_tape_name +from bub.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 57d6c8e5..a41b49c4 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx @@ -33,7 +33,7 @@ from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel from bub.agent import Agent, Tool -from bub.agent.store import FileTapeStore +from bub.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: From f795dc5fea8cc44676598819516077fe158c3661 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 17:18:28 +0800 Subject: [PATCH 10/11] refactor: export only bub.agent's own names from the package `bub.agent` no longer re-exports `Tape`, `TapeContext`, `session_tape_name`, `Tool`, `ToolContext` and `tool`; import them from `bub.tape` and `bub.tools`, where they are defined. Co-Authored-By: Claude Opus 5.5 --- src/bub/agent/__init__.py | 16 +--------------- tests/test_core_agent.py | 2 +- website/src/content/docs/docs/build/sdk.md | 12 +++++++----- .../src/content/docs/docs/tutorials/fastapi.mdx | 3 ++- website/src/content/docs/zh-cn/docs/build/sdk.md | 12 +++++++----- .../docs/zh-cn/docs/tutorials/fastapi.mdx | 3 ++- 6 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/bub/agent/__init__.py b/src/bub/agent/__init__.py index 890f6ace..71ddbda4 100644 --- a/src/bub/agent/__init__.py +++ b/src/bub/agent/__init__.py @@ -1,4 +1,4 @@ -"""Bub's agent kernel: agent loop, Republic model, tools, system prompt and tape. +"""Bub's agent kernel: agent loop, Republic model, events and system prompt. Nothing here reads settings or plugin hooks. The loop reports its progress as events to registered handlers; the framework and the builtin agent assemble these components. @@ -34,8 +34,6 @@ from bub.agent.model import ChatModelClient, Model, ModelClient from bub.agent.recorder import TapeRecorder from bub.agent.system_prompt import PromptContext, PromptSection, SystemPrompt - from bub.tape import Tape, TapeContext, session_tape_name - from bub.tools import Tool, ToolContext, tool __all__ = [ "AfterLlmCall", @@ -60,19 +58,13 @@ "StepFinished", "StepStarted", "SystemPrompt", - "Tape", - "TapeContext", "TapeEvent", "TapeRecorder", - "Tool", "ToolCall", "ToolCallDecision", "ToolCallResult", - "ToolContext", "default_tape_context", "events_from", - "session_tape_name", - "tool", ] # Exports are imported on first access, so importing one submodule (such as ``bub.agent.events`` @@ -100,19 +92,13 @@ "StepFinished": "bub.agent.events", "StepStarted": "bub.agent.events", "SystemPrompt": "bub.agent.system_prompt", - "Tape": "bub.tape", - "TapeContext": "bub.tape", "TapeEvent": "bub.agent.events", "TapeRecorder": "bub.agent.recorder", - "Tool": "bub.tools", "ToolCall": "bub.agent.events", "ToolCallDecision": "bub.agent.events", "ToolCallResult": "bub.agent.events", - "ToolContext": "bub.tools", "default_tape_context": "bub.agent.context", "events_from": "bub.agent.agent", - "session_tape_name": "bub.tape", - "tool": "bub.tools", } diff --git a/tests/test_core_agent.py b/tests/test_core_agent.py index 2da23916..ef565f45 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -23,11 +23,11 @@ PromptContext, RunStarted, StepFinished, - Tool, ToolCallDecision, ) from bub.errors import BubError from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.tools import Tool from tests.model_fakes import ProviderService, chat_events, sse, tool_events diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index a1eb1506..1de02df8 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -34,8 +34,9 @@ from pathlib import Path import republic -from bub.agent import Agent, Tool +from bub.agent import Agent from bub.store import FileTapeStore +from bub.tools import Tool async def lookup_order(order_id: str) -> dict[str, str]: @@ -119,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.agent` builds the same `Tool` and also adds it to Bub's global +the tool globally. The `@tool` decorator from `bub.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.agent import ToolContext +from bub.tools import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -263,7 +264,8 @@ Inside a tool, the run's bus is `context.events`: ```python from dataclasses import dataclass -from bub.agent import AgentEvent, ToolContext +from bub.agent import AgentEvent +from bub.tools import ToolContext @dataclass(kw_only=True) @@ -353,7 +355,7 @@ the turn state: | `continue_prompt(tape, prompt, state)` | `None` | Return a user message for the next step after tool calls. | ```python -from bub.agent import Tape +from bub.tape import Tape class TriageAgent(Agent): diff --git a/website/src/content/docs/docs/tutorials/fastapi.mdx b/website/src/content/docs/docs/tutorials/fastapi.mdx index 8a3b55f9..8705f1b5 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.agent import Agent, Tool +from bub.agent import Agent from bub.store import FileTapeStore +from bub.tools import Tool async def lookup_order(order_id: str) -> dict[str, str]: 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 a0b707ae..da965053 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -33,8 +33,9 @@ from pathlib import Path import republic -from bub.agent import Agent, Tool +from bub.agent import Agent from bub.store import FileTapeStore +from bub.tools import Tool async def lookup_order(order_id: str) -> dict[str, str]: @@ -116,13 +117,13 @@ Bub 自己的 `bub.builtin.model.BubModel` 就是这样的客户端。 ## 工具 -`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.agent` 中的 +`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.tools` 中的 `@tool` 装饰器生成同样的 `Tool`,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。 以 `context=True` 创建的工具会收到 `ToolContext`,其中包含本次运行的 `tape`、turn `state`、`run_id` 和事件总线: ```python -from bub.agent import ToolContext +from bub.tools import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -252,7 +253,8 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat ```python from dataclasses import dataclass -from bub.agent import AgentEvent, ToolContext +from bub.agent import AgentEvent +from bub.tools import ToolContext @dataclass(kw_only=True) @@ -337,7 +339,7 @@ async with agent.run("Check order A123", model=model, tape="customer-42") as str | `continue_prompt(tape, prompt, state)` | `None` | 在工具调用后返回下一步的用户消息。 | ```python -from bub.agent import Tape +from bub.tape import Tape class TriageAgent(Agent): 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 a41b49c4..7e652a40 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.agent import Agent, Tool +from bub.agent import Agent from bub.store import FileTapeStore +from bub.tools import Tool async def lookup_order(order_id: str) -> dict[str, str]: From 75797dcc51fac1445451666227a1bce83a60bd6e Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 15 Oct 2026 18:47:12 +0800 Subject: [PATCH 11/11] refactor!: keep the agent kernel in bub.core and streamline runs - Rename `bub.agent` back to `bub.core` and move the agent concepts into it: `tape`, `tools`, `store`, `sidecars` and `events`. The top-level aliases `bub.tape`, `bub.tools`, `bub.store` and `bub.sidecars` are removed; `errors`, `prompt`, `streaming`, `tracing` and `turn` stay at the top level. - Load `.env` in `bub.configure`, where settings read the environment, instead of `bub.framework`. All lazy `bub.*` exports, including `bub.tool`, resolve through `_EXPORTS`; only `bub.home` is computed. `bub.hookimpl` no longer loads `.env`. - Add `RunFinished` (status `ok`, `error` or `cancelled`, steps, elapsed time, error). It follows every `RunStarted` when the run's stream closes, even if it is never iterated, and is recorded as `loop.finish`. - Rename events to `*Started` / `*Finished`: `LLMCallStarted`, `LLMCallFinished`, `ToolCallStarted`, `ToolCallFinished` and `ChatFinished`; `LlmCallRequest`, `LlmCallResult` and `LlmCallDecision` become `LLMCall*`. Hook names such as `before_llm_call` are unchanged. - Build one stream per run: `run_on` takes the `invoke_agent` span and a close callback from `start`, so the `_trace_events` wrapper and the second `AsyncStreamEvents` are gone. `run_once` is now an async generator that writes usage into the run's `StreamState` instead of returning its own stream to be copied. BREAKING CHANGE: import `bub.core.tape`, `bub.core.tools`, `bub.core.store` and `bub.core.sidecars` instead of the top-level modules; the event classes and `LlmCall*` payloads are renamed; and `Agent.run_once` returns an async generator. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/main.yml | 8 +- AGENTS.md | 8 +- README.md | 2 +- pyproject.toml | 2 +- src/bub/__init__.py | 24 +- src/bub/agent/__init__.py | 110 ---- src/bub/builtin/agent.py | 21 +- src/bub/builtin/archive.py | 6 +- src/bub/builtin/codemode/__init__.py | 2 +- src/bub/builtin/codemode/republic_bridge.py | 2 +- src/bub/builtin/hook_impl.py | 16 +- src/bub/builtin/settings.py | 6 +- src/bub/builtin/spill.py | 4 +- src/bub/builtin/store.py | 6 +- src/bub/builtin/tools.py | 4 +- src/bub/channels/cli/__init__.py | 16 +- src/bub/channels/telegram.py | 2 +- src/bub/configure.py | 5 + src/bub/core/__init__.py | 74 +++ src/bub/{agent => core}/agent.py | 473 ++++++++++-------- src/bub/{agent => core}/commands.py | 0 src/bub/{agent => core}/context.py | 4 +- src/bub/{agent => core}/events.py | 48 +- src/bub/{agent => core}/model.py | 2 +- src/bub/{agent => core}/recorder.py | 22 +- src/bub/{ => core}/sidecars.py | 0 src/bub/{ => core}/store.py | 2 +- src/bub/{agent => core}/system_prompt.py | 6 +- src/bub/{ => core}/tape.py | 12 +- src/bub/{ => core}/tools.py | 12 +- src/bub/framework.py | 10 +- src/bub/hooks/interception.py | 52 +- src/bub/hooks/specs.py | 24 +- src/bub/prompt.py | 2 +- src/bub/utils.py | 2 +- tests/conftest.py | 2 +- tests/model_fakes.py | 12 +- tests/test_agent_hooks.py | 60 +-- tests/test_builtin_agent.py | 25 +- tests/test_builtin_cli.py | 2 +- tests/test_builtin_codex.py | 8 +- tests/test_builtin_hook_impl.py | 6 +- tests/test_builtin_tools.py | 6 +- tests/test_channels.py | 8 +- tests/test_codemode.py | 24 +- tests/test_core_agent.py | 106 ++-- tests/test_core_agent_step.py | 8 +- tests/test_core_boundary.py | 6 +- tests/test_environment.py | 6 +- tests/test_file_tape_store_entry_ids.py | 6 +- tests/test_fork_store_merge_back.py | 4 +- tests/test_provider_messages.py | 14 +- tests/test_sdk_isolation.py | 10 +- tests/test_session_settings.py | 6 +- tests/test_settings.py | 4 +- tests/test_spill.py | 16 +- tests/test_subagent_tool.py | 2 +- tests/test_tape.py | 8 +- tests/test_tape_search_output.py | 2 +- tests/test_tool_loading.py | 2 +- tests/test_tools.py | 22 +- tests/test_tracing.py | 16 +- website/src/content/docs/docs/build/hooks.mdx | 6 +- website/src/content/docs/docs/build/sdk.md | 49 +- website/src/content/docs/docs/build/tools.mdx | 10 +- .../docs/docs/concepts/tape-and-context.mdx | 6 +- .../docs/docs/concepts/turn-pipeline.mdx | 2 +- .../docs/docs/getting-started/install.mdx | 2 +- .../src/content/docs/docs/reference/hooks.mdx | 10 +- .../src/content/docs/docs/reference/types.mdx | 8 +- .../content/docs/docs/tutorials/fastapi.mdx | 10 +- .../src/content/docs/docs/tutorials/index.mdx | 2 +- .../content/docs/zh-cn/docs/build/hooks.mdx | 6 +- .../src/content/docs/zh-cn/docs/build/sdk.md | 49 +- .../content/docs/zh-cn/docs/build/tools.mdx | 10 +- .../zh-cn/docs/concepts/tape-and-context.mdx | 6 +- .../zh-cn/docs/concepts/turn-pipeline.mdx | 2 +- .../zh-cn/docs/getting-started/install.mdx | 2 +- .../docs/zh-cn/docs/reference/hooks.mdx | 10 +- .../docs/zh-cn/docs/reference/types.mdx | 8 +- .../docs/zh-cn/docs/tutorials/fastapi.mdx | 10 +- .../docs/zh-cn/docs/tutorials/index.mdx | 2 +- 82 files changed, 832 insertions(+), 758 deletions(-) delete mode 100644 src/bub/agent/__init__.py create mode 100644 src/bub/core/__init__.py rename src/bub/{agent => core}/agent.py (75%) rename src/bub/{agent => core}/commands.py (100%) rename src/bub/{agent => core}/context.py (96%) rename src/bub/{agent => core}/events.py (89%) rename src/bub/{agent => core}/model.py (96%) rename src/bub/{agent => core}/recorder.py (79%) rename src/bub/{ => core}/sidecars.py (100%) rename src/bub/{ => core}/store.py (99%) rename src/bub/{agent => core}/system_prompt.py (96%) rename src/bub/{ => core}/tape.py (98%) rename src/bub/{ => core}/tools.py (97%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ba1ca577..94fbf5b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,13 +56,13 @@ jobs: run: uv build --wheel -o dist # `pip install bub` without the app extra installs only the agent kernel. - - name: Use bub.agent without the app extra + - 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.agent import Agent - from bub.store import InMemoryTapeStore - import bub.errors, bub.store, bub.tape, bub.tools + 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") diff --git a/AGENTS.md b/AGENTS.md index 8c962ed5..498b4382 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,19 +5,19 @@ Core code lives under `src/`: - `src/bub/__main__.py`: Typer CLI entrypoint; `main` (the `bub` script) checks the `app` extra before importing the CLI. -- `src/bub/agent/`: 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/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.agent.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, tape services, and the fuzzy-searching file tape store. +- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `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.agent` (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`. +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 diff --git a/README.md b/README.md index 7348d762..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.** `pip install bub` installs only `bub.agent`, 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. +- **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 a9bb950b..a215b902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] -# The agent kernel, bub.agent. Everything else (framework, CLI, channels, builtin agent) is in the `app` extra. +# The agent kernel, bub.core. Everything else (framework, CLI, channels, builtin agent) is in the `app` extra. dependencies = [ "republic>=0.6.0a1", "pydantic>=2.12.5", diff --git a/src/bub/__init__.py b/src/bub/__init__.py index c54c59cf..58dbf274 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,6 +1,6 @@ """Bub framework package. -The top-level names are imported on first access, so ``import bub.agent`` does not load +The top-level names are imported on first access, so ``import bub.core`` does not load the framework, its settings or the CLI. """ @@ -15,9 +15,9 @@ if TYPE_CHECKING: from bub.configure import Settings, config, ensure_config + from bub.core.tools import tool from bub.framework import BubFramework from bub.hooks import hookimpl - from bub.tools import tool home: Path @@ -37,6 +37,7 @@ "config": "bub.configure", "ensure_config": "bub.configure", "hookimpl": "bub.hooks", + "tool": "bub.core.tools", } try: @@ -49,18 +50,13 @@ def __getattr__(name: str) -> Any: - if name == "tool": - from bub.tools import tool - - return tool - if name != "home" and name not in _EXPORTS: - raise AttributeError(f"module {__name__} has no attribute {name}") - # The framework loads ``.env`` on import, before settings, plugins or ``home`` read the environment. - framework = import_module("bub.framework") 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 framework.DEFAULT_HOME - value = getattr(import_module(_EXPORTS[name]), name) - globals()[name] = value - return value + return DEFAULT_HOME + 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/agent/__init__.py b/src/bub/agent/__init__.py deleted file mode 100644 index 71ddbda4..00000000 --- a/src/bub/agent/__init__.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Bub's agent kernel: agent loop, Republic model, events and system prompt. - -Nothing here reads settings or plugin hooks. The loop reports its progress as events -to registered handlers; the framework and the builtin agent assemble these components. -""" - -from importlib import import_module -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from bub.agent.agent import Agent, events_from - from bub.agent.context import default_tape_context - from bub.agent.events import ( - AfterLlmCall, - AfterToolCall, - AgentEvent, - BeforeLlmCall, - BeforeToolCall, - ChatCompleted, - CommandFinished, - EventBus, - EventHandler, - LlmCallDecision, - LlmCallRequest, - LlmCallResult, - RunStarted, - StepFinished, - StepStarted, - TapeEvent, - ToolCall, - ToolCallDecision, - ToolCallResult, - ) - from bub.agent.model import ChatModelClient, Model, ModelClient - from bub.agent.recorder import TapeRecorder - from bub.agent.system_prompt import PromptContext, PromptSection, SystemPrompt - -__all__ = [ - "AfterLlmCall", - "AfterToolCall", - "Agent", - "AgentEvent", - "BeforeLlmCall", - "BeforeToolCall", - "ChatCompleted", - "ChatModelClient", - "CommandFinished", - "EventBus", - "EventHandler", - "LlmCallDecision", - "LlmCallRequest", - "LlmCallResult", - "Model", - "ModelClient", - "PromptContext", - "PromptSection", - "RunStarted", - "StepFinished", - "StepStarted", - "SystemPrompt", - "TapeEvent", - "TapeRecorder", - "ToolCall", - "ToolCallDecision", - "ToolCallResult", - "default_tape_context", - "events_from", -] - -# Exports are imported on first access, so importing one submodule (such as ``bub.agent.events`` -# from ``bub.tools``) does not import the agent loop and the modules it depends on. -_EXPORTS = { - "AfterLlmCall": "bub.agent.events", - "AfterToolCall": "bub.agent.events", - "Agent": "bub.agent.agent", - "AgentEvent": "bub.agent.events", - "BeforeLlmCall": "bub.agent.events", - "BeforeToolCall": "bub.agent.events", - "ChatCompleted": "bub.agent.events", - "ChatModelClient": "bub.agent.model", - "CommandFinished": "bub.agent.events", - "EventBus": "bub.agent.events", - "EventHandler": "bub.agent.events", - "LlmCallDecision": "bub.agent.events", - "LlmCallRequest": "bub.agent.events", - "LlmCallResult": "bub.agent.events", - "Model": "bub.agent.model", - "ModelClient": "bub.agent.model", - "PromptContext": "bub.agent.system_prompt", - "PromptSection": "bub.agent.system_prompt", - "RunStarted": "bub.agent.events", - "StepFinished": "bub.agent.events", - "StepStarted": "bub.agent.events", - "SystemPrompt": "bub.agent.system_prompt", - "TapeEvent": "bub.agent.events", - "TapeRecorder": "bub.agent.recorder", - "ToolCall": "bub.agent.events", - "ToolCallDecision": "bub.agent.events", - "ToolCallResult": "bub.agent.events", - "default_tape_context": "bub.agent.context", - "events_from": "bub.agent.agent", -} - - -def __getattr__(name: str) -> Any: - if name not in _EXPORTS: - raise AttributeError(f"module {__name__} has no attribute {name}") - value = getattr(import_module(_EXPORTS[name]), name) - globals()[name] = value - return value diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index ee960af7..8f6f6cd7 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -7,17 +7,16 @@ from collections.abc import Collection from pathlib import Path -from bub.agent import Agent as CoreAgent -from bub.agent import PromptContext +import bub.core as core from bub.builtin.settings import load_settings +from bub.core.store import AsyncTapeStore, TapeStore +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import REGISTRY, Tool, model_tools from bub.envelope import field_of from bub.framework import BubFramework from bub.prompt import LegacyPrompt, UserContent, prompt_text, to_content from bub.skills import discover_skills, render_skills_prompt -from bub.store import AsyncTapeStore, TapeStore from bub.streaming import AsyncStreamEvents, StreamState -from bub.tape import Tape, session_tape_name -from bub.tools import REGISTRY, Tool, model_tools from bub.turn import TurnState from bub.utils import workspace_from_state @@ -25,11 +24,11 @@ ALLOWED_SKILLS_STATE_KEY = "allowed_skills" -class BubAgent(CoreAgent): +class BubAgent(core.Agent): """Bub's agent: the core loop configured by settings and the framework's hooks. Adds commands, skills, code mode, deferred tools, steering and the - ``continue_prompt`` hook to :class:`bub.agent.Agent`. System prompt sections, in + ``continue_prompt`` hook to :class:`bub.core.Agent`. System prompt sections, in order: the framework's ``system_prompt`` hooks, code mode, deferred tools, skills, then sections added with ``system_prompt.add``. The framework's interception hooks handle events before handlers added with ``events.on``. @@ -225,11 +224,11 @@ def _split_code_mode(self, state: TurnState) -> tuple[list[Tool], list[Tool] | N code_tools = [tool for tool in direct_tools if tool.exposure in ("auto", "code")] return [tool for tool in tools if tool.exposure == "direct"], code_tools - def _hook_prompt(self, context: PromptContext) -> str: + def _hook_prompt(self, context: core.PromptContext) -> str: return self.framework.get_system_prompt(prompt=context.prompt, state=context.state) @staticmethod - def _code_mode_prompt(context: PromptContext) -> str | None: + def _code_mode_prompt(context: core.PromptContext) -> str | None: from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, render_code_mode_prompt, write_tool_stub state = context.state @@ -240,13 +239,13 @@ def _code_mode_prompt(context: PromptContext) -> str | None: ) return render_code_mode_prompt(stub_path) - def _deferred_tools_prompt(self, context: PromptContext) -> str: + def _deferred_tools_prompt(self, context: core.PromptContext) -> str: from bub.builtin.tools import render_deferred_tools_prompt tools, _ = self._split_code_mode(context.state) return render_deferred_tools_prompt(_deferred_tools(tools)) - def _skills_prompt(self, context: PromptContext) -> str: + def _skills_prompt(self, context: core.PromptContext) -> str: allowed = context.state.get(ALLOWED_SKILLS_STATE_KEY) return self._load_skills_prompt( prompt_text(context.prompt), workspace_from_state(context.state), None if allowed is None else set(allowed) diff --git a/src/bub/builtin/archive.py b/src/bub/builtin/archive.py index b92e06d8..9e190514 100644 --- a/src/bub/builtin/archive.py +++ b/src/bub/builtin/archive.py @@ -8,9 +8,9 @@ from pathlib import Path from typing import Any -from bub.sidecars import sidecar_tape_name -from bub.store import TapeQuery -from bub.tape import Tape +from bub.core.sidecars import sidecar_tape_name +from bub.core.store import TapeQuery +from bub.core.tape import Tape def archive_dir() -> Path: diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py index 8d136b8e..0a366a67 100644 --- a/src/bub/builtin/codemode/__init__.py +++ b/src/bub/builtin/codemode/__init__.py @@ -19,10 +19,10 @@ from bub.builtin.codemode.republic_bridge import REPUBLIC_CALL_PREFIX, RepublicSession, decode_value, encode_value from bub.builtin.environment import environment_from_state from bub.builtin.settings import set_session_setting +from bub.core.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool from bub.environment import CallTool, CodeFailed from bub.errors import BubError, ErrorKind from bub.prompt import UserContent -from bub.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools, tool RUN_CODE_TOOL_NAME = "run_code" CODE_MODE_STATE_KEY = "code_mode" diff --git a/src/bub/builtin/codemode/republic_bridge.py b/src/bub/builtin/codemode/republic_bridge.py index b7302abf..902d3ceb 100644 --- a/src/bub/builtin/codemode/republic_bridge.py +++ b/src/bub/builtin/codemode/republic_bridge.py @@ -29,8 +29,8 @@ UsageDelta, ) +from bub.core.tools import ToolContext from bub.errors import BubError, ErrorKind -from bub.tools import ToolContext REPUBLIC_CALL_PREFIX = "republic." REPUBLIC_KEY = "$republic" diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 9eced762..cfc302b2 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -10,9 +10,6 @@ from loguru import logger from bub import inquirer as bub_inquirer -from bub.agent.commands import strip_command_prefix -from bub.agent.context import default_tape_context -from bub.agent.events import ToolCall, ToolCallDecision, ToolCallResult from bub.builtin.agent import BubAgent from bub.builtin.onboarding import collect_model_config from bub.builtin.settings import load_session_settings, load_settings @@ -21,6 +18,13 @@ from bub.channels.base import Channel from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage, MediaItem +from bub.core.commands import strip_command_prefix +from bub.core.context import default_tape_context +from bub.core.events import ToolCall, ToolCallDecision, ToolCallResult +from bub.core.sidecars import TapeSidecar +from bub.core.store import TapeStore +from bub.core.tape import TapeContext, session_tape_name +from bub.core.tools import is_content_result, render_result from bub.envelope import Envelope, content_of, field_of from bub.environment import Environment from bub.errors import BubError @@ -28,11 +32,7 @@ from bub.hooks import hookimpl from bub.model_selection import ModelChoice, ModelOptions from bub.prompt import UserContent -from bub.sidecars import TapeSidecar -from bub.store import TapeStore from bub.streaming import AsyncStreamEvents -from bub.tape import TapeContext, session_tape_name -from bub.tools import is_content_result, render_result from bub.turn import TurnState AGENTS_FILE_NAME = "AGENTS.md" @@ -340,7 +340,7 @@ async def before_tool_call( """ from bub.builtin.codemode import CODE_TOOLS_STATE_KEY from bub.builtin.tools import TOOL_DESCRIBE_TOOL_NAME - from bub.tools import model_tools + from bub.core.tools import model_tools if call.context is not None and call.context.command: return None diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index d676f3e6..ef11fb22 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -12,12 +12,12 @@ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict from bub import Settings, __version__, config, ensure_config -from bub.agent.commands import validate_command_prefix from bub.builtin.model import BubModel, ModelCandidate +from bub.core.commands import validate_command_prefix if TYPE_CHECKING: - from bub.tape import Tape - from bub.tools import ToolContext + from bub.core.tape import Tape + from bub.core.tools import ToolContext DEFAULT_MODEL = "openrouter:openrouter/free" DEFAULT_MAX_TOKENS = 16384 diff --git a/src/bub/builtin/spill.py b/src/bub/builtin/spill.py index c024e243..2ebea5d9 100644 --- a/src/bub/builtin/spill.py +++ b/src/bub/builtin/spill.py @@ -12,9 +12,9 @@ from bub import config from bub.configure import Settings +from bub.core.tape import Tape, TapeEntry, TapeQuery +from bub.core.tools import ToolContext, tool from bub.errors import BubError, ErrorKind -from bub.tape import Tape, TapeEntry, TapeQuery -from bub.tools import ToolContext, tool SPILL_READ_TOOL_NAME = "spill.read" SPILL_READ_MODEL_NAME = SPILL_READ_TOOL_NAME.replace(".", "_") diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py index 7df41000..2da18e2c 100644 --- a/src/bub/builtin/store.py +++ b/src/bub/builtin/store.py @@ -6,8 +6,8 @@ from collections.abc import Iterable from dataclasses import replace -from bub.store import FileTapeStore, TapeQuery -from bub.tape import TapeEntry +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_/-]+") @@ -17,7 +17,7 @@ class FuzzyFileTapeStore(FileTapeStore): - """:class:`~bub.store.FileTapeStore` whose text queries also match approximately.""" + """:class:`~bub.core.store.FileTapeStore` whose text queries also match approximately.""" def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: if not query._query: diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index a0fe203b..770d9d1d 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -14,12 +14,12 @@ from bub.builtin.environment import environment_from_state from bub.builtin.settings import load_settings, set_session_setting from bub.builtin.shell_manager import shell_manager +from bub.core.tools import REGISTRY, Tool, ToolContext, tool from bub.skills import discover_skills -from bub.tools import REGISTRY, Tool, ToolContext, tool if TYPE_CHECKING: from bub.builtin.agent import BubAgent - from bub.tape import Tape + from bub.core.tape import Tape DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 DEFAULT_HEADERS = {"accept": "text/markdown"} diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 25790826..c4caca2a 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -26,8 +26,6 @@ from rich.tree import Tree import bub -from bub.agent.commands import strip_command_prefix -from bub.agent.events import AfterToolCall, BeforeToolCall, EventBus from bub.builtin.agent import BubAgent from bub.channels.admission import AdmitDecision, TurnSnapshot from bub.channels.base import Interface @@ -40,10 +38,12 @@ from bub.channels.cli.writers import MarkdownWriter from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage +from bub.core.commands import strip_command_prefix +from bub.core.events import EventBus, ToolCallFinished, ToolCallStarted +from bub.core.tape import TapeInfo, session_tape_name +from bub.core.tools import REGISTRY from bub.envelope import Envelope, field_of from bub.streaming import StreamEvent -from bub.tape import TapeInfo, session_tape_name -from bub.tools import REGISTRY _GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment] _PROMPT_REFRESH_INTERVAL: float = SPINNERS["dots"]["interval"] / 1000.0 # type: ignore[operator] @@ -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/channels/telegram.py b/src/bub/channels/telegram.py index 7974a2c5..c455de4a 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -15,11 +15,11 @@ from telegram.request import HTTPXRequest from bub import config -from bub.agent.commands import strip_command_prefix, validate_command_prefix from bub.channels.base import Channel from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage, MediaItem, MediaType from bub.configure import Settings, ensure_config +from bub.core.commands import strip_command_prefix, validate_command_prefix from bub.utils import exclude_none diff --git a/src/bub/configure.py b/src/bub/configure.py index d6a1b2ac..e44c12e1 100644 --- a/src/bub/configure.py +++ b/src/bub/configure.py @@ -4,8 +4,13 @@ from pathlib import Path from typing import Any +from dotenv import find_dotenv, load_dotenv from pydantic_settings import BaseSettings, PydanticBaseSettingsSource +# Settings read the environment, so load ``.env`` before any of them is created. +load_dotenv(find_dotenv(usecwd=True)) +DEFAULT_HOME = Path.home() / ".bub" + CONFIG_MAP: dict[str, list[type[BaseSettings]]] = {} ROOT = "" MISSING = object() diff --git a/src/bub/core/__init__.py b/src/bub/core/__init__.py new file mode 100644 index 00000000..b4d39dea --- /dev/null +++ b/src/bub/core/__init__.py @@ -0,0 +1,74 @@ +"""Bub's agent kernel: agent loop, Republic model, tools, system prompt and tape. + +Nothing here reads settings or plugin hooks. The loop reports its progress as events +to registered handlers; the framework and the builtin agent assemble these components. +""" + +from bub.core.agent import Agent, events_from +from bub.core.context import default_tape_context +from bub.core.events import ( + AgentEvent, + ChatFinished, + CommandFinished, + EventBus, + EventHandler, + LLMCallDecision, + LLMCallFinished, + LLMCallRequest, + LLMCallResult, + LLMCallStarted, + RunFinished, + RunStarted, + StepFinished, + StepStarted, + TapeEvent, + ToolCall, + ToolCallDecision, + ToolCallFinished, + ToolCallResult, + ToolCallStarted, +) +from bub.core.model import ChatModelClient, Model, ModelClient +from bub.core.recorder import TapeRecorder +from bub.core.system_prompt import PromptContext, PromptSection, SystemPrompt +from bub.core.tape import Tape, TapeContext, session_tape_name +from bub.core.tools import Tool, ToolContext, tool + +__all__ = [ + "Agent", + "AgentEvent", + "ChatFinished", + "ChatModelClient", + "CommandFinished", + "EventBus", + "EventHandler", + "LLMCallDecision", + "LLMCallFinished", + "LLMCallRequest", + "LLMCallResult", + "LLMCallStarted", + "Model", + "ModelClient", + "PromptContext", + "PromptSection", + "RunFinished", + "RunStarted", + "StepFinished", + "StepStarted", + "SystemPrompt", + "Tape", + "TapeContext", + "TapeEvent", + "TapeRecorder", + "Tool", + "ToolCall", + "ToolCallDecision", + "ToolCallFinished", + "ToolCallResult", + "ToolCallStarted", + "ToolContext", + "default_tape_context", + "events_from", + "session_tape_name", + "tool", +] diff --git a/src/bub/agent/agent.py b/src/bub/core/agent.py similarity index 75% rename from src/bub/agent/agent.py rename to src/bub/core/agent.py index 752d1f2b..78e8502a 100644 --- a/src/bub/agent/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 @@ -16,32 +16,33 @@ from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady from republic.tools import BuiltinTool -from bub.agent.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix -from bub.agent.context import default_tape_context -from bub.agent.events import ( - AfterLlmCall, +from bub.core.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix +from bub.core.context import default_tape_context +from bub.core.events import ( AgentEvent, - BeforeLlmCall, - ChatCompleted, + ChatFinished, CommandFinished, EventBus, - LlmCallRequest, - LlmCallResult, + LLMCallFinished, + LLMCallRequest, + LLMCallResult, + LLMCallStarted, + RunFinished, RunStarted, StepFinished, StepStarted, TapeEvent, ) -from bub.agent.model import Model, ModelClient, is_context_length_error, model_client -from bub.agent.recorder import TapeRecorder -from bub.agent.system_prompt import PromptContext, PromptSection, SystemPrompt +from bub.core.model import Model, ModelClient, is_context_length_error, model_client +from bub.core.recorder import TapeRecorder +from bub.core.sidecars import TapeSidecar +from bub.core.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store +from bub.core.system_prompt import PromptContext, PromptSection, SystemPrompt +from bub.core.tape import Tape, TapeContext +from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.errors import BubError, ErrorKind from bub.prompt import UserContent -from bub.sidecars import TapeSidecar -from bub.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.tape import Tape, TapeContext -from bub.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.tracing import Span, current_span from bub.turn import TurnState @@ -56,7 +57,7 @@ class Agent: The agent owns no configuration or session. A run calls the model given to it, or else the agent's ``model``: a ``republic.ChatModel`` or a - :class:`~bub.agent.model.ModelClient`. The agent is assembled from its components: + :class:`~bub.core.model.ModelClient`. The agent is assembled from its components: Args: model: Default model of runs not given one; None requires each run to give one. @@ -76,9 +77,9 @@ class Agent: command_prefix: When set, a text prompt starting with it runs as a command (see :meth:`run_command`) instead of the agent loop. None disables commands. - The loop reports its progress as events (:mod:`bub.agent.events`) on ``events``; + 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/agent/commands.py b/src/bub/core/commands.py similarity index 100% rename from src/bub/agent/commands.py rename to src/bub/core/commands.py diff --git a/src/bub/agent/context.py b/src/bub/core/context.py similarity index 96% rename from src/bub/agent/context.py rename to src/bub/core/context.py index 18d77f69..3e649c24 100644 --- a/src/bub/agent/context.py +++ b/src/bub/core/context.py @@ -8,8 +8,8 @@ import republic -from bub.tape import TapeContext, TapeEntry, to_message -from bub.tools import result_content +from bub.core.tape import TapeContext, TapeEntry, to_message +from bub.core.tools import result_content def default_tape_context() -> TapeContext: diff --git a/src/bub/agent/events.py b/src/bub/core/events.py similarity index 89% rename from src/bub/agent/events.py rename to src/bub/core/events.py index e2e3a5de..b4a6cb38 100644 --- a/src/bub/agent/events.py +++ b/src/bub/core/events.py @@ -15,12 +15,12 @@ from bub.turn import TurnState if TYPE_CHECKING: - from bub.tape import Tape - from bub.tools import ToolContext + from bub.core.tape import Tape + from bub.core.tools import ToolContext @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/agent/model.py b/src/bub/core/model.py similarity index 96% rename from src/bub/agent/model.py rename to src/bub/core/model.py index 6034af9d..2ec1d48a 100644 --- a/src/bub/agent/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/agent/recorder.py b/src/bub/core/recorder.py similarity index 79% rename from src/bub/agent/recorder.py rename to src/bub/core/recorder.py index d5571248..d2446841 100644 --- a/src/bub/agent/recorder.py +++ b/src/bub/core/recorder.py @@ -7,7 +7,15 @@ import republic -from bub.agent.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/sidecars.py b/src/bub/core/sidecars.py similarity index 100% rename from src/bub/sidecars.py rename to src/bub/core/sidecars.py diff --git a/src/bub/store.py b/src/bub/core/store.py similarity index 99% rename from src/bub/store.py rename to src/bub/core/store.py index 8f8e7598..51604ed7 100644 --- a/src/bub/store.py +++ b/src/bub/core/store.py @@ -15,8 +15,8 @@ from loguru import logger from typing_extensions import TypeIs +from bub.core.tape import TapeEntry from bub.errors import BubError, ErrorKind -from bub.tape import TapeEntry class TapeStore(Protocol): diff --git a/src/bub/agent/system_prompt.py b/src/bub/core/system_prompt.py similarity index 96% rename from src/bub/agent/system_prompt.py rename to src/bub/core/system_prompt.py index 54702f8a..aa01739b 100644 --- a/src/bub/agent/system_prompt.py +++ b/src/bub/core/system_prompt.py @@ -12,9 +12,9 @@ from bub.turn import TurnState if TYPE_CHECKING: - from bub.agent.agent import Agent - from bub.tape import Tape - from bub.tools import Tool + from bub.core.agent import Agent + from bub.core.tape import Tape + from bub.core.tools import Tool @dataclass(frozen=True) diff --git a/src/bub/tape.py b/src/bub/core/tape.py similarity index 98% rename from src/bub/tape.py rename to src/bub/core/tape.py index a740c6e3..36c881b3 100644 --- a/src/bub/tape.py +++ b/src/bub/core/tape.py @@ -15,8 +15,8 @@ import republic from bub import tracing +from bub.core.sidecars import TapeSidecar, sidecar_tape_name from bub.errors import BubError, ErrorKind -from bub.sidecars import TapeSidecar, sidecar_tape_name __all__ = [ "LAST_ANCHOR", @@ -44,7 +44,7 @@ ] if TYPE_CHECKING: - from bub.store import ( + from bub.core.store import ( AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryQueryMixin, @@ -72,7 +72,7 @@ def __getattr__(name: str) -> Any: if name not in _STORE_EXPORTS: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - from bub import store + from bub.core import store return getattr(store, name) @@ -195,7 +195,7 @@ def to_message( data = dict(payload) raw_content = data.pop("content", None) if role == "tool" and raw_content is not None and not isinstance(raw_content, str | list): - from bub.tools import render_result + from bub.core.tools import render_result raw_content = render_result(raw_content) content = _content_parts(raw_content) @@ -311,7 +311,7 @@ def scoped(self, name: str, context: TapeContext | None = None) -> Tape: return replace(self, context=context or self.context, _name=name) def query(self) -> TapeQuery[AsyncTapeStore]: - from bub.store import TapeQuery + from bub.core.store import TapeQuery return TapeQuery(tape=self.name, store=self.store) @@ -509,7 +509,7 @@ def session_tape(self, session_id: str, workspace: Path, context: TapeContext | @contextlib.asynccontextmanager async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[Tape, None]: - from bub.store import ForkTapeStore + from bub.core.store import ForkTapeStore managed_sidecars = tuple(sidecar_tape_name(self.name, sidecar.name) for sidecar in self.sidecars) fork_store = ForkTapeStore(self.store, self.name, sidecars=managed_sidecars) diff --git a/src/bub/tools.py b/src/bub/core/tools.py similarity index 97% rename from src/bub/tools.py rename to src/bub/core/tools.py index c80ec5a0..10fdbe9a 100644 --- a/src/bub/tools.py +++ b/src/bub/core/tools.py @@ -12,10 +12,10 @@ from loguru import logger from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call -from bub.agent.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 -from bub.tape import Tape from bub.tracing import Span @@ -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 8ffeed6c..4b6cbc40 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -11,12 +11,15 @@ 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 from bub.envelope import Envelope, content_of, field_of, unpack_batch from bub.environment import ENVIRONMENT_STATE_KEY, Environment from bub.errors import BubError, ErrorKind @@ -25,18 +28,13 @@ from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs from bub.model_selection import ModelOptions from bub.prompt import UserContent, to_content, to_legacy_prompt -from bub.sidecars import TapeSidecar -from bub.store import AsyncTapeStore, TapeStore from bub.streaming import StreamState -from bub.tape import Tape, TapeContext from bub.turn import TurnResult, TurnState from bub.utils import maybe_context_manager if TYPE_CHECKING: from bub.channels.base import Channel -load_dotenv(find_dotenv(usecwd=True)) -DEFAULT_HOME = Path.home() / ".bub" DEFAULT_CONFIG_FILE = (DEFAULT_HOME / "config.yml").resolve() diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index 0bb69a8d..bcc56c37 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -7,26 +7,26 @@ from loguru import logger -from bub.agent.events import ( - AfterLlmCall, - AfterToolCall, - BeforeLlmCall, - BeforeToolCall, +from bub.core.events import ( 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 53cdc75e..af4f3b58 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -8,24 +8,24 @@ import pluggy -from bub.agent.events import ( - LlmCallDecision, - LlmCallRequest, - LlmCallResult, +from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot +from bub.channels.contracts import MessageHandler +from bub.core.events import ( + LLMCallDecision, + LLMCallRequest, + LLMCallResult, ToolCall, ToolCallDecision, ToolCallResult, ) -from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot -from bub.channels.contracts import MessageHandler +from bub.core.sidecars import TapeSidecar +from bub.core.store import AsyncTapeStore, TapeStore +from bub.core.tape import Tape, TapeContext from bub.envelope import Envelope from bub.environment import Environment from bub.model_selection import ModelOptions from bub.prompt import LegacyPrompt, UserContent -from bub.sidecars import TapeSidecar -from bub.store import AsyncTapeStore, TapeStore from bub.streaming import AsyncStreamEvents, StreamState -from bub.tape import Tape, TapeContext from bub.turn import TurnState if TYPE_CHECKING: @@ -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/prompt.py b/src/bub/prompt.py index d0f23aa8..ee018291 100644 --- a/src/bub/prompt.py +++ b/src/bub/prompt.py @@ -17,7 +17,7 @@ def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: """Return user content, converting text and legacy content blocks by their value.""" - from bub.tape import to_message + from bub.core.tape import to_message if isinstance(prompt, str): return [prompt] diff --git a/src/bub/utils.py b/src/bub/utils.py index aab1af4a..73882cd3 100644 --- a/src/bub/utils.py +++ b/src/bub/utils.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any -from bub.tape import TapeEntry +from bub.core.tape import TapeEntry from bub.turn import TurnState diff --git a/tests/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 f9bcdda2..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.agent import Agent as CoreAgent 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 721ad1d5..e6b80e29 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -11,16 +11,17 @@ import bub.builtin.codemode import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`) -from bub.agent import PromptContext, StepFinished -from bub.agent.context import default_tape_context from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox +from bub.core import PromptContext +from bub.core.context import default_tape_context +from bub.core.events import StepFinished +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import REGISTRY, tool from bub.prompt import UserContent -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.tape import Tape, session_tape_name -from bub.tools import REGISTRY, tool +from bub.streaming import StreamEvent, StreamState from tests.model_fakes import ProviderService # --------------------------------------------------------------------------- @@ -171,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 867d63b4..877651bf 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -409,7 +409,7 @@ def test_run_help_uses_the_configured_prefix( framework.workspace = tmp_path framework.load_builtin_hooks() monkeypatch.setattr( - "bub.agent.agent.Agent.run_once", + "bub.core.agent.Agent.run_once", lambda *args, **kwargs: pytest.fail("help must not invoke the model"), ) diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 4d624df1..2400582d 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -8,15 +8,15 @@ import pytest from typer.testing import CliRunner -from bub.agent.context import default_tape_context from bub.builtin.auth import app from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem +from bub.core.context import default_tape_context +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, TapeEntry +from bub.core.tools import Tool from bub.framework import BubFramework from bub.prompt import to_content -from 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 d41fa142..76147fe3 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -10,11 +10,11 @@ from bub.builtin.hook_impl import AGENTS_FILE_NAME, DEFAULT_SYSTEM_PROMPT, BuiltinImpl from bub.builtin.store import FuzzyFileTapeStore from bub.channels.message import ChannelMessage +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, session_tape_name +from bub.core.tools import REGISTRY from bub.framework import BubFramework -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tape import Tape, TapeContext, session_tape_name -from bub.tools import REGISTRY class RecordingLifespan: 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 73f7c1db..6ceced98 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1332,9 +1332,9 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp @pytest.mark.asyncio async def test_cli_tool_renderer_finishes_before_next_model_output() -> None: - from bub.agent.events import EventBus from bub.channels.cli import _CliToolCallRenderer - from bub.tools import Tool, ToolExecutor + from bub.core.events import EventBus + from bub.core.tools import Tool, ToolExecutor events: list[str] = [] @@ -1365,9 +1365,9 @@ def ordered_tool() -> str: @pytest.mark.asyncio async def test_cli_tool_renderer_shows_the_error_the_tool_raised() -> None: - from bub.agent.events import EventBus from bub.channels.cli import _CliToolCallRenderer - from bub.tools import Tool, ToolExecutor + from bub.core.events import EventBus + from bub.core.tools import Tool, ToolExecutor errors: list[BaseException] = [] diff --git a/tests/test_codemode.py b/tests/test_codemode.py index 97529d69..ff7f4e15 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -14,7 +14,6 @@ import pytest from pydantic import BaseModel, Field -from bub.agent.events import BeforeToolCall, EventBus from bub.builtin.codemode import ( CODE_TOOLS_STATE_KEY, RUN_CODE_TOOL_NAME, @@ -24,12 +23,13 @@ write_tool_stub, ) from bub.builtin.environment import LocalEnvironment, LocalProcess +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) @@ -526,9 +526,9 @@ async def test_media_tools_only_work_inside_run_code(tmp_path: Path) -> None: def test_media_tool_results_become_multimodal_tool_messages(tmp_path: Path) -> None: import republic - from bub.agent.context import default_tape_context - from bub.tape import TapeEntry - from bub.tools import content_result + from bub.core.context import default_tape_context + from bub.core.tape import TapeEntry + from bub.core.tools import content_result image = republic.Image("image/png", data=PNG_BYTES) entries = [ diff --git a/tests/test_core_agent.py b/tests/test_core_agent.py index ef565f45..e2e22327 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -8,26 +8,26 @@ import pytest import republic -import bub.agent -from bub.agent import ( - AfterLlmCall, - AfterToolCall, - Agent, +import bub.core +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, ToolCallDecision, + ToolCallFinished, + ToolCallStarted, ) +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tools import Tool from bub.errors import BubError -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tools import Tool 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: @@ -198,7 +200,7 @@ def reroute(event: BeforeLlmCall) -> None: def test_core_does_not_depend_on_settings_or_plugins() -> None: forbidden = ("bub.builtin", "bub.configure", "bub.framework", "bub.hooks", "bub.channels", "pluggy") - for path in Path(bub.agent.__file__).parent.glob("*.py"): + for path in Path(bub.core.__file__).parent.glob("*.py"): for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): if isinstance(node, ast.ImportFrom): names = [node.module or ""] @@ -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 32913fc5..0ef1ecff 100644 --- a/tests/test_core_agent_step.py +++ b/tests/test_core_agent_step.py @@ -9,13 +9,13 @@ import republic from republic.errors import APIStatusError, StreamIncompleteError -from bub.agent.context import default_tape_context 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 index dd91bfec..7194d045 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_core_boundary.py @@ -2,15 +2,15 @@ import sys from bub.builtin.store import FuzzyFileTapeStore -from bub.store import FileTapeStore, TapeQuery -from bub.tape import TapeEntry +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.agent\n" + "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" 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_provider_messages.py b/tests/test_provider_messages.py index f401ddcb..1b7bb911 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -9,15 +9,15 @@ import httpx2 import pytest -from bub.agent.context import default_tape_context 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 @@ -174,11 +174,11 @@ async def test_fresh_process_replays_tool_signatures_to_the_provider( import asyncio, json, sys from pathlib import Path import httpx2 -from bub.agent.context import default_tape_context +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 9c13f7d5..72407ebb 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -2,17 +2,17 @@ import pytest -from bub.agent import Agent as CoreAgent from bub.builtin import BubAgent from bub.builtin.codemode import run_code, set_code_mode from bub.builtin.model import BubModel from bub.builtin.tools import resolve_tool_names, run_subagent, show_help +from bub.core import Agent as CoreAgent +from bub.core.store import InMemoryTapeStore +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import Tool, ToolContext from bub.framework import BubFramework from bub.hooks import hookimpl -from bub.store import InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tape import Tape, session_tape_name -from bub.tools import Tool, ToolContext def _reply() -> AsyncStreamEvents: @@ -54,7 +54,7 @@ async def test_sdk_agents_execute_only_their_own_command_prefix( framework: BubFramework, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("BUB_COMMAND_PREFIX", "!") - monkeypatch.setattr("bub.agent.agent.Agent.run_once", lambda *args, **kwargs: _reply()) + monkeypatch.setattr("bub.core.agent.Agent.run_once", lambda *args, **kwargs: _reply()) custom = BubAgent(framework, tools=[show_help], skill_dirs=[], command_prefix="::") configured = BubAgent(framework, tools=[show_help], skill_dirs=[]) 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 242c4318..d89286bd 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -8,8 +8,6 @@ import pytest import republic -from bub.agent.context import default_tape_context -from bub.agent.events import AfterToolCall, EventBus from bub.builtin.archive import reset_tape from bub.builtin.hook_impl import BuiltinImpl from bub.builtin.settings import AgentSettings @@ -22,21 +20,23 @@ spill_read, ) from bub.builtin.tools import render_tools_prompt +from bub.core.context import default_tape_context +from bub.core.events import EventBus, ToolCallFinished +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore +from bub.core.tape import Tape +from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import 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 9b656cd9..e0874722 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -5,13 +5,13 @@ import pytest import republic -from bub.agent.context import default_tape_context -from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry, to_messages +from bub.core.context import default_tape_context +from bub.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, TapeEntry, to_messages def test_tape_reexports_legacy_store_objects() -> None: - from bub import store, tape + from bub.core import store, tape expected_exports = { "AsyncTapeStore", 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 907669aa..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.agent.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): @@ -129,7 +129,7 @@ def info(self, message: str, *args: Any) -> None: def error(self, message: str, *args: Any) -> None: errors.append((message.format(*args), self.exception)) - monkeypatch.setattr("bub.tools.logger", Recorder()) + monkeypatch.setattr("bub.core.tools.logger", Recorder()) def failing_tool() -> str: raise RuntimeError("boom") @@ -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 1878cfa2..ff820122 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -12,16 +12,16 @@ import pytest from bub import tracing -from bub.agent.context import default_tape_context from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings +from bub.core.context import default_tape_context +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import Tool, ToolContext, ToolExecutor from bub.framework import BubFramework from bub.hooks import hookimpl -from bub.hooks.interception import LlmCallDecision, ToolCallDecision, ToolCallResult -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.hooks.interception import LLMCallDecision, ToolCallDecision, ToolCallResult from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tape import Tape, session_tape_name -from bub.tools import Tool, ToolContext, ToolExecutor from bub.utils import workspace_from_state from tests.model_fakes import ProviderService, chat_events, sse @@ -256,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=[]) @@ -322,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/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/sdk.md b/website/src/content/docs/docs/build/sdk.md index 1de02df8..1e2fc35d 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -5,7 +5,7 @@ sidebar: order: 0 --- -`bub.agent` is Bub's agent kernel. Its `Agent` runs the agent loop (call the model, execute the tools it asks for, +`bub.core` is Bub's agent kernel. Its `Agent` runs the agent loop (call the model, execute the tools it asks for, repeat until it answers) over components you supply: tools, system prompt sections, and a tape store, plus the Republic model it calls, given to the agent or to each run. It reads no Bub settings and loads no plugins, so it embeds in any application. @@ -34,9 +34,9 @@ from pathlib import Path import republic -from bub.agent import Agent -from bub.store import FileTapeStore -from bub.tools import 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]: @@ -109,25 +109,25 @@ 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 -object implementing `bub.agent.ModelClient` instead: a `spec` (the default `provider:model`), a default +object implementing `bub.core.ModelClient` instead: a `spec` (the default `provider:model`), a default `max_tokens` (or `None`), and `open_stream(messages, spec=..., options=...)`, an async context manager yielding the provider name and a `republic.Stream`. Bub's own `bub.builtin.model.BubModel` is such a client. ## 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.tools` 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.tools import ToolContext +from bub.core.tools import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -150,7 +150,7 @@ skips the section. `agent.system_prompt.add(section)` appends a section and retu decorator; `agent.system_prompt.remove(section)` removes it. ```python -from bub.agent import PromptContext +from bub.core import PromptContext @agent.system_prompt.add @@ -210,25 +210,25 @@ 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 -The loop reports its progress as events from `bub.agent.events`; register handlers to observe or steer it. +The loop reports its progress as events from `bub.core.events`; register handlers to observe or steer it. ```python -from bub.agent 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") @@ -238,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)` @@ -264,8 +265,8 @@ Inside a tool, the run's bus is `context.events`: ```python from dataclasses import dataclass -from bub.agent import AgentEvent -from bub.tools import ToolContext +from bub.core.events import AgentEvent +from bub.core.tools import ToolContext @dataclass(kw_only=True) @@ -355,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.tape 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 e37c0de3..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 `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `bub.store.FileTapeStore` whose text queries return the newest unique matches first and also match approximately. Plugins replace it by providing their own `provide_tape_store` (for example, a SQLite or HTTP-backed store). +The default `provide_tape_store` returns a `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `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/concepts/turn-pipeline.mdx b/website/src/content/docs/docs/concepts/turn-pipeline.mdx index 8556e6fb..1c14cd82 100644 --- a/website/src/content/docs/docs/concepts/turn-pipeline.mdx +++ b/website/src/content/docs/docs/concepts/turn-pipeline.mdx @@ -86,7 +86,7 @@ The default observer (`BuiltinImpl.on_error`) sends an error envelope through `d - `resolve_session` — uses the envelope's `session_id` if present, otherwise `f"{channel}:{chat_id}"`. - `load_state` — opens the inbound's optional `lifespan`, seeds `session_id` and an internal `_runtime_agent`, and restores session settings (model, reasoning effort, code mode) recorded on the session tape. - `build_prompt` — marks command prompts (`,help`, `,skill`, ...) and passes them through unchanged for the agent to run, wraps other text with timestamp and context, and emits multimodal parts when media is attached. -- `run_model_stream` — runs the turn with `BubAgent.run_stream()`, built on the `bub.agent` agent loop with [Republic](https://github.com/bubbuild/republic) models, tool use, commands, and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)). The builtin plugin does not implement `run_model`; non-streaming callers get the collected text through the adapter described above. +- `run_model_stream` — runs the turn with `BubAgent.run_stream()`, built on the `bub.core` agent loop with [Republic](https://github.com/bubbuild/republic) models, tool use, commands, and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)). The builtin plugin does not implement `run_model`; non-streaming callers get the collected text through the adapter described above. - `save_state` — closes the per-message lifespan with the captured exception info. - `render_outbound` — wraps `model_output` into one `ChannelMessage` carrying the inbound's routing fields. - `dispatch_outbound` — forwards through the bound `ChannelRouter`. diff --git a/website/src/content/docs/docs/getting-started/install.mdx b/website/src/content/docs/docs/getting-started/install.mdx index b3048d2e..8e1eddda 100644 --- a/website/src/content/docs/docs/getting-started/install.mdx +++ b/website/src/content/docs/docs/getting-started/install.mdx @@ -86,7 +86,7 @@ If you prefer `pip`, the equivalent is: 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.agent` for [embedding in Python](/docs/build/sdk/); its `bub` command asks for the extra. +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 diff --git a/website/src/content/docs/docs/reference/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx index b551bd10..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.agent.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/agent/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 8705f1b5..e068ed67 100644 --- a/website/src/content/docs/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/docs/tutorials/fastapi.mdx @@ -1,11 +1,11 @@ --- title: Serve a Bub agent with FastAPI -description: Expose a bub.agent agent over HTTP with FastAPI, with one conversation per session. +description: Expose a bub.core agent over HTTP with FastAPI, with one conversation per session. sidebar: order: 7 --- -This tutorial serves an agent built from `bub.agent` as an HTTP API. Each request names a session; requests with +This tutorial serves an agent built from `bub.core` as an HTTP API. Each request names a session; requests with the same session continue one conversation, stored as a tape on disk. You need Python 3.12+ and an API key for a model provider. The example uses OpenAI through @@ -32,9 +32,9 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.agent import Agent -from bub.store import FileTapeStore -from bub.tools import 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/index.mdx b/website/src/content/docs/docs/tutorials/index.mdx index ffcf5d60..d06fdd4a 100644 --- a/website/src/content/docs/docs/tutorials/index.mdx +++ b/website/src/content/docs/docs/tutorials/index.mdx @@ -16,7 +16,7 @@ Tutorials are hands-on lessons. Use this section when you want to learn a workfl 5. [Load Agent Plugins](/docs/tutorials/agent-plugins/) — load a portable skill and MCP server alongside existing Bub integrations. 6. [Experimental: Extend Bub with Other Languages](/docs/tutorials/language-extensions/) — keep Bub in Python, move selected hooks into Go, Rust, or other languages, and wire them back through pluggy, WebAssembly, and Extism. 7. [Use Bub as an ACP agent](/docs/tutorials/acp-server/) — connect Bub to ACP-compatible graphical clients such as Zed. -8. [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/) — expose a `bub.agent` agent over HTTP, with one conversation per session. +8. [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/) — expose a `bub.core` agent over HTTP, with one conversation per session. ## Next steps diff --git a/website/src/content/docs/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/sdk.md b/website/src/content/docs/zh-cn/docs/build/sdk.md index da965053..3097e792 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -5,7 +5,7 @@ sidebar: order: 0 --- -`bub.agent` 是 Bub 的 agent 内核。它的 `Agent` 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答), +`bub.core` 是 Bub 的 agent 内核。它的 `Agent` 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答), 所用的组件都由你提供:工具、system prompt section 和 tape store,以及传给 agent 或每次运行的 Republic 模型。 它不读取 Bub 的 settings,也不加载插件,因此可以嵌入任何应用。 @@ -33,9 +33,9 @@ from pathlib import Path import republic -from bub.agent import Agent -from bub.store import FileTapeStore -from bub.tools import 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,24 +106,24 @@ async with agent.run(prompt, tape="t") as stream: ... ``` -`options` 是随本次运行每次模型调用发送的 `republic.ChatOptions`;循环会加入本步的工具。`BeforeLlmCall` +`options` 是随本次运行每次模型调用发送的 `republic.ChatOptions`;循环会加入本步的工具。`LLMCallStarted` 处理器可以把一次调用重定向到其他 `provider:model`:同一 provider 的模型复用运行模型的客户端,其他 provider 使用从 Republic 环境变量配置的客户端。 -如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 `bub.agent.ModelClient` +如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 `bub.core.ModelClient` 的对象:提供 `spec`(默认的 `provider:model`)、默认 `max_tokens`(或 `None`),以及 `open_stream(messages, spec=..., options=...)`——一个产出 provider 名称和 `republic.Stream` 的异步上下文管理器。 Bub 自己的 `bub.builtin.model.BubModel` 就是这样的客户端。 ## 工具 -`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.tools` 中的 +`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core.tools` 中的 `@tool` 装饰器生成同样的 `Tool`,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。 以 `context=True` 创建的工具会收到 `ToolContext`,其中包含本次运行的 `tape`、turn `state`、`run_id` 和事件总线: ```python -from bub.tools import ToolContext +from bub.core.tools import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -145,7 +145,7 @@ system prompt 是有序的 section 列表,每个模型步骤渲染一次,以 追加一个 section 并返回它,因此也可以用作装饰器;`agent.system_prompt.remove(section)` 将其移除。 ```python -from bub.agent import PromptContext +from bub.core import PromptContext @agent.system_prompt.add @@ -200,26 +200,26 @@ 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 上直接执行命令。 ## 事件 -循环通过 `bub.agent.events` 中的事件报告进度;注册处理器即可观察或干预它。 +循环通过 `bub.core.events` 中的事件报告进度;注册处理器即可观察或干预它。 ```python -from bub.agent 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") @@ -229,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)` 以装饰器形式完成同样的注册, @@ -253,8 +254,8 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat ```python from dataclasses import dataclass -from bub.agent import AgentEvent -from bub.tools import ToolContext +from bub.core.events import AgentEvent +from bub.core.tools import ToolContext @dataclass(kw_only=True) @@ -339,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.tape 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 415a63cb..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/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bub.store.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/concepts/turn-pipeline.mdx b/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx index 59f19161..ccaae096 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx @@ -86,7 +86,7 @@ hook 运行时会在两者间互相适配:若插件只实现 `run_model_stream - `resolve_session` — 优先使用 envelope 的 `session_id`,否则 `f"{channel}:{chat_id}"`。 - `load_state` — 打开 inbound 的可选 `lifespan`,初始化 `session_id` 与内部 `_runtime_agent`,并恢复 session tape 上记录的会话设置(模型、reasoning effort、code mode)。 - `build_prompt` — 标记命令 prompt(`,help`、`,skill` 等)并原样传给 agent 执行,用时间戳与上下文包裹其他文本,并在附带媒体时输出多模态片段。 -- `run_model_stream` — 通过 `BubAgent.run_stream()` 运行 turn,它构建在 `bub.agent` 的 agent loop 之上,使用 [Republic](https://github.com/bubbuild/republic) 模型,支持工具调用、命令和 auto-handoff(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。builtin 插件不实现 `run_model`;非流式调用方通过上文描述的适配获得拼接后的文本。 +- `run_model_stream` — 通过 `BubAgent.run_stream()` 运行 turn,它构建在 `bub.core` 的 agent loop 之上,使用 [Republic](https://github.com/bubbuild/republic) 模型,支持工具调用、命令和 auto-handoff(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。builtin 插件不实现 `run_model`;非流式调用方通过上文描述的适配获得拼接后的文本。 - `save_state` — 用捕获的异常信息关闭 per-message lifespan。 - `render_outbound` — 将 `model_output` 包装为携带 inbound 路由字段的 `ChannelMessage`。 - `dispatch_outbound` — 转发到绑定的 `ChannelRouter`。 diff --git a/website/src/content/docs/zh-cn/docs/getting-started/install.mdx b/website/src/content/docs/zh-cn/docs/getting-started/install.mdx index 48ea2743..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 @@ -85,7 +85,7 @@ uv tool update-shell pip install "bub[app]" ``` -`app` extra 提供 `bub` 命令、channel、插件和 builtin agent。不带 extra 安装的 `bub` 只包含 agent 内核 `bub.agent`,用于[在 Python 中嵌入](/zh-cn/docs/build/sdk/);此时运行 `bub` 命令会提示安装该 extra。 +`app` extra 提供 `bub` 命令、channel、插件和 builtin agent。不带 extra 安装的 `bub` 只包含 agent 内核 `bub.core`,用于[在 Python 中嵌入](/zh-cn/docs/build/sdk/);此时运行 `bub` 命令会提示安装该 extra。 ## 3. 从源码安装 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 65b147f9..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.agent.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/agent/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 7e652a40..50513580 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx @@ -1,11 +1,11 @@ --- title: 用 FastAPI 提供 Bub agent 服务 -description: 用 FastAPI 把 bub.agent agent 暴露为 HTTP API,每个会话对应一段对话。 +description: 用 FastAPI 把 bub.core agent 暴露为 HTTP API,每个会话对应一段对话。 sidebar: order: 7 --- -本教程把基于 `bub.agent` 构建的 agent 作为 HTTP API 提供服务。每个请求都指定一个会话;同一会话的请求会延续同一段对话, +本教程把基于 `bub.core` 构建的 agent 作为 HTTP API 提供服务。每个请求都指定一个会话;同一会话的请求会延续同一段对话, 对话以 tape 的形式保存在磁盘上。 你需要 Python 3.12+ 和某个模型提供方的 API key。示例通过 [Republic](https://github.com/bubbuild/republic) 使用 OpenAI; @@ -32,9 +32,9 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.agent import Agent -from bub.store import FileTapeStore -from bub.tools import 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/index.mdx b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx index 99457a89..12058ca2 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/index.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx @@ -16,7 +16,7 @@ sidebar: 5. [加载 Agent Plugins](/zh-cn/docs/tutorials/agent-plugins/) — 加载可移植的 skill 和 MCP 服务器,与现有 Bub 扩展一起使用。 6. [实验性:使用其他语言扩展 Bub](/zh-cn/docs/tutorials/language-extensions/) — 保持 Bub 作为 Python 宿主,把部分 hooks 放到 Go、Rust 或其他语言中实现,并通过 pluggy、WebAssembly 和 Extism 接回 Bub。 7. [将 Bub 作为 ACP agent 使用](/zh-cn/docs/tutorials/acp-server/) — 把 Bub 接入 Zed 等支持 ACP 的图形客户端。 -8. [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/) — 把 `bub.agent` agent 暴露为 HTTP API,每个会话对应一段对话。 +8. [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/) — 把 `bub.core` agent 暴露为 HTTP API,每个会话对应一段对话。 ## 下一步