From 727bafd83578e0611f401f1419bd7aea1b3e5fdc Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 10 Oct 2026 00:48:06 +0800 Subject: [PATCH 01/16] refactor: use Republic for builtin models and authentication --- Dockerfile | 2 +- README.md | 4 +- pyproject.toml | 6 +- src/bub/builtin/agent.py | 8 +- src/bub/builtin/auth.py | 559 +------------ src/bub/builtin/codex_provider.py | 521 ------------ src/bub/builtin/context.py | 20 +- src/bub/builtin/hook_impl.py | 29 +- src/bub/builtin/model_runner.py | 596 +++++-------- src/bub/builtin/onboarding.py | 139 ++-- src/bub/builtin/settings.py | 82 +- src/bub/channels/cli/__init__.py | 2 +- src/bub/channels/message.py | 26 - src/bub/tape.py | 47 +- src/bub/tools.py | 11 +- src/bub/tracing.py | 5 +- tests/__init__.py | 1 + tests/conftest.py | 47 ++ tests/model_fakes.py | 79 ++ tests/test_agent_hooks.py | 78 +- tests/test_builtin_agent.py | 118 ++- tests/test_builtin_cli.py | 37 +- tests/test_builtin_codex.py | 783 +++++------------- tests/test_builtin_model_runner.py | 656 +++++++-------- tests/test_builtin_tools.py | 6 +- tests/test_channels.py | 14 +- tests/test_image_message.py | 39 +- tests/test_onboarding.py | 248 +++--- tests/test_provider_messages.py | 252 ++++++ tests/test_settings.py | 199 ++++- tests/test_spill.py | 40 +- tests/test_tape.py | 10 +- tests/test_tool_loading.py | 105 +-- tests/test_tools.py | 9 +- tests/test_tracing.py | 156 ++-- uv.lock | 445 ++++------ .../docs/docs/concepts/tape-and-context.mdx | 4 +- .../docs/docs/getting-started/first-turn.mdx | 10 +- .../docs/docs/getting-started/index.mdx | 2 +- .../content/docs/docs/operate/configure.mdx | 14 +- .../src/content/docs/docs/reference/cli.mdx | 28 +- .../content/docs/docs/reference/settings.mdx | 24 +- .../src/content/docs/docs/reference/types.mdx | 4 + .../zh-cn/docs/concepts/tape-and-context.mdx | 4 +- .../zh-cn/docs/getting-started/first-turn.mdx | 10 +- .../docs/zh-cn/docs/getting-started/index.mdx | 2 +- .../docs/zh-cn/docs/operate/configure.mdx | 18 +- .../content/docs/zh-cn/docs/reference/cli.mdx | 28 +- .../docs/zh-cn/docs/reference/settings.mdx | 21 +- .../docs/zh-cn/docs/reference/types.mdx | 4 + 50 files changed, 2095 insertions(+), 3457 deletions(-) delete mode 100644 src/bub/builtin/codex_provider.py create mode 100644 tests/__init__.py create mode 100644 tests/model_fakes.py create mode 100644 tests/test_provider_messages.py diff --git a/Dockerfile b/Dockerfile index b782e515..bf6f90c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md LICENSE entrypoint.sh ./ # Copy the full source and install COPY src ./src -RUN uv sync --no-dev --no-editable && uv pip install "any-llm-sdk[gemini,xai]" && \ +RUN uv sync --locked --no-dev --no-editable && \ chmod +x /app/entrypoint.sh WORKDIR /workspace diff --git a/README.md b/README.md index 32b3b6ee..c1699ce0 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ See the [Build docs](https://bub.build/docs/build/) for hook guides, packaging, | `bub gateway` | Channel listener (Telegram, etc.) | | `bub install` | Install or sync Bub plugin deps | | `bub update` | Upgrade Bub plugin deps | -| `bub login openai` | OpenAI Codex OAuth | +| `bub login codex` | ChatGPT plan login | Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`). @@ -131,7 +131,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk | Variable | Default | Description | | --------------------------- | ---------------------------- | ---------------------------------------------------- | | `BUB_MODEL` | `openrouter:openrouter/free` | Model identifier | -| `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) | +| `BUB_API_KEY` | — | Provider key (optional with `bub login codex`) | | `BUB_API_BASE` | — | Custom provider endpoint | | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client | | `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call | diff --git a/pyproject.toml b/pyproject.toml index 66b46370..ea2082ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,7 @@ dependencies = [ "pluggy>=1.6.0", "inquirer-textual>=0.8.0", "typer>=0.24.1", - "authlib>=1.7.2", - "any-llm-sdk[anthropic]>=1.22.1", + "republic @ git+https://github.com/bubbuild/republic.git@449debdfed5b45dffb4744091db84fbbeb0704b7", "rich>=14.3.4", "prompt-toolkit>=3.0.52", "python-telegram-bot>=22.7", @@ -73,6 +72,9 @@ dev = [ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.version] source = "vcs" fallback-version = "0.3.0" diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 864b69e0..8e336934 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -1,4 +1,4 @@ -"""Runtime engine to process prompts with any-llm-sdk.""" +"""Runtime engine to process prompts with Republic.""" from __future__ import annotations @@ -39,7 +39,7 @@ class Agent: - """Agent that processes prompts using hooks, tools, tape, and any-llm-sdk.""" + """Agent that processes prompts using hooks, tools, tape, and Republic.""" def __init__( self, @@ -512,9 +512,7 @@ async def _run_once_stream( model_tools_for_call = model_tools(tools) if (span := current_span()) and span.recording: span.set(**{ - "gen_ai.tool.definitions": [ - tool.to_schema()["function"] | {"type": "function"} for tool in model_tools_for_call - ] + "gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in model_tools_for_call] }) steering_inbox = self.framework.get_steering_inbox() steering_envelopes = await steering_inbox.drain_messages(tape.context.state) if steering_inbox else [] diff --git a/src/bub/builtin/auth.py b/src/bub/builtin/auth.py index 3700c83d..63b7a0ff 100644 --- a/src/bub/builtin/auth.py +++ b/src/bub/builtin/auth.py @@ -1,537 +1,50 @@ -"""Authentication helpers for builtin providers.""" +"""CLI entry points for Republic-managed account authentication.""" -# ruff: noqa: B008 from __future__ import annotations -import json -import math +import asyncio import os -import secrets -import threading -import time -import urllib.parse -import webbrowser -from base64 import urlsafe_b64decode, urlsafe_b64encode -from collections.abc import Callable -from contextlib import suppress -from dataclasses import dataclass -from datetime import UTC, datetime -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import subprocess from pathlib import Path -from typing import Any +from typing import Annotated import typer -from authlib.integrations.httpx_client import OAuth2Client -from loguru import logger +from republic.errors import AuthenticationError +from republic.providers import CodexAuth -CODEX_PROVIDER = "openai" -DEFAULT_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback" +app = typer.Typer(name="login", help="Account authentication") -_CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" -_CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105 -_CODEX_OAUTH_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" -_CODEX_OAUTH_SCOPE = "openid profile email offline_access" -_CODEX_OAUTH_ORIGINATOR = "codex_cli_rs" -app = typer.Typer(name="login", help="Authentication related commands") +@app.callback() +def login() -> None: + """Authenticate a provider account.""" -class CodexOAuthLoginError(RuntimeError): - """Raised when Codex OAuth login cannot complete.""" - - -class CodexOAuthStateMismatchError(CodexOAuthLoginError): - """Raised when OAuth state validation fails.""" - - -class CodexOAuthMissingCodeError(CodexOAuthLoginError): - """Raised when OAuth redirect does not include an authorization code.""" - - -class CodexOAuthResponseError(TypeError): - """Raised when the OAuth token endpoint returns a malformed payload.""" - - -class CodexOAuthRefreshError(RuntimeError): - """Raised when an expired Codex OAuth token cannot be refreshed.""" - - -@dataclass(frozen=True) -class OpenAICodexOAuthTokens: - access_token: str - refresh_token: str - expires_at: int | None - account_id: str | None = None - - -def resolve_codex_home(codex_home: str | Path | None = None) -> Path: - if codex_home is not None: - return Path(codex_home).expanduser() - return Path(os.getenv("CODEX_HOME", "~/.codex")).expanduser() - - -def resolve_codex_auth_path(codex_home: str | Path | None = None) -> Path: - return resolve_codex_home(codex_home) / "auth.json" - - -def load_openai_codex_oauth_tokens(codex_home: str | Path | None = None) -> OpenAICodexOAuthTokens | None: - auth_path = resolve_codex_auth_path(codex_home) - try: - payload = json.loads(auth_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - if not isinstance(payload, dict): - return None - return _parse_tokens(payload) - - -def save_openai_codex_oauth_tokens( - tokens: OpenAICodexOAuthTokens, - codex_home: str | Path | None = None, -) -> Path: - auth_path = resolve_codex_auth_path(codex_home) - auth_path.parent.mkdir(parents=True, exist_ok=True) - - try: - existing = json.loads(auth_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - existing = {} - payload: dict[str, Any] = existing if isinstance(existing, dict) else {} - - token_payload = payload.get("tokens") - if not isinstance(token_payload, dict): - token_payload = {} - token_payload.update({ - "access_token": tokens.access_token, - "refresh_token": tokens.refresh_token, - }) - if tokens.expires_at is None: - token_payload.pop("expires_at", None) - else: - token_payload["expires_at"] = _unix_to_rfc3339(tokens.expires_at) - if tokens.account_id: - token_payload["account_id"] = tokens.account_id - payload["tokens"] = token_payload - payload["last_refresh"] = _unix_to_rfc3339(int(time.time())) - - auth_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") - with suppress(OSError): - os.chmod(auth_path, 0o600) - return auth_path - - -def refresh_openai_codex_oauth_tokens( - refresh_token: str, - *, - timeout_seconds: float = 15.0, - client_id: str = _CODEX_OAUTH_CLIENT_ID, - token_url: str = _CODEX_OAUTH_TOKEN_URL, -) -> OpenAICodexOAuthTokens: - with OAuth2Client(client_id=client_id, timeout=timeout_seconds, trust_env=True) as oauth: - payload = oauth.refresh_token(url=token_url, refresh_token=refresh_token) - return _tokens_from_token_payload(payload, account_id=None) - - -def openai_codex_oauth_resolver( - codex_home: str | Path | None = None, - *, - refresh_skew_seconds: int = 120, - refresh_timeout_seconds: float = 15.0, - refresher: Callable[[str], OpenAICodexOAuthTokens] | None = None, -) -> Callable[[str], str | None]: - """Build a provider-scoped OAuth token resolver with refresh support.""" - - lock = threading.Lock() - if refresher is None: - refresher = lambda refresh_token: refresh_openai_codex_oauth_tokens( - refresh_token, - timeout_seconds=refresh_timeout_seconds, - ) - - def _resolve(provider: str) -> str | None: - if provider != CODEX_PROVIDER: - return None - with lock: - tokens = load_openai_codex_oauth_tokens(codex_home) - if tokens is None: - return None - - now = int(time.time()) - # Unknown expiry is not evidence that the token needs refreshing. - if tokens.expires_at is None or tokens.expires_at > now + refresh_skew_seconds: - return tokens.access_token - +@app.command("codex") +def login_codex( + executable: Annotated[str, typer.Option("--executable", help="Codex CLI executable")] = "codex", + device_auth: Annotated[bool, typer.Option("--device-auth", help="Use device authorization")] = False, + codex_home: Annotated[Path | None, typer.Option("--codex-home", help="Codex credential directory")] = None, +) -> None: + """Log in with the Codex CLI using file credential storage.""" + try: + if codex_home is None: + auth = asyncio.run(CodexAuth.login(executable=executable, device_auth=device_auth)) + else: + home = codex_home.expanduser().resolve() + command = [executable, "login", "--config", 'cli_auth_credentials_store="file"'] + if device_auth: + command.append("--device-auth") try: - refreshed = refresher(tokens.refresh_token) - except Exception as exc: - logger.warning("Codex OAuth token refresh failed: {}", type(exc).__name__) - if tokens.expires_at > time.time(): - return tokens.access_token - raise CodexOAuthRefreshError( - f"Codex OAuth token refresh failed ({type(exc).__name__}). " - "Check your connection or sign in again with `bub login openai`." - ) from None - - persisted = OpenAICodexOAuthTokens( - access_token=refreshed.access_token, - refresh_token=refreshed.refresh_token, - expires_at=refreshed.expires_at, - account_id=refreshed.account_id or tokens.account_id, - ) - save_openai_codex_oauth_tokens(persisted, codex_home) - return persisted.access_token - - return _resolve - - -def login_openai_codex_oauth( - *, - codex_home: str | Path | None = None, - prompt_for_redirect: Callable[[str], str] | None = None, - open_browser: bool = True, - browser_opener: Callable[[str], Any] | None = None, - redirect_uri: str = DEFAULT_CODEX_REDIRECT_URI, - timeout_seconds: float = 300.0, - client_id: str = _CODEX_OAUTH_CLIENT_ID, - authorize_url: str = _CODEX_OAUTH_AUTHORIZE_URL, - token_url: str = _CODEX_OAUTH_TOKEN_URL, - scope: str = _CODEX_OAUTH_SCOPE, - originator: str = _CODEX_OAUTH_ORIGINATOR, -) -> OpenAICodexOAuthTokens: - """Run the OpenAI Codex OAuth flow and persist tokens.""" - - verifier = _build_pkce_verifier() - state = secrets.token_hex(16) - oauth_url = _build_authorize_url( - client_id=client_id, - redirect_uri=redirect_uri, - code_challenge=verifier, - state=state, - authorize_url=authorize_url, - scope=scope, - originator=originator, - ) - - if open_browser: - opener = browser_opener or webbrowser.open - opener(oauth_url) - - if prompt_for_redirect is None: - callback_values = _wait_for_local_oauth_callback(redirect_uri=redirect_uri, timeout_seconds=timeout_seconds) - if callback_values is None: - raise CodexOAuthLoginError( - "Did not receive OAuth callback. " - f"redirect_uri={redirect_uri!r}, timeout_seconds={timeout_seconds}. " - "Try increasing --timeout or use --manual." - ) - code, returned_state = callback_values - else: - code, returned_state = _extract_code_and_state(prompt_for_redirect(oauth_url)) - - if returned_state and returned_state != state: - raise CodexOAuthStateMismatchError - if not isinstance(code, str) or not code.strip(): - raise CodexOAuthMissingCodeError - - tokens = _exchange_openai_codex_authorization_code( - code=code.strip(), - verifier=verifier, - redirect_uri=redirect_uri, - timeout_seconds=timeout_seconds, - client_id=client_id, - token_url=token_url, - ) - save_openai_codex_oauth_tokens(tokens, codex_home) - return tokens - - -def _prompt_for_codex_redirect(authorize_url: str) -> str: - typer.echo("Open this URL in your browser and complete the Codex sign-in flow:\n") - typer.echo(authorize_url) - typer.echo("\nPaste the full callback URL or the authorization code.") - return str(typer.prompt("callback")).strip() - - -def _render_codex_login_result(tokens: OpenAICodexOAuthTokens, auth_path: Path) -> None: + subprocess.run(command, env={**os.environ, "CODEX_HOME": str(home)}, check=True) + except OSError: + raise AuthenticationError(f"Cannot start {executable}; install the Codex CLI.") from None + except subprocess.CalledProcessError as exc: + raise AuthenticationError(f"Codex login failed (exit {exc.returncode})") from None + auth = CodexAuth.from_file(home / "auth.json") + except AuthenticationError as exc: + typer.secho(f"login: failed: {exc}", fg="red", err=True) + raise typer.Exit(1) from None typer.echo("login: ok") - typer.echo(f"account_id: {tokens.account_id or '-'}") - typer.echo(f"auth_file: {auth_path}") - typer.echo("usage: set BUB_MODEL=openai: and omit BUB_API_KEY") - - -@app.command() -def openai( - codex_home: Path | None = typer.Option(None, "--codex-home", help="Directory to store Codex OAuth credentials"), - open_browser: bool = typer.Option(True, "--browser/--no-browser", help="Open the OAuth URL in a browser"), - manual: bool = typer.Option( - False, - "--manual", - help="Paste the callback URL or code instead of waiting for a local callback server", - ), - timeout_seconds: float = typer.Option(300.0, "--timeout", help="OAuth wait timeout in seconds"), -) -> None: - """Login with OpenAI OAuth.""" - - resolved_codex_home = resolve_codex_home(codex_home) - prompt_for_redirect = _prompt_for_codex_redirect if manual or not open_browser else None - - try: - tokens = login_openai_codex_oauth( - codex_home=resolved_codex_home, - prompt_for_redirect=prompt_for_redirect, - open_browser=open_browser, - redirect_uri=DEFAULT_CODEX_REDIRECT_URI, - timeout_seconds=timeout_seconds, - ) - except CodexOAuthLoginError as exc: - typer.echo(f"Codex login failed: {exc}", err=True) - raise typer.Exit(1) from exc - - _render_codex_login_result(tokens, resolved_codex_home / "auth.json") - - -def extract_openai_codex_account_id(access_token: str) -> str | None: - payload = _decode_unverified_jwt_payload(access_token) - if payload is None: - return None - auth = payload.get("https://api.openai.com/auth") - if not isinstance(auth, dict): - return None - account_id = auth.get("chatgpt_account_id") - if not isinstance(account_id, str): - return None - return account_id.strip() or None - - -def _decode_unverified_jwt_payload(access_token: str) -> dict[str, Any] | None: - """Read local JWT claims without treating them as verified credentials.""" - - parts = access_token.split(".") - if len(parts) != 3: - return None - payload_segment = parts[1] - padding = "=" * (-len(payload_segment) % 4) - try: - payload = json.loads(urlsafe_b64decode((payload_segment + padding).encode("ascii")).decode("utf-8")) - except (UnicodeError, ValueError): - return None - return payload if isinstance(payload, dict) else None - - -def _jwt_expiry(access_token: str) -> int | None: - payload = _decode_unverified_jwt_payload(access_token) - if payload is None: - return None - exp = payload.get("exp") - if type(exp) is int: - return exp - if type(exp) is float and math.isfinite(exp): - return int(exp) - return None - - -def _parse_tokens(payload: dict[str, Any]) -> OpenAICodexOAuthTokens | None: - tokens = payload.get("tokens") - if not isinstance(tokens, dict): - return None - - access_token = tokens.get("access_token") - refresh_token = tokens.get("refresh_token") - if not isinstance(access_token, str) or not isinstance(refresh_token, str): - return None - access = access_token.strip() - refresh = refresh_token.strip() - if not access or not refresh: - return None - - expires_at = _parse_expiry(tokens.get("expires_at"), access) - account_id = tokens.get("account_id") - return OpenAICodexOAuthTokens( - access_token=access, - refresh_token=refresh, - expires_at=expires_at, - account_id=account_id if isinstance(account_id, str) else None, - ) - - -def _parse_expiry(expires_raw: object, access_token: str) -> int | None: - try: - if isinstance(expires_raw, str): - if (expires_at := _rfc3339_to_unix(expires_raw)) is not None: - return expires_at - elif isinstance(expires_raw, int | float) and not isinstance(expires_raw, bool): - return int(expires_raw) - except (ValueError, OverflowError): - pass - return _jwt_expiry(access_token) - - -def _tokens_from_token_payload(payload: dict[str, Any], *, account_id: str | None) -> OpenAICodexOAuthTokens: - access_token = payload.get("access_token") - refresh_token = payload.get("refresh_token") - expires_in = payload.get("expires_in") - if not isinstance(access_token, str) or not isinstance(refresh_token, str): - raise CodexOAuthResponseError - if not isinstance(expires_in, int | float): - raise CodexOAuthResponseError - - access = access_token.strip() - return OpenAICodexOAuthTokens( - access_token=access, - refresh_token=refresh_token.strip(), - expires_at=int(time.time() + float(expires_in)), - account_id=account_id or extract_openai_codex_account_id(access), - ) - - -def _exchange_openai_codex_authorization_code( - code: str, - *, - verifier: str, - redirect_uri: str, - timeout_seconds: float, - client_id: str, - token_url: str, -) -> OpenAICodexOAuthTokens: - with OAuth2Client( - client_id=client_id, - redirect_uri=redirect_uri, - code_challenge_method="S256", - timeout=timeout_seconds, - ) as oauth: - payload = oauth.fetch_token( - url=token_url, - grant_type="authorization_code", - code=code, - code_verifier=verifier, - ) - return _tokens_from_token_payload( - payload, - account_id=extract_openai_codex_account_id(str(payload.get("access_token", ""))), - ) - - -def _build_pkce_verifier() -> str: - return urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=") - - -def _build_authorize_url( - *, - client_id: str, - redirect_uri: str, - code_challenge: str, - state: str, - authorize_url: str, - scope: str, - originator: str, -) -> str: - with OAuth2Client( - client_id=client_id, - redirect_uri=redirect_uri, - scope=scope, - code_challenge_method="S256", - trust_env=True, - ) as oauth: - url, _ = oauth.create_authorization_url( - authorize_url, - state=state, - code_verifier=code_challenge, - id_token_add_organizations="true", # noqa: S106 - codex_cli_simplified_flow="true", - originator=originator, - ) - return str(url) - - -def _extract_code_and_state(input_value: str) -> tuple[str | None, str | None]: - raw = input_value.strip() - if not raw: - return None, None - - parsed = urllib.parse.urlsplit(raw) - query = urllib.parse.parse_qs(parsed.query) - code = query.get("code", [None])[0] - state = query.get("state", [None])[0] - if isinstance(code, str) or isinstance(state, str): - return code if isinstance(code, str) else None, state if isinstance(state, str) else None - - if "code=" in raw: - parsed_query = urllib.parse.parse_qs(raw) - code = parsed_query.get("code", [None])[0] - state = parsed_query.get("state", [None])[0] - return code if isinstance(code, str) else None, state if isinstance(state, str) else None - - return raw, None - - -def _wait_for_local_oauth_callback( - *, redirect_uri: str, timeout_seconds: float -) -> tuple[str | None, str | None] | None: - parsed_redirect = urllib.parse.urlsplit(redirect_uri) - if parsed_redirect.scheme != "http" or (parsed_redirect.hostname or "").lower() not in {"127.0.0.1", "localhost"}: - return None - if parsed_redirect.port is None: - return None - - path = parsed_redirect.path or "/" - state: dict[str, str | None] = {"code": None, "state": None} - done = threading.Event() - lock = threading.Lock() - - class Handler(BaseHTTPRequestHandler): - def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - return - - def do_GET(self) -> None: - parsed = urllib.parse.urlsplit(self.path) - if parsed.path != path: - self.send_response(404) - self.end_headers() - return - - query = urllib.parse.parse_qs(parsed.query) - with lock: - code = query.get("code", [None])[0] - returned_state = query.get("state", [None])[0] - state["code"] = code if isinstance(code, str) else None - state["state"] = returned_state if isinstance(returned_state, str) else None - done.set() - - body = ( - b"

Authentication successful. Return to your terminal.

" - ) - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - try: - server = ThreadingHTTPServer((parsed_redirect.hostname or "localhost", parsed_redirect.port), Handler) - except OSError: - return None - - server.timeout = 0.2 - deadline = time.monotonic() + timeout_seconds - try: - while not done.is_set() and time.monotonic() < deadline: - server.handle_request() - finally: - server.server_close() - - if not done.is_set(): - return None - with lock: - return state["code"], state["state"] - - -def _unix_to_rfc3339(timestamp: int) -> str: - return datetime.fromtimestamp(timestamp, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def _rfc3339_to_unix(value: str) -> int | None: - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - return int(parsed.timestamp()) if parsed.tzinfo is not None else None - except (ValueError, OverflowError, OSError): - return None + typer.echo(f"account_id: {auth.account_id}") + typer.echo("Use BUB_MODEL=codex: for ChatGPT plan access.") diff --git a/src/bub/builtin/codex_provider.py b/src/bub/builtin/codex_provider.py deleted file mode 100644 index e6569d03..00000000 --- a/src/bub/builtin/codex_provider.py +++ /dev/null @@ -1,521 +0,0 @@ -"""OpenAI Codex OAuth provider for any-llm Responses calls.""" - -from __future__ import annotations - -import time -from collections.abc import AsyncIterator, Mapping, Sequence -from dataclasses import dataclass -from typing import Any, cast, override - -from any_llm.exceptions import MissingApiKeyError -from any_llm.providers.openai.base import BaseOpenAIProvider -from any_llm.types.completion import ChatCompletion, ChatCompletionChunk, CompletionParams -from any_llm.types.model import Model -from any_llm.types.responses import Response, ResponsesParams - -from bub.builtin.auth import ( - extract_openai_codex_account_id, - load_openai_codex_oauth_tokens, - openai_codex_oauth_resolver, -) - -DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" -DEFAULT_CODEX_ORIGINATOR = "bub" -DEFAULT_CODEX_INCLUDE = ["reasoning.encrypted_content"] -DEFAULT_CODEX_INSTRUCTIONS = "You are Codex." -DEFAULT_CODEX_TEXT_CONFIG = {"verbosity": "medium"} - - -class OpenAICodexTransportError(RuntimeError): - def __init__(self, status_code: int | None, message: str, body: str | None = None) -> None: - super().__init__(message) - self.status_code = status_code - self.body = body - - -class OpenaiCodexProvider(BaseOpenAIProvider): - """OpenAI-compatible Responses provider backed by Codex OAuth credentials.""" - - API_BASE = DEFAULT_CODEX_BASE_URL - ENV_API_KEY_NAME = "OPENAI_CODEX_API_KEY" - ENV_API_BASE_NAME = "OPENAI_CODEX_API_BASE" - PROVIDER_NAME = "openaicodex" - PROVIDER_DOCUMENTATION_URL = "https://platform.openai.com/docs/codex" - - SUPPORTS_COMPLETION_STREAMING = True - SUPPORTS_COMPLETION = True - SUPPORTS_COMPLETION_REASONING = True - SUPPORTS_RESPONSES = True - SUPPORTS_LIST_MODELS = False - SUPPORTS_BATCH = False - SUPPORTS_IMAGE_GENERATION = False - SUPPORTS_AUDIO_TRANSCRIPTION = False - SUPPORTS_AUDIO_SPEECH = False - SUPPORTS_EMBEDDING = False - SUPPORTS_MODERATION = False - - def __init__( - self, - *, - api_key: str | None = None, - api_base: str | None = None, - codex_home: str | None = None, - default_instructions: str = DEFAULT_CODEX_INSTRUCTIONS, - default_include: Sequence[str] = tuple(DEFAULT_CODEX_INCLUDE), - default_text: dict[str, Any] | None = None, - originator: str = DEFAULT_CODEX_ORIGINATOR, - store: bool = False, - **kwargs: Any, - ) -> None: - self._codex_home = codex_home - self._default_instructions = default_instructions - self._default_include = list(default_include) - self._default_text = dict(default_text or DEFAULT_CODEX_TEXT_CONFIG) - self._originator = originator - self._store = store - super().__init__(api_key=api_key, api_base=api_base, **kwargs) - - @override - def _verify_and_set_api_key(self, api_key: str | None = None) -> str | None: - if api_key: - return api_key - resolved = openai_codex_oauth_resolver(self._codex_home)("openai") - if not resolved: - raise MissingApiKeyError(self.PROVIDER_NAME, self.ENV_API_KEY_NAME) - return resolved - - @override - def _init_client(self, api_key: str | None = None, api_base: str | None = None, **kwargs: Any) -> None: - default_headers = dict(kwargs.pop("default_headers", {})) - default_headers.update(build_openai_codex_default_headers(api_key or "", originator=self._originator)) - super()._init_client( - api_key=api_key, - api_base=resolve_openai_codex_api_base(api_base), - default_headers=default_headers, - **kwargs, - ) - - @staticmethod - @override - def _convert_list_models_response(response: Any) -> Sequence[Model]: - raise NotImplementedError("OpenAI Codex OAuth provider does not support listing models.") - - @override - async def _acompletion( - self, - params: CompletionParams, - **kwargs: Any, - ) -> ChatCompletion | AsyncIterator[ChatCompletionChunk]: - responses_params = self._completion_params_to_responses_params(params) - if params.stream: - response = await self._aresponses(responses_params.model_copy(update={"stream": True})) - if not hasattr(response, "__aiter__"): - raise OpenAICodexTransportError(None, "OpenAI Codex Responses API returned a non-streaming result.") - return self._response_stream_to_completion_chunks( - cast("AsyncIterator[Any]", response), - model=params.model_id, - ) - - response = await self._aresponses(responses_params.model_copy(update={"stream": False})) - return self._response_to_completion(cast("Response", response), model=params.model_id) - - @override - async def _aresponses(self, params: ResponsesParams, **kwargs: Any) -> Any: - return await super()._aresponses(self._with_codex_response_defaults(params), **kwargs) - - def _completion_params_to_responses_params(self, params: CompletionParams) -> ResponsesParams: - reasoning = None - if params.reasoning_effort not in {None, "auto"}: - reasoning = {"effort": params.reasoning_effort} - - return ResponsesParams( - model=params.model_id, - input=cast("Any", _completion_messages_to_responses_input(params.messages)), - tools=self._completion_tools_to_response_tools(cast("Sequence[Any] | None", params.tools)), - tool_choice=self._completion_tool_choice_to_response_tool_choice(params.tool_choice), - response_format=params.response_format, - stream=params.stream, - parallel_tool_calls=params.parallel_tool_calls, - reasoning=reasoning, - ) - - @staticmethod - def _completion_tools_to_response_tools(tools: Sequence[Any] | None) -> list[dict[str, Any]] | None: - if not tools: - return None - response_tools: list[dict[str, Any]] = [] - for tool in tools: - payload = tool.model_dump(exclude_none=True) if hasattr(tool, "model_dump") else dict(tool) - function = payload.get("function") - if payload.get("type") == "function" and isinstance(function, dict): - response_tools.append({ - "type": "function", - "name": function.get("name", ""), - "description": function.get("description", ""), - "parameters": function.get("parameters", {}), - }) - else: - response_tools.append(payload) - return response_tools - - @staticmethod - def _completion_tool_choice_to_response_tool_choice(tool_choice: str | dict[str, Any] | None) -> Any: - if not isinstance(tool_choice, dict): - return tool_choice - function = tool_choice.get("function") - if tool_choice.get("type") == "function" and isinstance(function, dict): - return {"type": "function", "name": function.get("name", "")} - return tool_choice - - async def _response_stream_to_completion_chunks( - self, - events: AsyncIterator[Any], - *, - model: str, - ) -> AsyncIterator[ChatCompletionChunk]: - mapper = CodexCompletionChunkMapper(model=model) - async for event in events: - for chunk in mapper.map_event(event): - yield chunk - - @staticmethod - def _response_to_completion(response: Response, *, model: str) -> ChatCompletion: - text_parts: list[str] = [] - tool_calls: list[dict[str, Any]] = [] - for item in getattr(response, "output", []) or []: - if getattr(item, "type", None) == "message": - for content in getattr(item, "content", []) or []: - text = getattr(content, "text", None) - if isinstance(text, str): - text_parts.append(text) - if getattr(item, "type", None) in {"function_call", "custom_tool_call"}: - tool_calls.append({ - "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{len(tool_calls)}", - "type": "function", - "function": { - "name": getattr(item, "name", "") or "", - "arguments": _tool_item_arguments(item) or "{}", - }, - }) - - usage = _completion_usage_from_response_usage(getattr(response, "usage", None)) - return ChatCompletion.model_validate({ - "id": getattr(response, "id", None) or "chatcmpl_codex", - "object": "chat.completion", - "created": int(getattr(response, "created_at", None) or time.time()), - "model": getattr(response, "model", None) or model, - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls" if tool_calls else "stop", - "message": { - "role": "assistant", - "content": "".join(text_parts) or None, - "tool_calls": tool_calls or None, - }, - } - ], - "usage": usage, - }) - - def _with_codex_response_defaults(self, params: ResponsesParams) -> ResponsesParams: - update: dict[str, Any] = {} - if params.store is None: - update["store"] = self._store - if params.instructions is None: - update["instructions"] = self._default_instructions - if params.include is None: - update["include"] = list(self._default_include) - if isinstance(params.text, dict): - update["text"] = {**self._default_text, **params.text} - elif params.text is None: - update["text"] = dict(self._default_text) - return params.model_copy(update=update) - - -@dataclass -class _CodexToolState: - started: bool = False - arguments_seen: bool = False - - -class CodexCompletionChunkMapper: - def __init__(self, *, model: str) -> None: - self.model = model - self.created = int(time.time()) - self._tool_states: dict[int, _CodexToolState] = {} - self._tool_indexes_by_identifier: dict[str, int] = {} - - def map_event(self, event: Any) -> list[ChatCompletionChunk]: - event_type = getattr(event, "type", None) - if event_type == "response.output_text.delta": - return [self._delta_chunk({"content": _string_attr(event, "delta")})] - if event_type in {"response.reasoning_text.delta", "response.reasoning_summary_text.delta"}: - return [self._delta_chunk({"reasoning": _string_attr(event, "delta")})] - if event_type == "response.output_item.added": - return self._tool_item_chunks(event) - if event_type in {"response.function_call_arguments.delta", "response.custom_tool_call_input.delta"}: - return self._tool_arguments_delta_chunks(event) - if event_type == "response.function_call_arguments.done": - return self._tool_arguments_done_chunks(event) - if event_type == "response.output_item.done": - return self._tool_item_chunks(event, done=True) - if event_type == "response.completed": - response = getattr(event, "response", None) - return [self._terminal_chunk(response=response)] - if event_type in {"response.failed", "response.incomplete"}: - raise OpenAICodexTransportError(None, f"OpenAI Codex response ended with {event_type}") - if event_type == "error": - message = getattr(event, "message", None) or "OpenAI Codex response stream error" - raise OpenAICodexTransportError(None, str(message)) - return [] - - def _tool_item_chunks(self, event: Any, *, done: bool = False) -> list[ChatCompletionChunk]: - output_index = getattr(event, "output_index", None) - item = getattr(event, "item", None) - if getattr(item, "type", None) not in {"function_call", "custom_tool_call"} or not isinstance( - output_index, int - ): - return [] - - self._remember_tool_identifiers(item, output_index) - state = self._tool_states.setdefault(output_index, _CodexToolState()) - if done and state.started and state.arguments_seen: - return [] - - tool_delta: dict[str, Any] = { - "index": output_index, - "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{output_index}", - "type": "function", - "function": { - "name": getattr(item, "name", None) or "", - "arguments": "" if state.arguments_seen else _tool_item_arguments(item), - }, - } - state.started = True - if tool_delta["function"]["arguments"]: - state.arguments_seen = True - return [self._delta_chunk({"tool_calls": [tool_delta]})] - - def _tool_arguments_delta_chunks(self, event: Any) -> list[ChatCompletionChunk]: - output_index = self._tool_index_for_event(event) - if output_index is None: - return [] - delta = _string_attr(event, "delta") - if not delta: - return [] - state = self._tool_states.setdefault(output_index, _CodexToolState()) - state.arguments_seen = True - return [self._delta_chunk({"tool_calls": [{"index": output_index, "function": {"arguments": delta}}]})] - - def _tool_arguments_done_chunks(self, event: Any) -> list[ChatCompletionChunk]: - output_index = self._tool_index_for_event(event) - if output_index is None: - return [] - state = self._tool_states.setdefault(output_index, _CodexToolState()) - if state.arguments_seen: - return [] - arguments = _string_attr(event, "arguments") - if not arguments: - return [] - state.arguments_seen = True - name = _string_attr(event, "name") - function: dict[str, str] = {"arguments": arguments} - if name: - function["name"] = name - return [self._delta_chunk({"tool_calls": [{"index": output_index, "function": function}]})] - - def _tool_index_for_event(self, event: Any) -> int | None: - output_index = getattr(event, "output_index", None) - if isinstance(output_index, int): - return output_index - for attr in ("item_id", "call_id"): - identifier = getattr(event, attr, None) - if isinstance(identifier, str): - known_index = self._tool_indexes_by_identifier.get(identifier) - if known_index is not None: - return known_index - return None - - def _remember_tool_identifiers(self, item: Any, output_index: int) -> None: - for attr in ("id", "call_id"): - identifier = getattr(item, attr, None) - if isinstance(identifier, str) and identifier: - self._tool_indexes_by_identifier[identifier] = output_index - - def _delta_chunk(self, delta: dict[str, Any]) -> ChatCompletionChunk: - return ChatCompletionChunk.model_validate({ - "id": "chatcmpl_codex", - "object": "chat.completion.chunk", - "created": self.created, - "model": self.model, - "choices": [{"index": 0, "delta": delta, "finish_reason": None}], - }) - - def _terminal_chunk(self, *, response: Any) -> ChatCompletionChunk: - usage = _completion_usage_from_response_usage(getattr(response, "usage", None)) - finish_reason = "tool_calls" if self._tool_states else "stop" - return ChatCompletionChunk.model_validate({ - "id": getattr(response, "id", None) or "chatcmpl_codex", - "object": "chat.completion.chunk", - "created": int(getattr(response, "created_at", None) or self.created), - "model": getattr(response, "model", None) or self.model, - "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], - "usage": usage, - }) - - -def _completion_usage_from_response_usage(usage: Any) -> dict[str, Any]: - payload = usage.model_dump(exclude_none=True) if hasattr(usage, "model_dump") else usage - if not isinstance(payload, dict): - payload = { - "input_tokens": getattr(usage, "input_tokens", 0), - "output_tokens": getattr(usage, "output_tokens", 0), - "total_tokens": getattr(usage, "total_tokens", 0), - "input_tokens_details": getattr(usage, "input_tokens_details", None), - } - prompt_tokens = int(payload.get("input_tokens") or payload.get("prompt_tokens") or 0) - completion_tokens = int(payload.get("output_tokens") or payload.get("completion_tokens") or 0) - total_tokens = int(payload.get("total_tokens") or prompt_tokens + completion_tokens) - completion_usage: dict[str, Any] = { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, - } - input_details = _mapping_from_value(payload.get("input_tokens_details")) - cached_tokens = input_details.get("cached_tokens") - if isinstance(cached_tokens, int) and not isinstance(cached_tokens, bool): - completion_usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens} - return completion_usage - - -def _string_attr(obj: Any, name: str) -> str: - value = getattr(obj, name, None) - return value if isinstance(value, str) else "" - - -def _tool_item_arguments(item: Any) -> str: - return _string_attr(item, "arguments") or _string_attr(item, "input") - - -def _completion_messages_to_responses_input(messages: Sequence[Any]) -> list[dict[str, Any]]: - response_input: list[dict[str, Any]] = [] - for message in messages: - payload = _mapping_from_value(message) - if not payload: - continue - - role = payload.get("role") - if role == "tool": - tool_result = _completion_tool_result_to_response_item(payload) - if tool_result is not None: - response_input.append(tool_result) - continue - - content = _completion_content_to_responses_content(payload.get("content"), role=role) - tool_calls = payload.get("tool_calls") - if isinstance(tool_calls, Sequence) and not isinstance(tool_calls, str): - if content: - response_input.append({"role": "assistant", "content": content}) - response_input.extend(_completion_tool_calls_to_response_items(tool_calls)) - continue - - if isinstance(role, str): - response_input.append({"role": role, "content": content or ""}) - return response_input - - -def _completion_content_to_responses_content(content: Any, *, role: str | None) -> Any: - """Translate Chat Completions parts without changing strings or Responses parts.""" - if not isinstance(content, list): - return content - parts: list[Any] = [] - for value in content: - part = _mapping_from_value(value) - match part.get("type"): - case "text": - parts.append({**part, "type": "output_text" if role == "assistant" else "input_text"}) - case "image_url": - image = _mapping_from_value(part.get("image_url")) - parts.append({ - "type": "input_image", - "image_url": image.get("url"), - "detail": image.get("detail", "auto"), - }) - case "file": - parts.append({"type": "input_file", **_mapping_from_value(part.get("file"))}) - case _: - parts.append(value) - return parts - - -def _completion_tool_calls_to_response_items(tool_calls: Sequence[Any]) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - for tool_call in tool_calls: - payload = _mapping_from_value(tool_call) - function = _mapping_from_value(payload.get("function")) - call_id = payload.get("id") - name = function.get("name") - if not isinstance(call_id, str) or not isinstance(name, str): - continue - arguments = function.get("arguments") - items.append({ - "type": "function_call", - "call_id": call_id, - "name": name, - "arguments": arguments if isinstance(arguments, str) else "{}", - "status": "completed", - }) - return items - - -def _completion_tool_result_to_response_item(message: Mapping[str, Any]) -> dict[str, Any] | None: - call_id = message.get("tool_call_id") - if not isinstance(call_id, str) or not call_id: - return None - content = message.get("content") - return { - "type": "function_call_output", - "call_id": call_id, - "output": content if isinstance(content, str) else "", - } - - -def _mapping_from_value(value: Any) -> Mapping[str, Any]: - if isinstance(value, Mapping): - return value - if hasattr(value, "model_dump"): - dumped = value.model_dump(exclude_none=True) - if isinstance(dumped, Mapping): - return dumped - return {} - - -def should_use_openai_codex_provider( - provider: str, model_id: str, *, api_key: str | None, api_base: str | None -) -> bool: - if provider != "openai" or api_base: - return False - if api_key: - return extract_openai_codex_account_id(api_key) is not None - return load_openai_codex_oauth_tokens() is not None - - -def resolve_openai_codex_api_base(api_base: str | None) -> str: - raw = (api_base or DEFAULT_CODEX_BASE_URL).rstrip("/") - if raw.endswith("/responses"): - raw = raw[: -len("/responses")] - if raw.endswith("/codex"): - return raw - return f"{raw}/codex" - - -def build_openai_codex_default_headers(api_key: str, *, originator: str = DEFAULT_CODEX_ORIGINATOR) -> dict[str, str]: - account_id = extract_openai_codex_account_id(api_key) - if account_id is None: - raise OpenAICodexTransportError(None, "OpenAI Codex OAuth token is missing chatgpt_account_id") - return { - "chatgpt-account-id": account_id, - "OpenAI-Beta": "responses=experimental", - "originator": originator, - } diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py index 1476bc06..286b5dcd 100644 --- a/src/bub/builtin/context.py +++ b/src/bub/builtin/context.py @@ -49,7 +49,17 @@ def _append_message_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> N def _append_tool_call_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> list[dict[str, Any]]: calls = _normalize_tool_calls(entry.payload.get("calls")) if calls: - messages.append({"role": "assistant", "content": entry.payload.get("content") or "", "tool_calls": calls}) + fields = { + key: entry.payload[key] + for key in ("reasoning", "provider_data", "source_provider", "source_model") + if key in entry.payload + } + messages.append({ + "role": "assistant", + "content": entry.payload.get("content") or "", + "tool_calls": calls, + **fields, + }) return calls @@ -79,11 +89,9 @@ def _build_tool_result_message( if isinstance(call_id, str) and call_id: message["tool_call_id"] = call_id - function = call.get("function") - if isinstance(function, dict): - name = function.get("name") - if isinstance(name, str) and name: - message["name"] = name + name = call.get("name") + if isinstance(name, str) and name: + message["name"] = name return message diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 89c2b1f7..2e576a6d 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -3,7 +3,7 @@ from datetime import datetime from difflib import get_close_matches from pathlib import Path -from typing import Any, cast +from typing import cast import typer from loguru import logger @@ -18,7 +18,7 @@ from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.base import Channel from bub.channels.contracts import MessageHandler -from bub.channels.message import ChannelMessage, MediaItem, audio_format_from_mime_type +from bub.channels.message import ChannelMessage, MediaItem from bub.envelope import Envelope, content_of, field_of from bub.environment import Environment from bub.errors import BubError @@ -57,16 +57,6 @@ """ -def _input_audio_part(data_url: str, mime_type: str) -> dict[str, Any] | None: - prefix, separator, data = data_url.partition("base64,") - if not separator or not prefix.startswith("data:audio/") or not data: - return None - return { - "type": "input_audio", - "input_audio": {"data": data, "format": audio_format_from_mime_type(mime_type)}, - } - - class BuiltinImpl: """Default hook implementations for basic runtime operations.""" @@ -160,19 +150,8 @@ async def build_prompt(self, message: ChannelMessage, session_id: str, state: Tu media_parts: list[dict] = [] for item in cast("list[MediaItem]", media): - match item.type: - case "image" | "video": - data_url = await item.get_url() - if not data_url: - continue - part_type = f"{item.type}_url" - media_parts.append({"type": part_type, part_type: {"url": data_url}}) - case "audio": - data_url = await item.get_url() - if data_url and (audio_part := _input_audio_part(data_url, item.mime_type)): - media_parts.append(audio_part) - case _: - pass + if item.type in {"image", "audio", "video"} and (url := await item.get_url()): + media_parts.append({"type": item.type, "media_type": item.mime_type, "url": url}) if media_parts: return [{"type": "text", "text": text}, *media_parts] return text diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index aae88010..cb0607f5 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -1,37 +1,23 @@ -"""LLM completion and model-output helpers for the builtin agent.""" +"""Republic requests and model-output helpers for the builtin agent.""" from __future__ import annotations import asyncio -import inspect import re -from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Iterator -from contextlib import aclosing -from dataclasses import dataclass +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import aclosing, asynccontextmanager +from dataclasses import asdict, dataclass, field, replace from datetime import UTC, datetime from time import monotonic -from typing import Any, Literal, cast - -from any_llm import AnyLLM -from any_llm.constants import LLMProvider -from any_llm.providers.anthropic.base import BaseAnthropicProvider -from any_llm.providers.openai.base import BaseOpenAIProvider -from any_llm.types.completion import ( - ChatCompletion, - ChatCompletionChunk, - ChatCompletionMessage, - ChatCompletionMessageFunctionToolCall, - ChatCompletionMessageToolCall, - ChoiceDeltaToolCall, - Function, - ParsedChatCompletion, -) +from typing import Any, cast + +import republic from loguru import logger from pydantic import TypeAdapter, ValidationError +from republic import ChatOptions +from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady -from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider -from bub.builtin.settings import AgentSettings, ModelCandidate -from bub.channels.message import audio_mime_type_from_format +from bub.builtin.settings import AgentSettings, ModelCandidate, provider_extras from bub.errors import BubError, ErrorKind from bub.hooks.interception import ( AgentHooks, @@ -41,7 +27,7 @@ ) from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.tape import Tape -from bub.tools import Tool, ToolContext, ToolExecutor +from bub.tools import Tool, ToolContext, ToolExecutor, render_result from bub.tracing import Span, current_span, event CONTEXT_LENGTH_PATTERNS = re.compile( @@ -49,59 +35,6 @@ re.IGNORECASE, ) TOOL_ARGUMENTS_ADAPTER = TypeAdapter(dict[str, Any]) -CompletionResult = ChatCompletion | ParsedChatCompletion[Any] | AsyncIterator[ChatCompletionChunk] -GOOGLE_FILE_CONTENT_PROVIDERS = frozenset({LLMProvider.GEMINI, LLMProvider.VERTEXAI}) - - -def _extra_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any]: - """Return provider-specific extra completion options.""" - if isinstance(llm, BaseAnthropicProvider): - return {"cache_control": {"type": "ephemeral"}} - elif stream and isinstance(llm, BaseOpenAIProvider): - return {"stream_options": {"include_usage": True}} - return {} - - -def _adapt_messages_for_provider(messages: list[dict[str, Any]], provider: LLMProvider) -> list[dict[str, Any]]: - """Translate canonical multimodal blocks when a provider uses a different wire format.""" - if provider not in GOOGLE_FILE_CONTENT_PROVIDERS: - return messages - - adapted_messages: list[dict[str, Any]] = [] - for message in messages: - content = message.get("content") - if not isinstance(content, list): - adapted_messages.append(message) - continue - - adapted_content: list[Any] = [] - changed = False - for part in content: - if not isinstance(part, dict): - adapted_content.append(part) - continue - - if part.get("type") == "video_url": - video_url = part.get("video_url") - url = video_url.get("url") if isinstance(video_url, dict) else None - if isinstance(url, str) and url: - adapted_content.append({"type": "file", "file": {"file_data": url}}) - changed = True - continue - elif part.get("type") == "input_audio": - input_audio = part.get("input_audio") - data = input_audio.get("data") if isinstance(input_audio, dict) else None - audio_format = input_audio.get("format") if isinstance(input_audio, dict) else None - if isinstance(data, str) and data and isinstance(audio_format, str) and audio_format: - mime_type = audio_mime_type_from_format(audio_format) - file_data = f"data:{mime_type};base64,{data}" - adapted_content.append({"type": "file", "file": {"file_data": file_data}}) - changed = True - continue - adapted_content.append(part) - - adapted_messages.append({**message, "content": adapted_content} if changed else message) - return adapted_messages class ModelRunner: @@ -109,26 +42,8 @@ def __init__(self, settings: AgentSettings, hooks: AgentHooks | None = None) -> self.settings = settings self.hooks = hooks - def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, AnyLLM]]: - for candidate in self.settings.model_candidates(model): - client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider) - yield ( - candidate, - self.create_llm_client(candidate, client_kwargs), - ) - - @staticmethod - def create_llm_client(candidate: ModelCandidate, client_kwargs: dict[str, Any]) -> AnyLLM: - if candidate.provider == LLMProvider.OPENAI and should_use_openai_codex_provider( - candidate.provider.value, - candidate.model_id, - api_key=client_kwargs.get("api_key"), - api_base=client_kwargs.get("api_base"), - ): - return OpenaiCodexProvider(**client_kwargs) - return AnyLLM.create(candidate.provider, **client_kwargs) - - async def completion_response( + @asynccontextmanager + async def _completion( self, *, model: str, @@ -136,42 +51,71 @@ async def completion_response( tools: list[Tool], max_tokens: int | None = None, reasoning_effort: str | None = None, - ) -> CompletionResult: - tool_payloads = [tool.to_schema() for tool in tools] or None - clients = list(self.iter_llm_clients(model)) + ) -> AsyncIterator[tuple[ModelCandidate, republic.Stream[Any]]]: completion_error: Exception | None = None - for index, (candidate, llm) in enumerate(clients): + candidates = self.settings.model_candidates(model) + for index, candidate in enumerate(candidates): + stream: republic.Stream[Any] | None = None try: + client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider) + if reasoning_effort is not None and "extra_body" in client_kwargs: + extra = dict(client_kwargs["extra_body"]) + extra.pop("reasoning", None) + extra.pop("reasoning_effort", None) + client_kwargs["extra_body"] = extra + chat_model = republic.get_model(f"{candidate.provider}:{candidate.model_id}", **client_kwargs) if span := current_span(): span.rename(f"chat {candidate.model_id}") span.set(**{ - "gen_ai.provider.name": candidate.provider.value, + "gen_ai.provider.name": candidate.provider, "gen_ai.request.model": candidate.model_id, }) - streaming = llm.SUPPORTS_COMPLETION_STREAMING - completion_messages = _adapt_messages_for_provider(messages, candidate.provider) - completion_kwargs = { - **self.settings.completion_args, - **_extra_options(llm, stream=streaming), - "model": candidate.model_id, - "messages": completion_messages, - "tools": tool_payloads, - "max_tokens": max_tokens if max_tokens is not None else self.settings.max_tokens, - "stream": streaming, - } - if reasoning_effort is not None: - completion_kwargs["reasoning_effort"] = reasoning_effort - return cast("CompletionResult", await llm.acompletion(**completion_kwargs)) + options = self._chat_options(candidate.provider, tools, max_tokens, reasoning_effort) + stream = chat_model.stream( + _request_messages(messages, candidate.provider_name or candidate.provider, candidate.model_id), + **options, + ) + await stream.__aenter__() except Exception as exc: - event("bub.model.attempt_failed", model=candidate.name, error=repr(exc)) + if stream is not None: + await stream.__aexit__(type(exc), exc, exc.__traceback__) + event("bub.model.attempt_failed", model=candidate.name, error=type(exc).__name__) if completion_error is None: completion_error = exc - if index == len(clients) - 1: + if index == len(candidates) - 1: raise completion_error from None - logger.warning("model candidate failed; trying fallback model={} error={}", candidate.name, exc) + logger.warning( + "model candidate failed; trying fallback model={} error={}", candidate.name, type(exc).__name__ + ) + + else: + try: + yield candidate, stream + finally: + await stream.__aexit__(None, None, None) + return raise RuntimeError("no model candidates available") + def _chat_options( + self, provider: str, tools: list[Tool], max_tokens: int | None, reasoning_effort: str | None + ) -> ChatOptions: + options = provider_extras(self.settings.completion_args) + extra = provider_extras(dict(options.pop("extra_body", {}))) + if provider == "anthropic": + extra.setdefault("cache_control", {"type": "ephemeral"}) + options["tools"] = [republic.Tool(item.name, item.description, item.parameters) for item in tools] + if provider != "codex": + options["max_tokens"] = max_tokens if max_tokens is not None else self.settings.max_tokens + if reasoning_effort is not None: + options["reasoning_effort"] = reasoning_effort + # Per-session reasoning settings take precedence over provider extras. + extra.pop("reasoning_effort", None) + extra.pop("reasoning", None) + if extra: + options["extra_body"] = extra + return cast("ChatOptions", options) + def run( self, *, @@ -185,7 +129,7 @@ def run( state = StreamState() async def iterator() -> AsyncGenerator[StreamEvent, None]: - run_id = self.generate_run_id() + run_id = f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}" messages, new_messages = await self.build_messages( tape=tape, run_id=run_id, @@ -194,7 +138,7 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: model=model, steering_messages=steering_messages, ) - output = ModelOutputAccumulator() + output = ModelOutput() request = LlmCallRequest( run_id=run_id, model=model, @@ -206,8 +150,7 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: if self.hooks is not None: request, decision = await self.hooks.before_llm_call(request, state=tape.context.state) if decision is not None: - await self.record_chat( - tape=tape, + await tape.record_chat( run_id=run_id, system_prompt=system_prompt, new_messages=new_messages, @@ -217,18 +160,8 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: yield StreamEvent("text", {"delta": decision.text}) yield StreamEvent("final", {"ok": True, "text": decision.text}) return + output.provider, _, output.model = request.model.partition(":") llm_started = datetime.now(UTC) - after_fired = False - - async def fire_after(error: Exception | None = None) -> None: - """Fire after_llm_call once per completed call (success or Exception failure); cancellation/consumer close bypasses it.""" - - nonlocal after_fired - if after_fired: - return - after_fired = True - await self._fire_after_llm_call(request, output, state, llm_started, tape, error=error) - try: completion_started = monotonic() async with aclosing(self._traced_completion(request, tools, tape, state, output)) as events: @@ -236,20 +169,17 @@ async def fire_after(error: Exception | None = None) -> None: yield event completion_elapsed = monotonic() - completion_started except Exception as exc: - # Cancellation / consumer close (BaseException) intentionally - # bypasses after_llm_call: only real completions and failures - # are terminal observations. - await fire_after(exc) + await self._fire_after_llm_call(request, output, state, llm_started, tape, error=exc) raise - await fire_after() + await self._fire_after_llm_call(request, output, state, llm_started, tape) yield StreamEvent("usage", {"usage": state.usage, "elapsed_seconds": completion_elapsed}) tool_calls = output.tool_calls if tool_calls: tool_map = {tool_item.name: tool_item for tool_item in tools} - serialized_tool_calls = [tool_call.model_dump(exclude_none=True) for tool_call in tool_calls] - tool_invocations = [tool_invocation_from_native(tool_call, tool_map) for tool_call in tool_calls] + serialized_tool_calls = [asdict(tool_call) for tool_call in tool_calls] + tool_invocations = [_tool_invocation(tool_call, tool_map) for tool_call in tool_calls] yield StreamEvent("tool_call", {"tool_calls": serialized_tool_calls}) tape.context.state["_runtime_tool_names"] = tuple(tool_map) context = ToolContext(tape=tape, run_id=run_id, state=tape.context.state) @@ -258,36 +188,42 @@ async def fire_after(error: Exception | None = None) -> None: context=context, call_ids=[call.id for call in tool_calls], ) - await self.record_chat( - tape=tape, - run_id=run_id, - system_prompt=system_prompt, - new_messages=new_messages, - response_text=output.text or None, - tool_calls=serialized_tool_calls, - tool_results=execution.tool_results, - response=output.response, - model=request.model, - usage=state.usage, - ) - yield StreamEvent("tool_result", {"tool_results": execution.tool_results}) - yield StreamEvent( - "final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": execution.tool_results} - ) - return - - text = output.text - await self.record_chat( - tape=tape, + tool_results = execution.tool_results + else: + serialized_tool_calls, tool_results = [], None + assistant_fields: dict[str, Any] = {} + if response := output.response: + assistant_fields = { + key: value + for key, value in { + "reasoning": response.reasoning, + "provider_data": [ + asdict(part) for part in response.message.parts if isinstance(part, republic.ProviderData) + ], + }.items() + if value + } + if assistant_fields or any(call.metadata for call in response.tool_calls): + assistant_fields.update(source_provider=output.provider, source_model=output.model) + await tape.record_chat( run_id=run_id, system_prompt=system_prompt, new_messages=new_messages, - response_text=text, - response=output.response, + response_text=(output.text or None) if tool_calls else output.text, + tool_calls=serialized_tool_calls, + tool_results=tool_results, model=request.model, usage=state.usage, + assistant_fields=assistant_fields, + provider=output.provider, ) - yield StreamEvent("final", {"ok": True, "text": text}) + if tool_calls: + yield StreamEvent("tool_result", {"tool_results": tool_results}) + yield StreamEvent( + "final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": tool_results} + ) + else: + yield StreamEvent("final", {"ok": True, "text": output.text}) return AsyncStreamEvents(iterator(), state=state) @@ -297,7 +233,7 @@ def _traced_completion( tools: list[Tool], tape: Tape, state: StreamState, - output: ModelOutputAccumulator, + output: ModelOutput, ) -> AsyncStreamEvents: provider, _, model = request.model.partition(":") span = Span( @@ -314,37 +250,50 @@ def _traced_completion( ) span.messages("gen_ai.input.messages", request.messages) if span.recording: - span.set(**{ - "gen_ai.tool.definitions": [tool.to_schema()["function"] | {"type": "function"} for tool in tools] - }) + span.set(**{"gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in tools]}) async def iterator() -> AsyncGenerator[StreamEvent, None]: async with asyncio.timeout(self.settings.model_timeout_seconds): - completion = await self.completion_response( + async with self._completion( model=request.model, messages=list(request.messages), tools=tools, max_tokens=request.max_tokens, reasoning_effort=tape.context.state.get("reasoning_effort"), - ) - try: - async with aclosing(self._completion_events(completion, state, output)) as events: - async for item in events: - yield item - finally: - close = getattr(completion, "aclose", None) or getattr(completion, "close", None) - if close is not None: - result = close() - if inspect.isawaitable(result): - await result + ) as (candidate, stream): + output.provider = candidate.provider_name or candidate.provider + output.model = candidate.model_id + async for item in stream: + match item: + case TextDelta(chunk=delta) | RefusalDelta(chunk=delta): + output.text += delta + yield StreamEvent("text", {"delta": delta}) + case ReasoningDelta(chunk=delta): + yield StreamEvent("reasoning", {"delta": delta}) + case ToolCallReady(call=call): + output.tool_calls.append(call) + case Completed(response=response): + output.response = response + output.tool_calls = response.tool_calls + state.usage = { + **asdict(response.token_usage), + "total_tokens": response.token_usage.total_tokens, + } + span.set(**{ + "gen_ai.response.model": response.model, + "gen_ai.response.id": response.id, + "gen_ai.response.finish_reasons": [response.finish_reason] + if response.finish_reason + else None, + }) async def finish() -> None: if not span.recording: return usage = state.usage or {} span.set(**{ - "gen_ai.usage.input_tokens": usage.get("prompt_tokens", usage.get("input_tokens")), - "gen_ai.usage.output_tokens": usage.get("completion_tokens", usage.get("output_tokens")), + "gen_ai.usage.input_tokens": usage.get("input_tokens"), + "gen_ai.usage.output_tokens": usage.get("output_tokens"), }) span.messages( "gen_ai.output.messages", @@ -352,21 +301,17 @@ async def finish() -> None: { "role": "assistant", "content": output.text, - "tool_calls": [call.model_dump(exclude_none=True) for call in output.tool_calls], + "tool_calls": [asdict(call) for call in output.tool_calls], } ], ) return AsyncStreamEvents(iterator(), state=state, span=span, on_close=finish) - @staticmethod - def generate_run_id() -> str: - return f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}" - async def _fire_after_llm_call( self, request: LlmCallRequest, - output: ModelOutputAccumulator, + output: ModelOutput, state: StreamState, started: datetime, tape: Tape, @@ -378,7 +323,7 @@ async def _fire_after_llm_call( result = LlmCallResult( run_id=request.run_id, text=output.text or None, - tool_calls=[call.model_dump(exclude_none=True) for call in output.tool_calls], + tool_calls=[asdict(call) for call in output.tool_calls], usage=state.usage, error=error, duration_ms=duration_ms, @@ -398,200 +343,101 @@ async def build_messages( try: messages = await tape.read_messages() except BubError as exc: - await self.record_context_error( - tape=tape, + await tape.record_chat( run_id=run_id, system_prompt=system_prompt, + context_error=exc, + new_messages=[], + response_text=None, error=exc, model=model, ) raise - steering_messages_native = [{"role": "user", "content": message} for message in (steering_messages or [])] + steering = [{"role": "user", "content": message} for message in (steering_messages or [])] if system_prompt: messages = [{"role": "system", "content": system_prompt}, *messages] - new_messages = [*steering_messages_native] + new_messages = [*steering] if prompt is not None: new_messages.append({"role": "user", "content": prompt}) messages.extend(new_messages) return messages, new_messages - async def record_context_error( - self, - *, - tape: Tape, - run_id: str, - system_prompt: str | None, - error: BubError, - model: str, - ) -> None: - await self.record_chat( - tape=tape, - run_id=run_id, - system_prompt=system_prompt, - context_error=error, - new_messages=[], - response_text=None, - error=error, - model=model, - ) - - async def record_chat( - self, - *, - tape: Tape, - run_id: str, - system_prompt: str | None, - new_messages: list[dict[str, Any]], - response_text: str | None, - context_error: BubError | None = None, - tool_calls: list[dict[str, Any]] | None = None, - tool_results: list[Any] | None = None, - error: BubError | None = None, - response: Any | None = None, - provider: str | None = None, - model: str | None = None, - usage: dict[str, Any] | None = None, - ) -> None: - await tape.record_chat( - run_id=run_id, - system_prompt=system_prompt, - new_messages=new_messages, - response_text=response_text, - context_error=context_error, - tool_calls=tool_calls, - tool_results=tool_results, - error=error, - response=response, - provider=provider, - model=model, - usage=usage, - ) - - async def _completion_events( - self, - completion: CompletionResult, - state: StreamState, - output: ModelOutputAccumulator, - ) -> AsyncGenerator[StreamEvent, None]: - if isinstance(completion, ChatCompletion): - self._trace_response(completion) - if usage := Tape._extract_usage(completion): - state.usage = usage - output.response = completion - message = completion.choices[0].message - for event in self._completion_message_events(message, output): - yield event - return - - async for chunk in completion: - self._trace_response(chunk) - async for event in self._completion_chunk_events(chunk, state, output): - yield event - - @staticmethod - def _trace_response(response: ChatCompletion | ChatCompletionChunk) -> None: - if span := current_span(): - span.set(**{"gen_ai.response.model": response.model, "gen_ai.response.id": response.id}) - reasons = [choice.finish_reason for choice in response.choices if choice.finish_reason is not None] - if reasons: - span.set(**{"gen_ai.response.finish_reasons": reasons}) - - def _completion_message_events( - self, - message: ChatCompletionMessage, - output: ModelOutputAccumulator, - ) -> Iterable[StreamEvent]: - if message.reasoning: - yield StreamEvent("reasoning", {"delta": self.reasoning_text(message.reasoning)}) - if message.content: - output.add_text(message.content) - yield StreamEvent("text", {"delta": message.content}) - output.add_message_tool_calls(cast("Iterable[ChatCompletionMessageToolCall]", message.tool_calls or [])) - - async def _completion_chunk_events( - self, - chunk: ChatCompletionChunk, - state: StreamState, - output: ModelOutputAccumulator, - ) -> AsyncGenerator[StreamEvent, None]: - if usage := Tape._extract_usage(chunk): - state.usage = usage - for choice in chunk.choices: - delta = choice.delta - if delta.reasoning: - yield StreamEvent("reasoning", {"delta": self.reasoning_text(delta.reasoning)}) - if delta.content: - output.add_text(delta.content) - yield StreamEvent("text", {"delta": delta.content}) - if delta.tool_calls: - output.merge_delta_tool_calls(delta.tool_calls) - - @staticmethod - def reasoning_text(reasoning: object) -> str: - content = getattr(reasoning, "content", reasoning) - return "" if content is None else str(content) - @dataclass -class StreamToolCall: - id: str | None = None - type: Literal["function"] | None = None - name: str | None = None - arguments: str = "" - - def merge(self, delta: ChoiceDeltaToolCall) -> None: - if delta.id: - self.id = delta.id - if delta.type: - self.type = delta.type - if delta.function is None: - return - if delta.function.name: - if self.name is None or self.name == delta.function.name: - self.name = delta.function.name - else: - self.name += delta.function.name - if delta.function.arguments: - self.arguments += delta.function.arguments - - def as_tool_call(self, index: int) -> ChatCompletionMessageFunctionToolCall: - return ChatCompletionMessageFunctionToolCall( - id=self.id or f"call_{index}", - type=self.type or "function", - function=Function(name=self.name or "", arguments=self.arguments or "{}"), +class ModelOutput: + response: republic.Response[Any] | None = None + provider: str | None = None + model: str | None = None + text: str = "" + tool_calls: list[republic.ToolCall] = field(default_factory=list) + + +def _request_messages(messages: list[dict[str, Any]], provider: str, model: str) -> list[republic.Message]: + """Convert the tape and hook dictionaries at the request boundary.""" + result: list[republic.Message] = [] + calls: dict[str, republic.ToolCall] = {} + for message in messages: + role = message["role"] + if role not in {"system", "user", "assistant", "tool"}: + raise BubError(ErrorKind.INVALID_INPUT, f"Unknown message role: {role}") + if role == "tool": + result.append( + republic.Message( + "tool", + tool_results=( + republic.tool_result( + calls[message["tool_call_id"]], + render_result(message.get("content", "")), + is_error=bool(message.get("is_error")), + ), + ), + ) + ) + continue + converted = republic.user(*_request_content(message.get("content"))) + keep_metadata = message.get("source_provider") == provider and message.get("source_model") == model + parts = list(converted.parts) + if keep_metadata: + if reasoning := message.get("reasoning"): + parts.append(republic.Reasoning(reasoning)) + parts.extend(republic.ProviderData(**item) for item in message.get("provider_data", [])) + tool_calls = tuple( + republic.ToolCall(**(call | {"metadata": call.get("metadata", {}) if keep_metadata else {}})) + for call in message.get("tool_calls") or [] ) + calls.update((call.id, call) for call in tool_calls) + result.append(replace(converted, role=role, parts=tuple(parts), tool_calls=tool_calls)) + return result + + +def _request_content(content: object) -> list[str | republic.Image | republic.Audio | republic.Video]: + if content is None: + return [] + if isinstance(content, str): + return [content] + if not isinstance(content, list): + raise BubError(ErrorKind.INVALID_INPUT, "Expected text or a list of message content blocks.") + result: list[str | republic.Image | republic.Audio | republic.Video] = [] + for block in content: + match block: + case {"type": "text", "text": str(text)}: + result.append(text) + case {"type": "image", "url": str(url), "media_type": str(mime)}: + result.append(republic.image(url, media_type=mime)) + case {"type": "audio", "url": str(url), "media_type": str(mime)}: + result.append(republic.audio(url, media_type=mime)) + case {"type": "video", "url": str(url), "media_type": str(mime)}: + result.append(republic.video(url, media_type=mime)) + case _: + raise BubError( + ErrorKind.INVALID_INPUT, + f"Unsupported message content block: {block.get('type') if isinstance(block, dict) else type(block).__name__}", + ) + return result -class ModelOutputAccumulator: - def __init__(self) -> None: - self.response: ChatCompletion | ParsedChatCompletion[Any] | None = None - self._text_parts: list[str] = [] - self._message_calls: list[ChatCompletionMessageToolCall] = [] - self._stream_calls: dict[int, StreamToolCall] = {} - - def add_text(self, text: str) -> None: - self._text_parts.append(text) - - def add_message_tool_calls(self, calls: Iterable[ChatCompletionMessageToolCall]) -> None: - self._message_calls.extend(calls) - - def merge_delta_tool_calls(self, deltas: Iterable[ChoiceDeltaToolCall]) -> None: - for delta in deltas: - self._stream_calls.setdefault(delta.index, StreamToolCall()).merge(delta) - - @property - def text(self) -> str: - return "".join(self._text_parts) - - @property - def tool_calls(self) -> list[ChatCompletionMessageToolCall]: - if self._message_calls: - return list(self._message_calls) - return [self._stream_calls[index].as_tool_call(index) for index in sorted(self._stream_calls)] - - -def tool_invocation_from_native( - tool_call: ChatCompletionMessageToolCall, +def _tool_invocation( + tool_call: republic.ToolCall, tool_map: dict[str, Tool], ) -> tuple[Tool, dict[str, Any]]: """Resolve a model tool call to (runtime tool, arguments). @@ -603,7 +449,11 @@ def tool_invocation_from_native( call, the placeholder raises a clear tool error rather than succeeding with an empty result. """ - tool_name, arguments = parse_native_function_call(tool_call) + tool_name = tool_call.name + try: + arguments = TOOL_ARGUMENTS_ADAPTER.validate_json(tool_call.arguments or "{}") + except ValidationError as exc: + raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc tool_obj = tool_map.get(tool_name) if tool_obj is None: @@ -614,16 +464,6 @@ def raise_unknown_tool(**_: Any) -> None: return tool_obj, arguments -def parse_native_function_call(tool_call: ChatCompletionMessageToolCall) -> tuple[str, dict[str, Any]]: - if tool_call.type != "function": - raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") - try: - arguments = TOOL_ARGUMENTS_ADAPTER.validate_json(tool_call.function.arguments or "{}") - except ValidationError as exc: - raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc - return tool_call.function.name, arguments - - def is_context_length_error(error_msg: str) -> bool: """Check whether an error message indicates a context-length / prompt-too-long failure.""" return bool(CONTEXT_LENGTH_PATTERNS.search(error_msg)) diff --git a/src/bub/builtin/onboarding.py b/src/bub/builtin/onboarding.py index a62da049..1e122145 100644 --- a/src/bub/builtin/onboarding.py +++ b/src/bub/builtin/onboarding.py @@ -3,32 +3,28 @@ from __future__ import annotations import asyncio -import inspect -import os from typing import Any from urllib.parse import urlsplit +import republic import typer -from any_llm import AnyLLM -from any_llm.exceptions import AuthenticationError, MissingApiKeyError, UnsupportedProviderError +from republic.errors import AuthenticationError, ProviderNotFoundError, UnsupportedFeatureError from bub import configure, inquirer -from bub.builtin.codex_provider import should_use_openai_codex_provider -from bub.builtin.settings import DEFAULT_MODEL, AgentSettings +from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, CustomProvider PROVIDERS = { "openrouter": "OpenRouter (hosted model gateway)", "openai": "OpenAI (official API)", "openai-compatible": "OpenAI-compatible (custom URL / local server)", "anthropic": "Anthropic (Claude API)", - "gemini": "Google Gemini (native API)", - "azure": "Azure AI (Azure endpoint)", - "bedrock": "Amazon Bedrock (AWS)", + "google": "Google Gemini (native API)", + "codex": "Codex (ChatGPT plan login)", + "azure-openai": "Azure OpenAI (Azure endpoint)", "ollama": "Ollama (local server)", - "groq": "Groq", "mistral": "Mistral AI", "deepseek": "DeepSeek", - "custom": "Other provider (SDK provider name)", + "custom": "Other registered Republic provider", } OPENAI_BASE = "https://api.openai.com/v1" CONNECTION_TIMEOUT = 10 @@ -37,31 +33,12 @@ RETRY_CONNECTION = "Retry connection" -def _provider_class(provider: str) -> type[AnyLLM] | None: - try: - return AnyLLM.get_provider_class(provider) - except (ImportError, UnsupportedProviderError): - return None - - def _default_base(provider: str) -> str: - provider_class = _provider_class(provider) - if provider_class is None: + try: + client = republic.get_provider(provider, api_key="display-only", api_base="", env_prefix="BUB_DISPLAY_ONLY") + except (AuthenticationError, ProviderNotFoundError, ValueError): return "" - env_name = provider_class.ENV_API_BASE_NAME - return ( - (os.getenv(env_name) if env_name else None) - or provider_class.API_BASE - or ("http://localhost:11434" if provider == "ollama" else "") - ) - - -def _has_environment_key(provider: str) -> bool: - if AgentSettings().model_client_kwargs(provider)["api_key"]: - return True - provider_class = _provider_class(provider) - names = (provider_class.ENV_API_KEY_NAME or "").split("/") if provider_class else [] - return any(os.getenv(name) for name in names) + return client.api_base def _endpoint(provider: str, api_base: str | None) -> str: @@ -99,19 +76,9 @@ def _ask_base(default: str, *, required: bool) -> str: async def _discover_models(provider: str, **client_args: Any) -> list[str]: async with asyncio.timeout(CONNECTION_TIMEOUT): - if not AnyLLM.get_provider_class(provider).SUPPORTS_LIST_MODELS: - raise NotImplementedError - llm = AnyLLM.create(provider, **client_args) - try: - models = await llm.alist_models() - return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()}) - finally: - client = getattr(llm, "client", None) - close = getattr(client, "aclose", None) or getattr(client, "close", None) - if callable(close): - result = close() - if inspect.isawaitable(result): - await result + client = republic.get_provider(provider, **client_args) + models = await client.list_models() + return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()}) def discover_models(provider: str, **client_args: Any) -> list[str]: @@ -120,16 +87,14 @@ def discover_models(provider: str, **client_args: Any) -> list[str]: def _connection_error(exc: Exception) -> str: - # SDK errors may contain request URLs, response bodies or credentials. - if isinstance(exc, AuthenticationError | MissingApiKeyError): + # Provider errors may contain request URLs, response bodies or credentials. + if isinstance(exc, AuthenticationError): return "Authentication failed or API key missing. Check the key and its permissions." if isinstance(exc, TimeoutError): return f"Connection timed out after {CONNECTION_TIMEOUT} seconds. Check the URL and network." - if isinstance(exc, NotImplementedError | UnsupportedProviderError): + if isinstance(exc, NotImplementedError | UnsupportedFeatureError | ProviderNotFoundError): return "Model discovery is unavailable for this provider. You can enter a model ID manually." - if isinstance(exc, ImportError): - return "The provider SDK is not installed. Install its dependencies or enter a model ID manually." - status = getattr(exc, "status_code", None) or getattr(getattr(exc, "original_exception", None), "status_code", None) + status = getattr(exc, "status_code", None) if status in {401, 403}: return f"Authentication rejected (HTTP {status}). Check the API key and its permissions." if status == 404: @@ -151,9 +116,13 @@ def _choose_model(models: list[str], default: str) -> str: def _connection_config( - current_config: dict[str, object], provider: str, api_base: str, api_key: str + current_config: dict[str, object], provider: str, api_base: str, api_key: str, api_format: str | None ) -> dict[str, object]: + if isinstance(named := current_config.get("providers"), dict) and provider in named: + return {"providers": {provider: {"api_base": api_base or None, "api_key": api_key or None}}} config: dict[str, object] = {} + if api_format is not None: + config["client_args"] = {**AgentSettings.model_validate(current_config).client_args, "api_format": api_format} for name, value in (("api_base", api_base), ("api_key", api_key)): existing = current_config.get(name) if isinstance(existing, dict): @@ -167,12 +136,15 @@ def _connection_config( def _select_connection( current_config: dict[str, object], current_provider: str, current_endpoint: str -) -> tuple[str, str, str]: +) -> tuple[str, str, str, str | None]: choice = ( "openai-compatible" if current_provider == "openai" and current_endpoint != OPENAI_BASE else current_provider ) choices = dict(PROVIDERS) choices.setdefault(choice, choice) + for registered in republic.all_providers(): + if registered != "typesafe": + choices.setdefault(registered, registered) selected = inquirer.ask_fuzzy("LLM provider", choices=list(choices.values()), default=choices[choice]) choice = next((name for name, label in choices.items() if label == selected), selected) provider = "openai" if choice == "openai-compatible" else choice @@ -186,10 +158,13 @@ def _select_connection( for name in ("api_base", "api_key") if isinstance(value := current_config.get(name), dict) or provider == current_provider } - stored = AgentSettings.model_construct( - api_base=explicit.get("api_base"), api_key=explicit.get("api_key") - ).model_client_kwargs(provider) - api_base, api_key = stored["api_base"] or "", stored["api_key"] or "" + stored = AgentSettings.model_construct(api_base=explicit.get("api_base"), api_key=explicit.get("api_key")) + api_base = AgentSettings._provider_value(stored.api_base, provider) or "" + api_key = AgentSettings._provider_value(stored.api_key, provider) or "" + if isinstance(named := current_config.get("providers"), dict) and provider in named: + custom = CustomProvider.model_validate(named[provider]) + api_base = custom.api_base or api_base + api_key = custom.api_key or api_key previous_endpoint = _endpoint(provider, api_base) if choice == "openai" and previous_endpoint != OPENAI_BASE: api_base = OPENAI_BASE @@ -197,35 +172,33 @@ def _select_connection( typer.echo(f"API endpoint: {endpoint}" if api_base else f"Default API endpoint: {endpoint}") if ( choice in {"openai-compatible", "custom"} - or provider in {"azure", "ollama"} + or provider in {"azure-openai", "ollama"} or (choice != "openai" and api_base and _endpoint(provider, api_base) != _default_base(provider).rstrip("/")) ): - api_base = _ask_base(api_base, required=choice == "openai-compatible" or provider == "azure") + api_base = _ask_base(api_base, required=choice == "openai-compatible" or provider == "azure-openai") if _endpoint(provider, api_base) != previous_endpoint: api_key = "" - return provider, api_base, api_key + return provider, api_base, api_key, "chat" if choice == "openai-compatible" else None -def _ask_key(provider: str, api_base: str, api_key: str) -> str: +def _ask_key(api_key: str) -> str: prompt = "API key (Enter to keep current key)" if api_key else "API key (optional)" api_key = inquirer.ask_secret(prompt).strip() or api_key - if ( - provider == "openai" - and api_base - and api_base.rstrip("/") != OPENAI_BASE - and not api_key - and not _has_environment_key(provider) - ): - # The OpenAI SDK requires a nonempty key even for servers without auth. - return "not-required" return api_key def _configure_connection( - current_config: dict[str, object], provider: str, api_base: str, api_key: str, model_default: str + current_config: dict[str, object], + provider: str, + api_base: str, + api_key: str, + model_default: str, + api_format: str | None, ) -> dict[str, object]: action = "" models: list[str] = [] + settings = AgentSettings.model_validate(current_config) + provider_type = settings.providers[provider].type if provider in settings.providers else provider typer.echo("Leave the API key blank to keep the current key or use environment credentials.") while action != MANUAL_MODEL: if action == EDIT_CONNECTION: @@ -234,21 +207,20 @@ def _configure_connection( if _endpoint(provider, api_base) != previous_endpoint: api_key = model_default = "" if action != RETRY_CONNECTION: - api_key = _ask_key(provider, api_base, api_key) - config = _connection_config(current_config, provider, api_base, api_key) + api_key = _ask_key(api_key) if provider_type != "codex" else "" + config = _connection_config(current_config, provider, api_base, api_key, api_format) settings = AgentSettings.model_validate(configure.merge({}, current_config, config)) client_args = settings.model_client_kwargs(provider) + provider_type = settings.providers[provider].type if provider in settings.providers else provider if (client_args["api_base"] or "", client_args["api_key"] or "") != (api_base, api_key): typer.echo("BUB_* environment settings override this connection's URL or API key.") - if should_use_openai_codex_provider( - provider, model_default, api_key=client_args["api_key"], api_base=client_args["api_base"] - ): - typer.echo("Using OpenAI OAuth login. Model discovery is unavailable; enter a model ID manually.") + if provider_type == "codex": + typer.echo("Using Codex account login. Model discovery is unavailable; enter a model ID manually.") break typer.echo("Checking connection and fetching models...") try: - models = discover_models(provider, **client_args) - except (NotImplementedError, UnsupportedProviderError) as exc: + models = discover_models(provider_type, **client_args) + except (NotImplementedError, UnsupportedFeatureError, ProviderNotFoundError) as exc: typer.secho(_connection_error(exc), fg="yellow") break except Exception as exc: @@ -271,8 +243,9 @@ def collect_model_config(current_config: dict[str, object]) -> dict[str, object] if not separator: current_provider, _, fallback = DEFAULT_MODEL.partition(":") model_default = settings.model.strip() or fallback + current_provider = current_provider.lower() current_endpoint = _endpoint(current_provider, settings.model_client_kwargs(current_provider)["api_base"]) - provider, api_base, api_key = _select_connection(current_config, current_provider, current_endpoint) + provider, api_base, api_key, api_format = _select_connection(current_config, current_provider, current_endpoint) if (provider, _endpoint(provider, api_base)) != (current_provider, current_endpoint): model_default = "" - return _configure_connection(current_config, provider, api_base, api_key, model_default) + return _configure_connection(current_config, provider, api_base, api_key, model_default, api_format) diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 59d96c0b..f964db8a 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -7,8 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from any_llm import AnyLLM -from any_llm.constants import LLMProvider +import republic from pydantic import BaseModel, Field, field_validator from pydantic.fields import FieldInfo from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict @@ -24,21 +23,53 @@ DEFAULT_MAX_TOKENS = 16384 +def provider_extras(options: dict[str, Any]) -> dict[str, Any]: + """Keep wire extras from replacing the runtime request or token budget.""" + managed = { + "model", + "messages", + "input", + "contents", + "system", + "instructions", + "tools", + "stream", + "stream_options", + "max_tokens", + "max_output_tokens", + "max_completion_tokens", + } + result = {key: value for key, value in options.items() if key not in managed} + if isinstance(result.get("generationConfig"), dict): + result["generationConfig"] = { + key: value for key, value in result["generationConfig"].items() if key != "maxOutputTokens" + } + return result + + @dataclass(frozen=True) class ModelCandidate: - provider: LLMProvider + provider: str model_id: str name: str provider_name: str | None = None class CustomProvider(BaseModel): - """A named endpoint that speaks one of any-llm's provider APIs.""" + """A named endpoint that speaks a registered Republic provider API.""" - type: LLMProvider + type: str api_key: str | None = None api_base: str | None = None + @field_validator("type") + @classmethod + def registered_type(cls, value: str) -> str: + provider = value.lower() + if provider not in republic.all_providers(): + raise ValueError(f"Unknown Republic provider type: {value!r}") + return provider + class ProviderSpecificEnvSource(PydanticBaseSettingsSource): def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: @@ -54,11 +85,12 @@ def __call__(self) -> dict[str, Any]: @staticmethod def _provider_specific(setting_name: str) -> dict[str, str]: + env_names = {name.upper().replace("-", "_"): name for name in republic.all_providers()} setting_regex = re.compile(rf"^BUB_(.+)_{setting_name.upper()}$") result: dict[str, str] = {} for key, value in os.environ.items(): if match := setting_regex.match(key): - result[match.group(1).lower()] = value + result[env_names.get(match.group(1), match.group(1).lower())] = value return result @@ -114,29 +146,41 @@ def model_candidates(self, model: str) -> list[ModelCandidate]: candidates: list[ModelCandidate] = [] for candidate in candidate_names: - prefix, sep, rest = candidate.partition(":") - if sep and (custom := self.providers.get(prefix)): - candidates.append( - ModelCandidate(provider=custom.type, model_id=rest, name=candidate, provider_name=prefix) - ) + provider, separator, model_id = candidate.partition(":") + if not separator or not provider or not model_id: + raise ValueError(f"Expected a provider:model identifier, got {candidate!r}") + if custom := self.providers.get(provider): + candidates.append(ModelCandidate(custom.type, model_id, candidate, provider)) continue - provider, model_id = AnyLLM.split_model_provider(candidate) - candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=candidate)) + provider = provider.lower() + candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=f"{provider}:{model_id}")) return candidates def model_client_kwargs(self, provider: str) -> dict[str, Any]: custom = self.providers.get(provider) + provider = provider if custom else provider.lower() + options = dict(self.client_args) + if "extra_body" in options: + options["extra_body"] = provider_extras(options["extra_body"]) + options.setdefault("env_prefix", f"BUB_{provider.upper().replace('-', '_')}") + prefix = provider.upper().replace("-", "_") return { - **self.client_args, - "api_key": (custom and custom.api_key) or self._provider_value(self.api_key, provider), - "api_base": (custom and custom.api_base) or self._provider_value(self.api_base, provider), + **options, + "api_key": (custom and custom.api_key) or self._credential_value(self.api_key, provider, "api_key", prefix), + "api_base": (custom and custom.api_base) + or self._credential_value(self.api_base, provider, "api_base", prefix), } + @classmethod + def _credential_value( + cls, value: str | dict[str, str] | None, provider: str, setting: str, prefix: str + ) -> str | None: + explicit = cls._provider_value(value, provider) + return explicit or os.getenv(f"{prefix}_{setting.upper()}", explicit) + @staticmethod def _provider_value(value: str | dict[str, str] | None, provider: str) -> str | None: - if isinstance(value, dict): - return value.get(provider) - return value + return value.get(provider) if isinstance(value, dict) else value @property def home(self) -> pathlib.Path: diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index b13881fd..71170880 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -404,7 +404,7 @@ async def stream_events( def _update_token_speed(self, data: dict[str, Any]) -> None: usage = data.get("usage") - tokens = usage.get("completion_tokens", usage.get("output_tokens")) if isinstance(usage, dict) else None + tokens = usage.get("output_tokens") if isinstance(usage, dict) else None elapsed = data.get("elapsed_seconds") self._last_token_speed = None if ( diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py index 9027a2a3..3fd3f1ef 100644 --- a/src/bub/channels/message.py +++ b/src/bub/channels/message.py @@ -9,32 +9,6 @@ type MessageKind = Literal["error", "normal", "command"] type MediaType = Literal["image", "audio", "video", "document"] -_AUDIO_FORMAT_TO_MIME_TYPE = { - "aiff": "audio/aiff", - "flac": "audio/flac", - "m4a": "audio/mp4", - "mp3": "audio/mpeg", - "ogg": "audio/ogg", - "wav": "audio/wav", - "webm": "audio/webm", -} -_AUDIO_MIME_TYPE_TO_FORMAT = { - **{mime_type: audio_format for audio_format, mime_type in _AUDIO_FORMAT_TO_MIME_TYPE.items()}, - "audio/x-aiff": "aiff", - "audio/x-flac": "flac", - "audio/x-m4a": "m4a", - "audio/x-wav": "wav", -} - - -def audio_format_from_mime_type(mime_type: str) -> str: - normalized = mime_type.partition(";")[0].strip().lower() - return _AUDIO_MIME_TYPE_TO_FORMAT.get(normalized, normalized.removeprefix("audio/") or "unknown") - - -def audio_mime_type_from_format(audio_format: str) -> str: - return _AUDIO_FORMAT_TO_MIME_TYPE.get(audio_format, f"audio/{audio_format}") - @dataclass class MediaItem: diff --git a/src/bub/tape.py b/src/bub/tape.py index 627a074a..ec38760c 100644 --- a/src/bub/tape.py +++ b/src/bub/tape.py @@ -12,8 +12,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from pydantic import BaseModel - from bub import tracing from bub.errors import BubError from bub.sidecars import TapeSidecar, sidecar_tape_name @@ -111,10 +109,8 @@ def anchor(cls, name: str, state: dict[str, Any] | None = None, **meta: Any) -> return cls(id=0, kind="anchor", payload=payload, meta=dict(meta)) @classmethod - def tool_call(cls, calls: list[dict[str, Any]], *, content: str | None = None, **meta: Any) -> TapeEntry: - payload: dict[str, Any] = {"calls": calls} - if content is not None: - payload["content"] = content + def tool_call(cls, calls: list[dict[str, Any]], *, content: str = "", **meta: Any) -> TapeEntry: + payload: dict[str, Any] = {"calls": calls, "content": content} return cls(id=0, kind="tool_call", payload=payload, meta=dict(meta)) @classmethod @@ -258,17 +254,16 @@ async def info(self) -> TapeInfo: if not isinstance(token_usage, int) or isinstance(token_usage, bool): continue last_token_usage = token_usage - prompt_tokens = usage.get("prompt_tokens") - prompt_details = usage.get("prompt_tokens_details") - cached_tokens = prompt_details.get("cached_tokens") if isinstance(prompt_details, Mapping) else None + input_tokens = usage.get("input_tokens") + cached_tokens = usage.get("cached_tokens") if ( - isinstance(prompt_tokens, int) - and not isinstance(prompt_tokens, bool) - and prompt_tokens > 0 + isinstance(input_tokens, int) + and not isinstance(input_tokens, bool) + and input_tokens > 0 and isinstance(cached_tokens, int) and not isinstance(cached_tokens, bool) ): - last_token_cache_hit_rate = cached_tokens / prompt_tokens + last_token_cache_hit_rate = cached_tokens / input_tokens break return TapeInfo( name=self.name, @@ -338,10 +333,10 @@ async def record_chat( # noqa: C901 tool_calls: list[dict[str, Any]] | None = None, tool_results: list[Any] | None = None, error: BubError | None = None, - response: Any | None = None, provider: str | None = None, model: str | None = None, usage: dict[str, Any] | None = None, + assistant_fields: dict[str, Any] | None = None, ) -> None: tape_name = self.name meta = {"run_id": run_id, **tracing.correlation()} @@ -352,38 +347,28 @@ async def record_chat( # noqa: C901 for message in new_messages: await self.store.append(tape_name, TapeEntry.message(message, **meta)) if tool_calls: - await self.store.append(tape_name, TapeEntry.tool_call(tool_calls, content=response_text, **meta)) + call_entry = TapeEntry.tool_call(tool_calls, content=response_text or "", **meta) + call_entry.payload.update(assistant_fields or {}) + await self.store.append(tape_name, call_entry) if tool_results is not None: await self.store.append(tape_name, TapeEntry.tool_result(tool_results, **meta)) if error is not None and error is not context_error: await self.store.append(tape_name, TapeEntry.error(error, **meta)) if response_text is not None and not tool_calls: await self.store.append( - tape_name, TapeEntry.message({"role": "assistant", "content": response_text}, **meta) + tape_name, + TapeEntry.message({"role": "assistant", "content": response_text, **(assistant_fields or {})}, **meta), ) data: dict[str, Any] = {"status": "error" if error is not None else "ok"} - resolved_usage = usage or self._extract_usage(response) - if resolved_usage is not None: - data["usage"] = resolved_usage + if usage is not None: + data["usage"] = usage if provider: data["provider"] = provider if model: data["model"] = model await self.store.append(tape_name, TapeEntry.event("run", data, **meta)) - @staticmethod - def _extract_usage(response: object) -> dict[str, Any] | None: - usage = getattr(response, "usage", None) - if usage is None: - return None - if isinstance(usage, dict): - return usage - if isinstance(usage, BaseModel): - payload = usage.model_dump(exclude_none=True) - return payload if isinstance(payload, dict) else None - return None - async def _archive_tape(self, tape_name: str, stamp: str) -> Path: from bub.store import TapeQuery diff --git a/src/bub/tools.py b/src/bub/tools.py index 0d60a399..e972e200 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -175,15 +175,8 @@ def render(self, result: Any) -> str: return self.renderer(result) def to_schema(self) -> dict[str, Any]: - """Build an any-llm completion tool payload.""" - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": self.parameters, - }, - } + """Build a function tool schema for hooks and diagnostics.""" + return {"name": self.name, "description": self.description, "parameters": self.parameters} @classmethod def from_callable( diff --git a/src/bub/tracing.py b/src/bub/tracing.py index 5716fab7..0af2faac 100644 --- a/src/bub/tracing.py +++ b/src/bub/tracing.py @@ -82,15 +82,14 @@ def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]: # Preserve media structure without copying inline binary payloads. parts.append({"type": "text", "content": f"[{part.get('type', 'media')} omitted]"}) for call in message.get("tool_calls") or []: - function = call.get("function", {}) - arguments = function.get("arguments", {}) + arguments = call.get("arguments", {}) if isinstance(arguments, str): with suppress(ValueError): arguments = json.loads(arguments) parts.append({ "type": "tool_call", "id": call.get("id", ""), - "name": function.get("name", ""), + "name": call.get("name", ""), "arguments": arguments, }) return parts diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..698a4849 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Bub behavior tests and shared model response fixtures.""" diff --git a/tests/conftest.py b/tests/conftest.py index 380894b0..df583803 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,13 @@ from collections.abc import Callable, Generator from pathlib import Path +from typing import Any +import httpx2 import pytest import bub.configure as configure +from tests.model_fakes import ProviderService @pytest.fixture(autouse=True) @@ -37,3 +40,47 @@ def _load(content: str = "") -> Path: return config_file return _load + + +@pytest.fixture +def provider_service() -> ProviderService: + return ProviderService() + + +@pytest.fixture +def codex_executable(tmp_path: Path) -> Path: + import sys + + executable = tmp_path / "codex" + executable.write_text( + f"#!{sys.executable}\n" + + """ +import json +import os +import sys +from pathlib import Path +from typing import Any +args = sys.argv[1:] +if not args or args[0] != "login" or 'cli_auth_credentials_store="file"' not in args: + sys.exit(2) +home = Path(os.environ["CODEX_HOME"]) +home.mkdir(parents=True, exist_ok=True) +account = "device-account" if "--device-auth" in args else "browser-account" +(home / "auth.json").write_text(json.dumps({"tokens": {"access_token": "test-token", "account_id": account}})) +""", + encoding="utf-8", + ) + executable.chmod(0o755) + return executable + + +@pytest.fixture +def provider_transport(provider_service: ProviderService, monkeypatch: pytest.MonkeyPatch) -> ProviderService: + constructor = httpx2.AsyncClient + + def client(**kwargs: Any) -> httpx2.AsyncClient: + kwargs.setdefault("transport", httpx2.MockTransport(provider_service._respond)) + return constructor(**kwargs) + + monkeypatch.setattr(httpx2, "AsyncClient", client) + return provider_service diff --git a/tests/model_fakes.py b/tests/model_fakes.py new file mode 100644 index 00000000..cea7755e --- /dev/null +++ b/tests/model_fakes.py @@ -0,0 +1,79 @@ +"""HTTP provider fixtures shared by runtime behavior tests.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx2 + + +def sse(events: list[Any]) -> httpx2.Response: + return httpx2.Response( + 200, + headers={"content-type": "text/event-stream"}, + text="".join(f"data: {event if isinstance(event, str) else json.dumps(event)}\n\n" for event in events), + ) + + +def chat_events(text: str = "done") -> list[Any]: + return [ + {"choices": [{"delta": {"content": text}, "finish_reason": "stop"}]}, + {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}}, + "[DONE]", + ] + + +def tool_events(calls: list[dict[str, Any]], text: str = "") -> list[Any]: + return [ + {"choices": [{"delta": {"content": text, "tool_calls": calls}, "finish_reason": "tool_calls"}]}, + "[DONE]", + ] + + +class ProviderService: + """Record HTTP requests and return queued provider responses.""" + + def __init__(self) -> None: + self.requests: list[httpx2.Request] = [] + self.responses: list[httpx2.Response] = [] + + def reply(self, response: httpx2.Response) -> None: + self.responses.append(response) + + def reply_chat( + self, + text: str = "done", + calls: list[dict[str, Any]] | None = None, + *, + model: str = "actual-model", + input_tokens: int = 3, + output_tokens: int = 2, + ) -> None: + delta: dict[str, Any] = {"content": text} + if calls: + delta["tool_calls"] = [ + {"index": index, "id": call["id"], "function": {"name": call["name"], "arguments": call["arguments"]}} + for index, call in enumerate(calls) + ] + self.reply( + sse([ + { + "id": "response-1", + "model": model, + "choices": [{"delta": delta, "finish_reason": "tool_calls" if calls else "stop"}], + }, + {"choices": [], "usage": {"prompt_tokens": input_tokens, "completion_tokens": output_tokens}}, + "[DONE]", + ]) + ) + + def body(self, index: int = -1) -> Any: + return json.loads(self.requests[index].content) + + def client(self) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(self._respond)) + + def _respond(self, request: httpx2.Request) -> httpx2.Response: + self.requests.append(request) + return self.responses.pop(0) diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 5ce6fbd6..f38666b5 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -3,11 +3,14 @@ from __future__ import annotations from dataclasses import replace +from pathlib import Path from typing import Any import pluggy import pytest +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings from bub.errors import BubError from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import ( @@ -20,7 +23,10 @@ ToolCallResult, ) from bub.hooks.runtime import HookRuntime +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.tape import Tape, TapeContext from bub.tools import Tool, ToolExecutor +from tests.model_fakes import ProviderService, sse def make_hooks(*plugins: Any) -> AgentHooks: @@ -259,48 +265,45 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> None: class TestModelRunnerHookIntegration: """Regression tests for PR #255 review findings (effective request, exactly-once).""" - def _runner_and_tape(self, hooks: AgentHooks, captured: dict): - import bub - from bub.builtin.model_runner import ModelRunner - from bub.builtin.settings import AgentSettings - from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore - from bub.tape import Tape, TapeContext - - class FakeRunner(ModelRunner): - async def completion_response(self, *, model, messages, tools, max_tokens=None, reasoning_effort=None): - captured.update(model=model, max_tokens=max_tokens) - - async def chunks(): - return - yield # pragma: no cover - - return chunks() - - settings = AgentSettings.model_construct(model="openai:orig", max_tokens=100, model_timeout_seconds=None) - runner = FakeRunner(settings, hooks=hooks) - store = AsyncTapeStoreAdapter(InMemoryTapeStore()) - tape = Tape(bub.home / "tapes", store, TapeContext(anchor=None)).scoped("t1") + def _runner_and_tape(self, hooks: AgentHooks, tmp_path: Path, api_format: str | None = None): + runner = ModelRunner( + AgentSettings( + model="openai:orig", max_tokens=100, client_args={"api_format": api_format} if api_format else {} + ), + hooks=hooks, + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("t1") return runner, tape @pytest.mark.asyncio - async def test_rewritten_model_and_max_tokens_reach_provider_and_tape(self) -> None: + async def test_rewritten_model_and_max_tokens_reach_provider_and_tape( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: class Reroute: @hookimpl def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: return replace(request, model="anthropic:new", max_tokens=42) - captured: dict = {} - runner, tape = self._runner_and_tape(make_hooks(Reroute()), captured) + provider_transport.reply( + sse([ + {"type": "message_start", "message": {"id": "test", "usage": {}}}, + {"type": "message_stop"}, + ]) + ) + runner, tape = self._runner_and_tape(make_hooks(Reroute()), tmp_path) events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") async for _ in events: pass - assert captured == {"model": "anthropic:new", "max_tokens": 42} + assert provider_transport.body()["model"] == "new" + assert provider_transport.body()["max_tokens"] == 42 entries = list(await tape.store.fetch_all(tape.query().kinds("event"))) run_events = [e for e in entries if e.payload.get("name") == "run"] assert run_events[-1].payload["data"]["model"] == "anthropic:new" @pytest.mark.asyncio - async def test_after_llm_call_not_fired_on_early_close(self) -> None: + async def test_after_llm_call_not_fired_on_early_close( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: observed: list[LlmCallResult] = [] class Observe: @@ -308,28 +311,19 @@ class Observe: def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: observed.append(result) - captured: dict = {} - runner, tape = self._runner_and_tape(make_hooks(Observe()), captured) - - from bub.builtin.model_runner import ModelRunner # noqa: F401 - - async def fake_events(completion, state, output): - from bub.streaming import StreamEvent - - yield StreamEvent("text", {"delta": "a"}) - yield StreamEvent("text", {"delta": "b"}) + provider_transport.reply_chat("done") + runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") - runner._completion_events = fake_events # type: ignore[method-assign] events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") iterator = events.__aiter__() await iterator.__anext__() await iterator.aclose() - # Consumer close is intentionally NOT a terminal observation: - # after_llm_call fires only for real completions and Exception failures. assert observed == [] @pytest.mark.asyncio - async def test_after_llm_call_fires_exactly_once_on_success(self) -> None: + async def test_after_llm_call_fires_exactly_once_on_success( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: observed: list[LlmCallResult] = [] class Observe: @@ -337,8 +331,8 @@ class Observe: def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: observed.append(result) - captured: dict = {} - runner, tape = self._runner_and_tape(make_hooks(Observe()), captured) + provider_transport.reply_chat("done") + runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") async for _ in events: pass diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 519c1c66..195d5a89 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -7,7 +7,6 @@ from unittest.mock import MagicMock, patch import pytest -from any_llm.types.completion import ChatCompletionChunk import bub.builtin.codemode import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`) @@ -19,20 +18,18 @@ from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.tape import TapeContext from bub.tools import REGISTRY, tool +from tests.model_fakes import ProviderService # --------------------------------------------------------------------------- # Agent.run() tests: merge_back logic and model passthrough # --------------------------------------------------------------------------- -class _FakeModelRunner(ModelRunner): - def __init__(self, settings: AgentSettings) -> None: - super().__init__(settings) - self.completion_kwargs: dict[str, Any] | None = None - - async def completion_response(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - self.completion_kwargs = kwargs - return _chat_stream("done") +@pytest.fixture(autouse=True) +def model_provider(provider_transport: ProviderService) -> ProviderService: + provider_transport.reply_chat() + provider_transport.reply_chat() + return provider_transport def _make_agent() -> Agent: @@ -51,41 +48,18 @@ async def build_prompt(message: dict[str, Any], session_id: str, state: dict[str with patch.object(Agent, "__init__", lambda self, fw: None): agent = Agent.__new__(Agent) - agent.settings = AgentSettings.model_construct(model="test:model", api_key="k", api_base="b", client_args={}) + agent.settings = AgentSettings.model_construct( + model="openai:model", api_key="k", api_base=None, client_args={"api_format": "chat"} + ) agent.command_prefix = agent.settings.command_prefix agent.framework = framework agent.tools = REGISTRY.copy() agent.tape_store = None agent.skill_dirs = None - agent.model_runner = _FakeModelRunner(agent.settings) + agent.model_runner = ModelRunner(agent.settings) return agent -def _model_runner(agent: Agent) -> _FakeModelRunner: - assert isinstance(agent.model_runner, _FakeModelRunner) - return agent.model_runner - - -def _chat_chunk(content: str) -> ChatCompletionChunk: - return ChatCompletionChunk.model_validate({ - "id": "chatcmpl_test", - "object": "chat.completion.chunk", - "created": 0, - "model": "test:model", - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "delta": {"role": "assistant", "content": content}, - } - ], - }) - - -async def _chat_stream(content: str) -> AsyncIterator[ChatCompletionChunk]: - yield _chat_chunk(content) - - class _ForkCapture: """Captures fork_tape enter and exit behavior.""" @@ -137,7 +111,7 @@ async def record_chat( tool_calls: list[dict[str, Any]] | None = None, tool_results: list[Any] | None = None, error: BubError | None = None, - response: Any | None = None, + assistant_fields: dict[str, Any] | None = None, provider: str | None = None, model: str | None = None, usage: dict[str, Any] | None = None, @@ -209,8 +183,10 @@ async def test_agent_run_temp_session_does_not_merge_back() -> None: @pytest.mark.asyncio -async def test_agent_run_passes_model_to_llm() -> None: - """The model parameter should be forwarded to any-llm.""" +async def test_agent_run_passes_model_to_llm( + model_provider: ProviderService, +) -> None: + """The model parameter should be forwarded to Republic.""" agent = _make_agent() fork_capture = _ForkCapture() fake_tapes = _FakeTapeFactory(fork_capture) @@ -224,9 +200,9 @@ async def test_agent_run_passes_model_to_llm() -> None: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert completion_kwargs["model"] == "openai:gpt-4o" + assert completion_kwargs["model"] == "gpt-4o" @pytest.mark.asyncio @@ -244,8 +220,10 @@ async def test_agent_run_empty_prompt_returns_error() -> None: @pytest.mark.asyncio -async def test_agent_run_model_defaults_to_none() -> None: - """When model is not specified, settings.model is used for any-llm.""" +async def test_agent_run_model_defaults_to_none( + model_provider: ProviderService, +) -> None: + """When model is not specified, settings.model is used for Republic.""" agent = _make_agent() fork_capture = _ForkCapture() fake_tapes = _FakeTapeFactory(fork_capture) @@ -254,9 +232,9 @@ async def test_agent_run_model_defaults_to_none() -> None: result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert completion_kwargs["model"] == "test:model" + assert completion_kwargs["model"] == "model" @pytest.mark.asyncio @@ -301,11 +279,13 @@ async def continue_prompt(*, prompt: str | list[dict] | None, tape: _FakeTape, s @pytest.mark.asyncio -async def test_agent_run_model_override_does_not_mutate_default() -> None: +async def test_agent_run_model_override_does_not_mutate_default( + model_provider: ProviderService, +) -> None: """A per-call model override must not leak into the agent's configured model. The override is resolved per turn (``model or self.settings.model``) and - forwarded to any-llm; it must never be written back to ``settings.model``. + forwarded to Republic; it must never be written back to ``settings.model``. This is the agent-layer half of the guarantee that a session-scoped model switch (state['model'] -> run_stream(model=...)) cannot bleed across sessions the way a process-global env var would. @@ -323,14 +303,16 @@ async def test_agent_run_model_override_does_not_mutate_default() -> None: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert completion_kwargs["model"] == "openai:gpt-4o" + assert completion_kwargs["model"] == "gpt-4o" assert agent.settings.model == default_model @pytest.mark.asyncio -async def test_agent_run_injects_steering_messages_once_by_session() -> None: +async def test_agent_run_injects_steering_messages_once_by_session( + model_provider: ProviderService, +) -> None: agent = _make_agent() fork_capture = _ForkCapture() fake_tapes = _FakeTapeFactory(fork_capture) @@ -349,7 +331,7 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None: result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None completion_messages = completion_kwargs["messages"] assert completion_messages[-3:] == [ @@ -367,7 +349,7 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None: result = await agent.run_stream(session_id="user/s1", prompt="again", state={"_runtime_workspace": "/tmp"}) # noqa: S108 [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None completion_messages = completion_kwargs["messages"] assert completion_messages[-1] == {"role": "user", "content": "again"} @@ -375,7 +357,9 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None: @pytest.mark.asyncio -async def test_agent_run_resolves_allowed_tool_aliases_and_limits_prompt() -> None: +async def test_agent_run_resolves_allowed_tool_aliases_and_limits_prompt( + model_provider: ProviderService, +) -> None: allowed_name = "tests.allowed_agent_tool" denied_name = "tests.denied_agent_tool" REGISTRY.pop(allowed_name, None) @@ -402,16 +386,18 @@ def denied_agent_tool() -> str: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_allowed_agent_tool"] + assert [tool["function"]["name"] for tool in completion_kwargs["tools"]] == ["tests_allowed_agent_tool"] system_prompt = completion_kwargs["messages"][0]["content"] assert "tests_allowed_agent_tool" not in system_prompt assert "tests_denied_agent_tool" not in system_prompt @pytest.mark.asyncio -async def test_agent_run_excludes_command_tools() -> None: +async def test_agent_run_excludes_command_tools( + model_provider: ProviderService, +) -> None: visible_name = "tests.visible_agent_tool" internal_name = "tests.internal_agent_tool" REGISTRY.pop(visible_name, None) @@ -437,9 +423,9 @@ def internal_agent_tool() -> str: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_visible_agent_tool"] + assert [tool["function"]["name"] for tool in completion_kwargs["tools"]] == ["tests_visible_agent_tool"] system_prompt = completion_kwargs["messages"][0]["content"] assert "tests_visible_agent_tool" not in system_prompt assert "tests_internal_agent_tool" not in system_prompt @@ -503,7 +489,7 @@ async def test_run_command_model_switches_session_model_directly() -> None: @pytest.mark.asyncio @pytest.mark.parametrize("allow_run_code", [True, False]) async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, allow_run_code: bool + model_provider: ProviderService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, allow_run_code: bool ) -> None: from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, RUN_CODE_TOOL_NAME, run_code @@ -517,9 +503,9 @@ async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub( result = await agent.run_stream(session_id="user/s1", prompt="hello", state=state, allowed_tools=allowed_tools) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - tool_names = [tool.name for tool in completion_kwargs["tools"]] + tool_names = [tool["function"]["name"] for tool in completion_kwargs["tools"]] system_prompt = completion_kwargs["messages"][0]["content"] if not allow_run_code: assert sorted(tool_names) == ["bash", "bash_output", "fs_read", "tape_info"] @@ -541,7 +527,9 @@ async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub( @pytest.mark.asyncio -async def test_run_code_is_hidden_when_session_code_mode_is_off(tmp_path: Path) -> None: +async def test_run_code_is_hidden_when_session_code_mode_is_off( + model_provider: ProviderService, tmp_path: Path +) -> None: from bub.builtin.codemode import RUN_CODE_TOOL_NAME, run_code agent = _make_agent() @@ -551,10 +539,10 @@ async def test_run_code_is_hidden_when_session_code_mode_is_off(tmp_path: Path) result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": str(tmp_path)}) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert RUN_CODE_TOOL_NAME not in [tool.name for tool in completion_kwargs["tools"]] - assert "tape_info" in [tool.name for tool in completion_kwargs["tools"]] + assert RUN_CODE_TOOL_NAME not in [tool["function"]["name"] for tool in completion_kwargs["tools"]] + assert "tape_info" in [tool["function"]["name"] for tool in completion_kwargs["tools"]] assert "" not in completion_kwargs["messages"][0]["content"] diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index 6ab2a7f1..a8d50e91 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -13,7 +13,6 @@ from inquirer_textual.common.PromptSettings import PromptSettings from typer.testing import CliRunner -import bub.builtin.auth as auth import bub.builtin.cli as cli import bub.builtin.onboarding as onboarding import bub.configure as configure @@ -21,14 +20,10 @@ from bub.framework import BubFramework from bub.hooks import hookimpl -TEST_ACCESS_TOKEN = "access" # noqa: S105 -TEST_REFRESH_TOKEN = "refresh" # noqa: S105 - @pytest.fixture(autouse=True) def no_model_discovery_network(monkeypatch): monkeypatch.setattr(onboarding, "discover_models", lambda *args, **kwargs: []) - monkeypatch.setattr("bub.builtin.codex_provider.load_openai_codex_oauth_tokens", lambda: None) def _fake_result(answer: Any, command: str | None = "enter") -> InquirerResult[Any]: @@ -543,29 +538,19 @@ def test_onboard_collects_builtin_runtime_config_with_custom_provider(tmp_path: } -def test_login_openai_command_runs_codex_oauth(tmp_path: Path) -> None: - tokens = auth.OpenAICodexOAuthTokens( - access_token=TEST_ACCESS_TOKEN, - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - account_id="acct_123", +def test_login_command_reports_the_authenticated_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, codex_executable: Path +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "credentials")) + result = CliRunner().invoke( + _create_app(), ["login", "codex", "--executable", str(codex_executable), "--device-auth"] ) - login = patch("bub.builtin.auth.login_openai_codex_oauth", return_value=tokens) - - with login as login_mock: - result = CliRunner().invoke( - _create_app(), - ["login", "openai", "--codex-home", str(tmp_path), "--manual", "--no-browser"], - ) - - assert result.exit_code == 0 + assert result.exit_code == 0, result.output assert "login: ok" in result.stdout - assert "account_id: acct_123" in result.stdout - assert f"auth_file: {tmp_path / 'auth.json'}" in result.stdout - login_mock.assert_called_once() - assert login_mock.call_args.kwargs["codex_home"] == tmp_path - assert login_mock.call_args.kwargs["open_browser"] is False - assert login_mock.call_args.kwargs["prompt_for_redirect"] is auth._prompt_for_codex_redirect + assert "account_id: device-account" in result.stdout + assert "BUB_MODEL=codex:" in result.stdout + assert (tmp_path / "credentials" / "auth.json").exists() + assert "test-token" not in result.stdout def test_login_rejects_unknown_provider() -> None: diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 38f28d1c..6886cdfa 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -1,605 +1,230 @@ from __future__ import annotations -import base64 import json -import time -from datetime import UTC, datetime +import os +import sys from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock, MagicMock -import httpx import pytest -from any_llm.constants import LLMProvider -from any_llm.types.completion import CompletionParams -from any_llm.types.responses import ResponsesParams -from openai.types.responses.response_input_param import ResponseInputParam -from openai.types.responses.response_output_text import ResponseOutputText -from pydantic import TypeAdapter, ValidationError +from typer.testing import CliRunner -from bub.builtin.auth import ( - CodexOAuthRefreshError, - OpenAICodexOAuthTokens, - extract_openai_codex_account_id, - load_openai_codex_oauth_tokens, - openai_codex_oauth_resolver, - save_openai_codex_oauth_tokens, -) -from bub.builtin.codex_provider import ( - DEFAULT_CODEX_INCLUDE, - DEFAULT_CODEX_INSTRUCTIONS, - DEFAULT_CODEX_TEXT_CONFIG, - OpenaiCodexProvider, - build_openai_codex_default_headers, - resolve_openai_codex_api_base, - should_use_openai_codex_provider, -) -from bub.builtin.model_runner import ModelOutputAccumulator, ModelRunner -from bub.builtin.settings import AgentSettings, CustomProvider, ModelCandidate +from bub.builtin.auth import app +from bub.builtin.context import default_tape_context +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem from bub.framework import BubFramework +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, sse -TEST_REFRESH_TOKEN = "refresh" # noqa: S105 -TEST_REFRESH_TOKEN_OLD = "refresh_old" # noqa: S105 -TEST_REFRESH_TOKEN_NEW = "refresh_new" # noqa: S105 - -def _jwt_with_account(account_id: str, *, exp: int | None = None) -> str: - header = _b64({"alg": "none"}) - claims: dict[str, Any] = {"https://api.openai.com/auth": {"chatgpt_account_id": account_id}} - if exp is not None: - claims["exp"] = exp - payload = _b64(claims) - return f"{header}.{payload}.sig" - - -def _b64(payload: dict[str, Any]) -> str: - raw = json.dumps(payload, separators=(",", ":")).encode() - return base64.urlsafe_b64encode(raw).decode().rstrip("=") - - -def _write_codex_auth_without_expiry(home: Path, access_token: str, last_refresh: int) -> None: - (home / "auth.json").write_text( - json.dumps({ - "last_refresh": datetime.fromtimestamp(last_refresh, tz=UTC).isoformat(), - "tokens": {"access_token": access_token, "refresh_token": TEST_REFRESH_TOKEN}, - }) - ) - - -def test_openai_codex_oauth_tokens_round_trip(tmp_path: Path) -> None: - tokens = OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_123"), - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - account_id="acct_123", - ) - - auth_path = save_openai_codex_oauth_tokens(tokens, tmp_path) - loaded = load_openai_codex_oauth_tokens(tmp_path) - - assert auth_path == tmp_path / "auth.json" - assert loaded == tokens - assert auth_path.stat().st_mode & 0o777 == 0o600 - - -def test_openai_codex_oauth_resolver_refreshes_expired_token(tmp_path: Path) -> None: - save_openai_codex_oauth_tokens( - OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_old"), - refresh_token=TEST_REFRESH_TOKEN_OLD, - expires_at=int(time.time()) - 1, - account_id="acct_old", - ), - tmp_path, - ) - refreshed = OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_new"), - refresh_token=TEST_REFRESH_TOKEN_NEW, - expires_at=int(time.time()) + 3600, - account_id="acct_new", - ) - - resolver = openai_codex_oauth_resolver(tmp_path, refresher=lambda refresh_token: refreshed) - - assert resolver("openai") == refreshed.access_token - assert load_openai_codex_oauth_tokens(tmp_path) == refreshed - - -def test_codex_auth_without_expires_at_uses_access_token_jwt_exp(tmp_path: Path) -> None: - now = int(time.time()) - token = _jwt_with_account("acct_123", exp=now + 10 * 86400) - _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200) - refresh_calls: list[str] = [] - - def fail_refresh(refresh_token: str) -> OpenAICodexOAuthTokens: - refresh_calls.append(refresh_token) - raise RuntimeError("refresh should not run") - - loaded = load_openai_codex_oauth_tokens(tmp_path) - assert loaded is not None - assert loaded.expires_at == now + 10 * 86400 - assert openai_codex_oauth_resolver(tmp_path, refresher=fail_refresh)("openai") == token - assert refresh_calls == [] - - -@pytest.mark.parametrize( - "expiry_offset, refresh_duration, expected_token", [(60, 0, True), (-60, 0, False), (1, 2, False)] -) -def test_codex_provider_uses_valid_token_or_reports_refresh_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, expiry_offset: int, refresh_duration: int, expected_token: bool +@pytest.mark.asyncio +@pytest.mark.parametrize("content_kind", ["string", "text", "image", "assistant-history"]) +async def test_codex_sends_content_with_file_credentials( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider_service: ProviderService, content_kind: str ) -> None: - now = int(time.time()) - monkeypatch.setattr("bub.builtin.auth.time.time", lambda: now) - token = _jwt_with_account("acct_123", exp=now + expiry_offset) - _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200) - - def fail_refresh(refresh_token: str, **kwargs: Any) -> OpenAICodexOAuthTokens: - monkeypatch.setattr("bub.builtin.auth.time.time", lambda: now + refresh_duration) - raise RuntimeError("refresh unavailable") - - monkeypatch.setattr("bub.builtin.auth.refresh_openai_codex_oauth_tokens", fail_refresh) - if expected_token: - OpenaiCodexProvider(codex_home=str(tmp_path)) - else: - with pytest.raises(CodexOAuthRefreshError, match="Codex OAuth"): - OpenaiCodexProvider(codex_home=str(tmp_path)) - - -def test_codex_auth_explicit_expiry_precedes_jwt_exp_and_missing_claim_stays_unknown(tmp_path: Path) -> None: - now = int(time.time()) - token = _jwt_with_account("acct_123", exp=now + 10 * 86400) - _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200) - auth_path = tmp_path / "auth.json" - payload = json.loads(auth_path.read_text()) - payload["tokens"]["expires_at"] = now + 3600 - auth_path.write_text(json.dumps(payload)) - loaded = load_openai_codex_oauth_tokens(tmp_path) - assert loaded is not None - assert loaded.expires_at == now + 3600 - - for fallback_token in (_jwt_with_account("acct_123"), "not-a-jwt"): - _write_codex_auth_without_expiry(tmp_path, fallback_token, last_refresh=now - 7200) - loaded = load_openai_codex_oauth_tokens(tmp_path) - assert loaded is not None - assert loaded.expires_at is None - refresher = MagicMock(side_effect=RuntimeError("refresh should not run")) - assert openai_codex_oauth_resolver(tmp_path, refresher=refresher)("openai") == fallback_token - refresher.assert_not_called() - - -def test_codex_auth_uses_valid_token_when_expiry_metadata_is_invalid(tmp_path: Path) -> None: - now = int(time.time()) - token = _jwt_with_account("acct_123", exp=now + 86400) - _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200) - auth_path = tmp_path / "auth.json" - payload = json.loads(auth_path.read_text()) - payload["tokens"]["expires_at"] = "invalid" - auth_path.write_text(json.dumps(payload)) - - refresher = MagicMock(side_effect=RuntimeError("refresh should not run")) - assert openai_codex_oauth_resolver(tmp_path, refresher=refresher)("openai") == token - refresher.assert_not_called() - - -def test_extract_openai_codex_account_id() -> None: - assert extract_openai_codex_account_id(_jwt_with_account("acct_123")) == "acct_123" - assert extract_openai_codex_account_id("not-a-jwt") is None - - -def test_codex_provider_selection_requires_oauth_file_or_oauth_token(monkeypatch) -> None: - monkeypatch.setattr( - "bub.builtin.codex_provider.load_openai_codex_oauth_tokens", - lambda: OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_123"), - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - ), - ) - - assert should_use_openai_codex_provider("openai", "gpt-5.5", api_key=None, api_base=None) is True - assert ( - should_use_openai_codex_provider("openai", "gpt-4o", api_key=_jwt_with_account("acct_123"), api_base=None) - is True + monkeypatch.setenv("CODEX_HOME", str(tmp_path)) + (tmp_path / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "test-token", "account_id": "test-account"}}) ) - assert should_use_openai_codex_provider("openai", "gpt-5-codex", api_key="sk-test", api_base=None) is False - assert should_use_openai_codex_provider("openai", "gpt-5-codex", api_key=None, api_base="https://api.test") is False - - -def test_codex_provider_selection_uses_normal_openai_without_oauth(monkeypatch) -> None: - monkeypatch.setattr("bub.builtin.codex_provider.load_openai_codex_oauth_tokens", lambda: None) - - assert should_use_openai_codex_provider("openai", "gpt-5.5", api_key=None, api_base=None) is False - - -def test_model_runner_creates_codex_provider_for_codex_model(monkeypatch) -> None: - fake_provider = MagicMock() - provider_class = MagicMock(return_value=fake_provider) - monkeypatch.setattr("bub.builtin.model_runner.OpenaiCodexProvider", provider_class) - monkeypatch.setattr( - "bub.builtin.codex_provider.load_openai_codex_oauth_tokens", - lambda: OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_123"), - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - ), - ) - candidate = ModelCandidate(provider=LLMProvider.OPENAI, model_id="gpt-5.5", name="openai:gpt-5.5") - - client = ModelRunner.create_llm_client(candidate, {"api_key": None, "api_base": None}) - - assert client is fake_provider - provider_class.assert_called_once_with(api_key=None, api_base=None) - - -def test_custom_openai_provider_leaves_codex_oauth_in_place(monkeypatch) -> None: - codex_class = MagicMock(return_value="codex") - create = MagicMock(return_value="relay") - monkeypatch.setattr("bub.builtin.model_runner.OpenaiCodexProvider", codex_class) - monkeypatch.setattr("bub.builtin.model_runner.AnyLLM.create", create) - monkeypatch.setattr( - "bub.builtin.codex_provider.load_openai_codex_oauth_tokens", - lambda: OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_123"), - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - ), - ) - settings = AgentSettings.model_construct( - model="relay:qwen/kimi-k3", - fallback_models=["openai:gpt-5.5"], - api_key=None, - api_base=None, - client_args={}, - providers={"relay": CustomProvider(type=LLMProvider.OPENAI, api_base="https://relay.test/v1", api_key="k")}, - ) - - clients = [client for _, client in ModelRunner(settings).iter_llm_clients("relay:qwen/kimi-k3")] - - assert clients == ["relay", "codex"] - create.assert_called_once_with(LLMProvider.OPENAI, api_key="k", api_base="https://relay.test/v1") - codex_class.assert_called_once_with(api_key=None, api_base=None) - - -def test_codex_provider_adds_response_defaults() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - params = ResponsesParams(model="gpt-5-codex", input="hello", stream=True, text={"format": {"type": "text"}}) - - prepared = provider._with_codex_response_defaults(params) - - assert prepared.store is False - assert prepared.instructions == DEFAULT_CODEX_INSTRUCTIONS - assert prepared.include == DEFAULT_CODEX_INCLUDE - assert prepared.text == {**DEFAULT_CODEX_TEXT_CONFIG, "format": {"type": "text"}} - - -def test_codex_provider_preserves_explicit_response_options() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - params = ResponsesParams( - model="gpt-5-codex", - input="hello", - instructions="custom", - include=[], - store=True, - text={"verbosity": "low"}, - ) - - prepared = provider._with_codex_response_defaults(params) - - assert prepared.store is True - assert prepared.instructions == "custom" - assert prepared.include == [] - assert prepared.text == {**DEFAULT_CODEX_TEXT_CONFIG, "verbosity": "low"} - - -def test_codex_completion_params_use_official_responses_payload_fields() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - params = CompletionParams( - model_id="gpt-5.5", - messages=[{"role": "user", "content": "hello"}], - max_tokens=100, - temperature=0.2, - top_p=0.9, - presence_penalty=0.1, - frequency_penalty=0.1, - user="user_123", - stream=True, - stream_options={"include_usage": True}, - ) - - responses_params = provider._completion_params_to_responses_params(params) - payload = responses_params.model_dump(exclude_none=True, exclude={"response_format"}) - - assert payload == { - "model": "gpt-5.5", - "input": [{"role": "user", "content": "hello"}], - "stream": True, - } - - -def test_codex_completion_params_convert_chat_tool_messages_to_responses_items() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - params = CompletionParams( - model_id="gpt-5.5", - messages=[ - {"role": "user", "content": "run bash"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": {"name": "bash", "arguments": '{"command":"pwd"}'}, - } - ], - }, - {"role": "tool", "tool_call_id": "call_1", "name": "bash", "content": "workspace"}, - ], - stream=True, - ) - - responses_params = provider._completion_params_to_responses_params(params) - payload = responses_params.model_dump(exclude_none=True, exclude={"response_format"}) - - assert payload == { - "model": "gpt-5.5", - "input": [ - {"role": "user", "content": "run bash"}, - { - "type": "function_call", - "call_id": "call_1", - "name": "bash", - "arguments": '{"command":"pwd"}', - "status": "completed", - }, - {"type": "function_call_output", "call_id": "call_1", "output": "workspace"}, - ], - "stream": True, - } - - -def test_codex_provider_resolves_codex_api_base_and_headers() -> None: - token = _jwt_with_account("acct_123") - - assert resolve_openai_codex_api_base(None) == "https://chatgpt.com/backend-api/codex" - assert resolve_openai_codex_api_base("https://example.test/responses") == "https://example.test/codex" - assert build_openai_codex_default_headers(token) == { - "chatgpt-account-id": "acct_123", - "OpenAI-Beta": "responses=experimental", - "originator": "bub", - } - - -def _validate_codex_input(items: list[dict[str, Any]]) -> None: - for item in items: - if item.get("role") == "assistant": - # Assistant history requires output_text, as the real endpoint does. - for part in item["content"]: - ResponseOutputText.model_validate({"annotations": [], **part}) - else: - TypeAdapter(ResponseInputParam).validate_python([item]) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("content_kind", ["string", "text", "image", "file", "assistant-history"]) -async def test_codex_completion_accepts_chat_content_without_bad_request(content_kind: str, tmp_path: Path) -> None: framework = BubFramework(config_file=tmp_path / "config.yml") framework.load_builtin_hooks() - message = ChannelMessage(session_id="content-test", channel="cli", content="Reply exactly BUB_READY.") + message = ChannelMessage(session_id="content", channel="cli", content="Reply BUB_READY.") if content_kind == "image": - message.media = [MediaItem(type="image", mime_type="image/png", url="https://example.test/image.png")] - content = await framework.build_prompt(message, message.session_id, {}) + message.media = [MediaItem("image", "image/png", url="https://example.test/image.png")] + prompt = await framework.build_prompt(message, message.session_id, {}) if content_kind == "text": - content = [{"type": "text", "text": message.content}] - elif content_kind == "file": - content = [{"type": "text", "text": message.content}, {"type": "file", "file": {"file_id": "file-test"}}] - messages = [{"role": "user", "content": content}] - if content_kind == "assistant-history": - messages = [ - { - "role": "assistant", - "content": [{"type": "text", "text": "Checking the previous request."}], - "tool_calls": [ - {"id": "call_test", "type": "function", "function": {"name": "check", "arguments": "{}"}} - ], - }, - {"role": "tool", "tool_call_id": "call_test", "content": "Ready"}, - *messages, - ] - - def respond(request: httpx.Request) -> httpx.Response: - payload = json.loads(request.content) - try: - _validate_codex_input(payload["input"]) - except ValidationError: - # Reproduce the endpoint's rejection of Chat Completions content parts. - return httpx.Response( - 400, json={"error": {"message": "Invalid Responses content", "type": "invalid_request_error"}} + prompt = [{"type": "text", "text": message.content}] + provider_service.reply( + sse([ + {"type": "response.output_text.delta", "delta": "BUB_READY"}, + {"type": "response.completed", "response": {"status": "completed"}}, + ]) + ) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("content") + await tape.ensure_bootstrap_anchor() + if content_kind == "assistant-history": + await tape.store.append( + tape.name, + TapeEntry.message({ + "role": "assistant", + "content": [{"type": "text", "text": "Checking."}], + "tool_calls": [{"id": "call-1", "name": "check", "arguments": "{}"}], + }), + ) + await tape.store.append( + tape.name, TapeEntry.message({"role": "tool", "tool_call_id": "call-1", "content": "Ready"}) ) events = [ - {"type": "response.output_text.delta", "delta": "BUB_READY"}, - {"type": "response.completed", "response": {"id": "resp_test", "model": "gpt-5.5"}}, + event + async for event in runner.run(tape=tape, model="codex:test", tools=[], system_prompt=None, prompt=prompt) ] - return httpx.Response( - 200, - headers={"content-type": "text/event-stream"}, - text="".join(f"data: {json.dumps(event)}\n\n" for event in events), - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"), http_client=http_client) - stream = await provider.acompletion(model="gpt-5.5", messages=messages, stream=True) - answer = "".join([chunk.choices[0].delta.content or "" async for chunk in stream if chunk.choices]) - - assert answer == "BUB_READY" - - -async def _codex_response_events(): - yield SimpleNamespace(type="response.output_text.delta", delta="hel") - yield SimpleNamespace(type="response.output_text.delta", delta="lo") - yield SimpleNamespace( - type="response.completed", - response=SimpleNamespace( - id="resp_123", - created_at=1, - model="gpt-5-codex", - usage=SimpleNamespace( - input_tokens=3, - output_tokens=2, - total_tokens=5, - input_tokens_details={"cached_tokens": 2}, - ), - ), - ) - - -@pytest.mark.asyncio -async def test_codex_completion_stream_maps_response_events_to_completion_chunks() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - provider._aresponses = AsyncMock(return_value=_codex_response_events()) # type: ignore[method-assign] - params = CompletionParams( - model_id="gpt-5-codex", - messages=[{"role": "user", "content": "hello"}], - stream=True, - ) - - completion = await provider._acompletion(params) - chunks = [chunk async for chunk in completion] - - assert [chunk.choices[0].delta.content for chunk in chunks[:2]] == ["hel", "lo"] - assert chunks[-1].choices[0].finish_reason == "stop" - assert chunks[-1].usage is not None - assert chunks[-1].usage.prompt_tokens == 3 - assert chunks[-1].usage.completion_tokens == 2 - assert chunks[-1].usage.prompt_tokens_details is not None - assert chunks[-1].usage.prompt_tokens_details.cached_tokens == 2 - - -async def _codex_tool_response_events(): - yield SimpleNamespace( - type="response.output_item.added", - output_index=0, - item=SimpleNamespace(type="function_call", id="fc_1", call_id="call_1", name="bash", arguments=""), - ) - yield SimpleNamespace(type="response.function_call_arguments.delta", output_index=0, delta='{"command":') - yield SimpleNamespace(type="response.function_call_arguments.delta", output_index=0, delta='"pwd"}') - yield SimpleNamespace( - type="response.completed", - response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None), - ) - - -@pytest.mark.asyncio -async def test_codex_completion_stream_maps_response_tool_calls_to_completion_chunks() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - provider._aresponses = AsyncMock(return_value=_codex_tool_response_events()) # type: ignore[method-assign] - params = CompletionParams( - model_id="gpt-5-codex", - messages=[{"role": "user", "content": "hello"}], - stream=True, - ) - - completion = await provider._acompletion(params) - chunks = [chunk async for chunk in completion] - - first_tool_delta = chunks[0].choices[0].delta.tool_calls[0] - assert first_tool_delta.id == "call_1" - assert first_tool_delta.function.name == "bash" - assert "".join(chunk.choices[0].delta.tool_calls[0].function.arguments or "" for chunk in chunks[1:3]) == ( - '{"command":"pwd"}' - ) - assert chunks[-1].choices[0].finish_reason == "tool_calls" - - -async def _codex_custom_tool_response_events(): - yield SimpleNamespace( - type="response.output_item.added", - output_index=1, - item=SimpleNamespace(type="custom_tool_call", id="ctc_1", call_id="call_1", name="bash", input=""), - ) - yield SimpleNamespace( - type="response.custom_tool_call_input.delta", item_id="ctc_1", call_id="call_1", delta='{"command":' - ) - yield SimpleNamespace( - type="response.custom_tool_call_input.delta", item_id="ctc_1", call_id="call_1", delta='"pwd"}' - ) - yield SimpleNamespace( - type="response.completed", - response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None), - ) + request = provider_service.requests[0] + body = provider_service.body() + assert str(request.url) == "https://chatgpt.com/backend-api/codex/responses" + assert request.headers["authorization"] == "Bearer test-token" + assert request.headers["ChatGPT-Account-Id"] == "test-account" + assert body["store"] is False + assert body["stream"] is True + assert "max_output_tokens" not in body + assert body["include"] == ["reasoning.encrypted_content"] + assert body["input"][-1]["content"][0]["type"] == "input_text" + if content_kind == "image": + assert body["input"][-1]["content"][-1] == { + "type": "input_image", + "image_url": "https://example.test/image.png", + } + if content_kind == "assistant-history": + assert body["input"][1]["type"] == "function_call" + assert body["input"][2]["type"] == "function_call_output" + assert events[-1].data == {"ok": True, "text": "BUB_READY"} @pytest.mark.asyncio -async def test_codex_completion_stream_maps_custom_tool_call_input_deltas_to_completion_chunks() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - provider._aresponses = AsyncMock(return_value=_codex_custom_tool_response_events()) # type: ignore[method-assign] - params = CompletionParams( - model_id="gpt-5-codex", - messages=[{"role": "user", "content": "hello"}], - stream=True, - ) - - completion = await provider._acompletion(params) - chunks = [chunk async for chunk in completion] - - first_tool_delta = chunks[0].choices[0].delta.tool_calls[0] - assert first_tool_delta.index == 1 - assert first_tool_delta.id == "call_1" - assert first_tool_delta.function.name == "bash" - assert "".join(chunk.choices[0].delta.tool_calls[0].function.arguments or "" for chunk in chunks[1:3]) == ( - '{"command":"pwd"}' - ) - assert chunks[-1].choices[0].finish_reason == "tool_calls" - - -async def _codex_tool_done_name_null_response_events(): - yield SimpleNamespace( - type="response.function_call_arguments.done", - item_id="fc_1", - output_index=0, - name=None, - arguments='{"message":"hello"}', - ) - yield SimpleNamespace( - type="response.output_item.done", - output_index=0, - item=SimpleNamespace( - type="function_call", - id="fc_1", - call_id="call_1", - name="echo", - arguments='{"message":"hello"}', - status="completed", - ), - ) - yield SimpleNamespace( - type="response.completed", - response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None), - ) +async def test_codex_replays_encrypted_reasoning_and_tool_results_after_reload( + tmp_path: Path, provider_service: ProviderService +) -> None: + reasoning = {"type": "reasoning", "id": "reason-1", "encrypted_content": "opaque", "summary": []} + provider_service.reply( + sse([ + {"type": "response.output_item.done", "item": reasoning}, + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "id": "fc-1", + "call_id": "call-1", + "name": "echo", + "arguments": "{}", + }, + }, + {"type": "response.completed", "response": {"status": "completed"}}, + ]) + ) + provider_service.reply( + sse([ + {"type": "response.output_text.delta", "delta": "done"}, + {"type": "response.completed", "response": {"status": "completed"}}, + ]) + ) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(api_key="test", client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("codex") + await tape.ensure_bootstrap_anchor() + tools = [Tool(name="echo", handler=lambda: "echoed")] + _ = [ + event + async for event in runner.run(tape=tape, model="codex:test", tools=tools, system_prompt=None, prompt="echo") + ] + reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( + "codex" + ) + events = [ + event + async for event in runner.run( + tape=reopened, model="codex:test", tools=tools, system_prompt=None, prompt=None + ) + ] + assert provider_service.body()["input"][1:] == [ + reasoning, + {"type": "function_call", "call_id": "call-1", "name": "echo", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call-1", "output": "echoed"}, + ] + assert events[-1].data == {"ok": True, "text": "done"} @pytest.mark.asyncio -async def test_codex_completion_stream_keeps_tool_name_when_arguments_done_name_is_null() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - provider._aresponses = AsyncMock(return_value=_codex_tool_done_name_null_response_events()) # type: ignore[method-assign] - params = CompletionParams( - model_id="gpt-5-codex", - messages=[{"role": "user", "content": "call echo"}], - stream=True, - ) - - completion = await provider._acompletion(params) - output = ModelOutputAccumulator() - chunks = [chunk async for chunk in completion] - for chunk in chunks: - tool_calls = chunk.choices[0].delta.tool_calls - if tool_calls: - output.merge_delta_tool_calls(tool_calls) - - tool_call = output.tool_calls[0] - assert tool_call.id == "call_1" - assert tool_call.function.name == "echo" - assert tool_call.function.arguments == '{"message":"hello"}' - assert chunks[-1].choices[0].finish_reason == "tool_calls" +async def test_named_endpoint_falls_back_to_codex_with_its_own_credentials( + tmp_path: Path, provider_service: ProviderService +) -> None: + import httpx2 + + provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}})) + provider_service.reply( + sse([ + {"type": "response.output_text.delta", "delta": "ready"}, + {"type": "response.completed", "response": {"status": "completed"}}, + ]) + ) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings( + model="relay:sample", + fallback_models=["codex:sample"], + api_key={"codex": "codex-key"}, + providers={"relay": {"type": "openai", "api_key": "relay-key", "api_base": "https://relay.test/v1"}}, + client_args={"http_client": client, "max_retries": 0}, + ) + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") + events = [ + event + async for event in runner.run(tape=tape, model="relay:sample", tools=[], system_prompt=None, prompt="hello") + ] + assert [str(request.url) for request in provider_service.requests] == [ + "https://relay.test/v1/responses", + "https://chatgpt.com/backend-api/codex/responses", + ] + assert [request.headers["authorization"] for request in provider_service.requests] == [ + "Bearer relay-key", + "Bearer codex-key", + ] + assert events[-1].data == {"ok": True, "text": "ready"} + + +@pytest.mark.parametrize("device_auth", [False, True]) +@pytest.mark.parametrize("directory_option", [False, True]) +def test_login_stores_credentials_in_the_selected_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, codex_executable: Path, device_auth: bool, directory_option: bool +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "default-home")) + directory = tmp_path / "selected-home" if directory_option else tmp_path / "default-home" + args = ["codex", "--executable", str(codex_executable)] + if directory_option: + args.extend(["--codex-home", str(directory)]) + if device_auth: + args.append("--device-auth") + result = CliRunner().invoke(app, args) + assert result.exit_code == 0, result.output + account = "device-account" if device_auth else "browser-account" + assert f"account_id: {account}" in result.output + assert json.loads((directory / "auth.json").read_text())["tokens"]["account_id"] == account + assert "test-token" not in result.output + assert os.environ["CODEX_HOME"] == str(tmp_path / "default-home") + if directory_option: + assert not (tmp_path / "default-home" / "auth.json").exists() + + +@pytest.mark.parametrize("directory_option", [False, True]) +def test_login_reports_a_missing_executable(tmp_path: Path, directory_option: bool) -> None: + args = ["codex", "--executable", str(tmp_path / "missing")] + if directory_option: + args.extend(["--codex-home", str(tmp_path)]) + result = CliRunner().invoke(app, args) + assert result.exit_code == 1 + assert "Cannot start" in result.output + + +@pytest.mark.parametrize("directory_option", [False, True]) +def test_failed_login_does_not_report_saved_credentials_as_success( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, directory_option: bool +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path)) + (tmp_path / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "test-token", "account_id": "saved-account"}}) + ) + executable = tmp_path / "failed-codex" + executable.write_text(f"#!{sys.executable}\nimport sys\nsys.exit(2)\n") + executable.chmod(0o755) + args = ["codex", "--executable", str(executable)] + if directory_option: + args.extend(["--codex-home", str(tmp_path)]) + result = CliRunner().invoke(app, args) + assert result.exit_code == 1 + assert "login: failed" in result.output + assert "saved-account" not in result.output + assert "login: ok" not in result.output diff --git a/tests/test_builtin_model_runner.py b/tests/test_builtin_model_runner.py index 6fee8704..dc41741f 100644 --- a/tests/test_builtin_model_runner.py +++ b/tests/test_builtin_model_runner.py @@ -1,405 +1,361 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Iterator +import json from pathlib import Path -from typing import Any import pytest -from any_llm.constants import LLMProvider -from any_llm.providers.anthropic.base import BaseAnthropicProvider -from any_llm.providers.openai.base import BaseOpenAIProvider -from any_llm.types.completion import ( - ChatCompletion, - ChatCompletionChunk, - ChatCompletionMessageFunctionToolCall, - Function, -) -from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall, Custom +import republic from bub.builtin.context import default_tape_context -from bub.builtin.model_runner import ( - ModelRunner, - _adapt_messages_for_provider, - parse_native_function_call, - tool_invocation_from_native, -) -from bub.builtin.settings import AgentSettings, ModelCandidate +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings from bub.errors import BubError, ErrorKind -from bub.store import FileTapeStore -from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, Tape, TapeContext -from bub.tools import Tool, ToolExecutor - - -@pytest.mark.parametrize("provider", [LLMProvider.GEMINI, LLMProvider.VERTEXAI]) -def test_adapt_messages_converts_video_url_for_google_providers(provider: LLMProvider) -> None: - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "describe this video"}, - {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,dmlkZW8="}}, - {"type": "input_audio", "input_audio": {"data": "YXVkaW8=", "format": "ogg"}}, - ], - } - ] - - result = _adapt_messages_for_provider(messages, provider) - - assert result == [ - { - "role": "user", - "content": [ - {"type": "text", "text": "describe this video"}, - {"type": "file", "file": {"file_data": "data:video/mp4;base64,dmlkZW8="}}, - {"type": "file", "file": {"file_data": "data:audio/ogg;base64,YXVkaW8="}}, - ], - } - ] - assert messages[0]["content"][1]["type"] == "video_url" - - -def test_adapt_messages_keeps_native_multimodal_blocks_for_openrouter() -> None: - messages = [ - { - "role": "user", - "content": [ - {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,dmlkZW8="}}, - {"type": "input_audio", "input_audio": {"data": "YXVkaW8=", "format": "ogg"}}, - ], - } - ] - - assert _adapt_messages_for_provider(messages, LLMProvider.OPENROUTER) is messages +from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.tape import Tape, TapeContext +from bub.tools import Tool +from tests.model_fakes import ProviderService, chat_events, sse, tool_events @pytest.mark.asyncio -async def test_unknown_tool_placeholder_surfaces_error_without_hooks() -> None: - tool_call = ChatCompletionMessageFunctionToolCall( - id="call-1", - type="function", - function=Function(name="missing_tool", arguments="{}"), +async def test_unknown_tool_returns_an_error_result(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply( + sse( + tool_events([ + {"index": 0, "id": "call-1", "function": {"name": "missing_tool", "arguments": "{}"}}, + ]) + ) ) - invocation = tool_invocation_from_native(tool_call, {}) - - execution = await ToolExecutor().execute_async([invocation]) - - assert execution.error is not None - assert "missing_tool" in execution.error.message + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("unknown") + events = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="Run the tool." + ) + ] + result = next(event.data["tool_results"][0] for event in events if event.kind == "tool_result") + assert result["kind"] == "tool" + assert "missing_tool" in result["message"] + assert events[-1].kind == "final" @pytest.mark.asyncio -@pytest.mark.parametrize("streaming", [False, True]) -@pytest.mark.parametrize("content", [None, "", "Check byte equality.\nOnly exact equality counts."]) +@pytest.mark.parametrize("content", ["", "Check byte equality.\nOnly exact equality counts."]) @pytest.mark.parametrize("continuation_prompt", ["Continue.", "", None]) -async def test_tool_call_text_survives_into_next_request_after_tape_reload( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - streaming: bool, - content: str | None, - continuation_prompt: str | None, +async def test_tool_turn_replays_after_tape_reload( + tmp_path: Path, provider_service: ProviderService, content: str, continuation_prompt: str | None ) -> None: - calls = [ - {"id": f"call-{name}", "type": "function", "function": {"name": name, "arguments": "{}"}} - for name in ("inspect", "compare") + provider_service.reply( + sse( + tool_events( + [ + {"index": index, "id": f"call-{name}", "function": {"name": name, "arguments": "{}"}} + for index, name in enumerate(("inspect", "compare")) + ], + text=content, + ) + ) + ) + provider_service.reply(sse(chat_events("Compared."))) + tools = [Tool(name="inspect", handler=lambda: "files found"), Tool(name="compare", handler=lambda: "bytes differ")] + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + root = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") + async with root.fork_tape() as tape: + await tape.ensure_bootstrap_anchor() + events = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=tools, system_prompt=None, prompt="Compare the outputs." + ) + ] + reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") + follow_up = [ + event + async for event in runner.run( + tape=reopened, model="openai:test", tools=tools, system_prompt=None, prompt=continuation_prompt + ) + ] + assert next(event.data["tool_results"] for event in events if event.kind == "tool_result") == [ + "files found", + "bytes differ", ] - response = BaseOpenAIProvider._convert_completion_response({ - "id": "completion-1", - "object": "chat.completion", - "created": 0, - "model": "test-model", - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls", - "message": {"role": "assistant", "content": content, "tool_calls": calls}, - } - ], - }) - - async def stream() -> AsyncIterator[ChatCompletionChunk]: - deltas = [{"content": part} for part in (content or "").splitlines(keepends=True)] - deltas.append({"tool_calls": [{"index": index, **call} for index, call in enumerate(calls)]}) - for delta in deltas: - yield ChatCompletionChunk.model_validate({ - "id": "completion-1", - "object": "chat.completion.chunk", - "created": 0, - "model": "test-model", - "choices": [{"index": 0, "delta": delta}], - }) - - requests: list[list[dict[str, Any]]] = [] + messages = provider_service.body()["messages"] + users = [message["content"] for message in messages if message["role"] == "user"] + assert users == ["Compare the outputs.", *([continuation_prompt] if continuation_prompt is not None else [])] + assistant = next(message for message in messages if message["role"] == "assistant") + assert (assistant.get("content") or "") == content + assert {call["id"]: call["function"]["name"] for call in assistant["tool_calls"]} == { + "call-inspect": "inspect", + "call-compare": "compare", + } + assert {message["tool_call_id"]: message["content"] for message in messages if message["role"] == "tool"} == { + "call-inspect": "files found", + "call-compare": "bytes differ", + } + assert follow_up[-1].data == {"ok": True, "text": "Compared."} - async def complete(**kwargs: Any) -> ChatCompletion | AsyncIterator[ChatCompletionChunk]: - requests.append(kwargs["messages"]) - return stream() if streaming else response - runner = ModelRunner(AgentSettings.model_construct(model="test-model", model_timeout_seconds=None)) - monkeypatch.setattr(runner, "completion_response", complete) - tools = [Tool(name="inspect", handler=lambda: "files found"), Tool(name="compare", handler=lambda: "bytes differ")] - root = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("test-tape") - async with root.fork_tape() as tape: +@pytest.mark.asyncio +async def test_continuation_sends_steering_without_a_prompt(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("steering") await tape.ensure_bootstrap_anchor() events = [ event async for event in runner.run( - tape=tape, model="test-model", tools=tools, system_prompt=None, prompt="Compare the outputs." + tape=tape, + model="openai:test", + tools=[], + system_prompt=None, + prompt=None, + steering_messages=["new user direction"], ) ] - - reloaded = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( - "test-tape" - ) - async for _ in runner.run( - tape=reloaded, model="test-model", tools=tools, system_prompt=None, prompt=continuation_prompt - ): - pass - - assert "".join(event.data["delta"] for event in events if event.kind == "text") == (content or "") - expected_messages = [ - {"role": "user", "content": "Compare the outputs."}, - {"role": "assistant", "content": content or "", "tool_calls": calls}, - {"role": "tool", "content": "files found", "tool_call_id": "call-inspect", "name": "inspect"}, - {"role": "tool", "content": "bytes differ", "tool_call_id": "call-compare", "name": "compare"}, - ] - if continuation_prompt is not None: - expected_messages.append({"role": "user", "content": continuation_prompt}) - assert requests[1] == expected_messages - if continuation_prompt is None: - persisted = await reloaded.store.fetch_all(reloaded.query().kinds("message")) - assert [entry.payload for entry in persisted if entry.payload.get("role") == "user"] == [ - {"role": "user", "content": "Compare the outputs."} - ] + assert provider_service.body()["messages"] == [{"role": "user", "content": "new user direction"}] + assert events[-1].data == {"ok": True, "text": "done"} + assert {"role": "user", "content": "new user direction"} in await tape.read_messages() @pytest.mark.asyncio -async def test_build_messages_keeps_steering_when_continuation_has_no_prompt(tmp_path: Path) -> None: - runner = ModelRunner(AgentSettings.model_construct(model="test-model")) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("steering") - await tape.ensure_bootstrap_anchor() - - messages, new_messages = await runner.build_messages( - tape=tape, - run_id="run-1", - system_prompt=None, - prompt=None, - model="test-model", - steering_messages=["new user direction"], - ) - - assert messages == new_messages == [{"role": "user", "content": "new user direction"}] - - @pytest.mark.parametrize("arguments", ["[]", "null", "1", "not json"]) -def test_function_tool_call_rejects_non_object_arguments(arguments: str) -> None: - call = ChatCompletionMessageFunctionToolCall( - id="call-1", type="function", function=Function(name="inspect", arguments=arguments) +async def test_invalid_tool_arguments_do_not_execute_the_handler( + tmp_path: Path, provider_service: ProviderService, arguments: str +) -> None: + provider_service.reply( + sse(tool_events([{"index": 0, "id": "call-1", "function": {"name": "inspect", "arguments": arguments}}])) ) + invoked = [] + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid") + with pytest.raises(BubError) as exc: + _ = [ + event + async for event in runner.run( + tape=tape, + model="openai:test", + tools=[Tool(name="inspect", handler=lambda: invoked.append(True))], + system_prompt=None, + prompt="Inspect.", + ) + ] + assert exc.value.kind == ErrorKind.INVALID_INPUT + assert not invoked + assert not await tape.store.fetch_all(tape.query().kinds("tool_result")) - with pytest.raises(BubError) as exc_info: - parse_native_function_call(call) - assert exc_info.value.kind == ErrorKind.INVALID_INPUT +@pytest.mark.asyncio +async def test_streaming_reports_usage_and_records_it_in_tape( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(max_tokens=100, client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("usage") + events = [ + event + async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + ] + assert provider_service.body()["stream_options"] == {"include_usage": True} + usage = next(event.data for event in events if event.kind == "usage") + assert usage["usage"]["input_tokens"] == 3 + assert usage["usage"]["output_tokens"] == 2 + assert usage["usage"]["total_tokens"] == 5 + assert usage["elapsed_seconds"] >= 0 + assert events[-1].data == {"ok": True, "text": "done"} + assert (await tape.info()).last_token_usage == 5 -def test_custom_tool_call_is_not_treated_as_a_function_call() -> None: - call = ChatCompletionMessageCustomToolCall( - id="call-1", type="custom", custom=Custom(name="inspect", input="raw input") +@pytest.mark.asyncio +async def test_anthropic_request_enables_caching_and_generation_options( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply( + sse([ + {"type": "message_start", "message": {"id": "test", "usage": {}}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "done"}}, + {"type": "message_stop"}, + ]) ) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings(max_tokens=100, client_args={"http_client": client}, completion_args={"temperature": 0.2}) + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cache") + events = [ + event + async for event in runner.run( + tape=tape, model="anthropic:test", tools=[], system_prompt=None, prompt="hello" + ) + ] + assert provider_service.body()["cache_control"] == {"type": "ephemeral"} + assert provider_service.body()["temperature"] == 0.2 + assert provider_service.body()["max_tokens"] == 100 + assert events[-1].data == {"ok": True, "text": "done"} - with pytest.raises(BubError) as exc_info: - parse_native_function_call(call) - - assert exc_info.value.kind == ErrorKind.INVALID_INPUT - - -class _FakeStreamingOpenAIProvider(BaseOpenAIProvider): - SUPPORTS_COMPLETION_STREAMING = True - def __init__(self) -> None: - self.completion_kwargs: dict[str, Any] | None = None +@pytest.mark.asyncio +async def test_session_reasoning_and_runtime_arguments_take_precedence( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings( + client_args={ + "http_client": client, + "api_format": "chat", + "extra_body": {"model": "bad", "messages": [], "reasoning_effort": "low"}, + }, + completion_args={ + "reasoning_effort": "low", + "model": "ignored", + "max_tokens": 1, + "stream": False, + "extra_body": {"reasoning_effort": "low", "tools": []}, + }, + ) + ) + tape = Tape( + tmp_path, + AsyncTapeStoreAdapter(InMemoryTapeStore()), + TapeContext(anchor=None, state={"reasoning_effort": "high"}), + ).scoped("reasoning") + _ = [ + event + async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + ] + request = provider_service.body() + assert request["reasoning_effort"] == "high" + assert request["model"] == "test" + assert request["messages"] == [{"role": "user", "content": "hello"}] + assert request["max_completion_tokens"] == 16384 + assert request["stream"] is True - async def acompletion(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - self.completion_kwargs = kwargs - include_usage = kwargs.get("stream_options") == {"include_usage": True} - async def stream() -> AsyncIterator[ChatCompletionChunk]: - yield ChatCompletionChunk.model_validate({ - "id": "chatcmpl_test", - "object": "chat.completion.chunk", - "created": 0, - "model": "gpt-test", +@pytest.mark.asyncio +async def test_truncated_stream_never_executes_tools(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply( + sse([ + { "choices": [ { - "index": 0, - "finish_reason": None, - "delta": {"role": "assistant", "content": "done"}, + "delta": { + "tool_calls": [ + {"index": 0, "id": "call-1", "function": {"name": "echo", "arguments": "{}"}} + ] + } } - ], - }) - final_chunk: dict[str, Any] = { - "id": "chatcmpl_test", - "object": "chat.completion.chunk", - "created": 0, - "model": "gpt-test", - "choices": [], + ] } - if include_usage: - final_chunk["usage"] = {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5} - yield ChatCompletionChunk.model_validate(final_chunk) - - return stream() - - -class _FakeStreamingAnthropicProvider(BaseAnthropicProvider): - def __init__(self) -> None: - self.completion_kwargs: dict[str, Any] | None = None - - def _init_client(self, api_key: str | None = None, api_base: str | None = None, **kwargs: Any) -> None: - pass - - async def acompletion(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - self.completion_kwargs = kwargs - - async def stream() -> AsyncIterator[ChatCompletionChunk]: - if False: - yield - - return stream() - - -class _FakeOpenAIModelRunner(ModelRunner): - def __init__(self, settings: AgentSettings, llm: _FakeStreamingOpenAIProvider) -> None: - super().__init__(settings) - self._llm = llm - - def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, _FakeStreamingOpenAIProvider]]: - yield ModelCandidate(provider=LLMProvider.OPENAI, model_id=model, name=f"openai:{model}"), self._llm - - -class _FakeAnthropicModelRunner(ModelRunner): - def __init__(self, settings: AgentSettings, llm: _FakeStreamingAnthropicProvider) -> None: - super().__init__(settings) - self._llm = llm - - def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, _FakeStreamingAnthropicProvider]]: - yield ModelCandidate(provider=LLMProvider.ANTHROPIC, model_id=model, name=f"anthropic:{model}"), self._llm + ]) + ) + invoked = [] + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("truncated") + with pytest.raises(republic.errors.StreamIncompleteError): + _ = [ + event + async for event in runner.run( + tape=tape, + model="openai:test", + tools=[Tool(name="echo", handler=lambda: invoked.append(True))], + system_prompt=None, + prompt="hello", + ) + ] + assert not invoked + assert not await tape.store.fetch_all(tape.query().kinds("tool_call", "tool_result")) @pytest.mark.asyncio -async def test_streaming_openai_usage_is_requested_and_recorded_in_tape( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize("next_model", ["google:model-one", "google:model-two", "openai:model-one"]) +async def test_provider_state_is_replayed_only_to_its_source_after_reload( + tmp_path: Path, provider_service: ProviderService, next_model: str ) -> None: - clock = iter([10.0, 12.0]) - monkeypatch.setattr("bub.builtin.model_runner.monotonic", lambda: next(clock)) - store = InMemoryTapeStore() - tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), TapeContext()).scoped("test-tape") - llm = _FakeStreamingOpenAIProvider() - runner = _FakeOpenAIModelRunner( - AgentSettings.model_construct(model="openai:gpt-test", max_tokens=100, model_timeout_seconds=None), - llm, - ) - - await tape.ensure_bootstrap_anchor() - events = [ - event async for event in runner.run(tape=tape, model="gpt-test", tools=[], system_prompt=None, prompt="hello") - ] - - assert llm.completion_kwargs is not None - assert llm.completion_kwargs["stream"] is True - assert llm.completion_kwargs["stream_options"] == {"include_usage": True} - assert [(event.kind, event.data) for event in events] == [ - ("text", {"delta": "done"}), - ( - "usage", + opaque = {"executableCode": {"language": "PYTHON", "code": "1 + 1"}} + provider_service.reply( + sse([ { - "usage": {"completion_tokens": 2, "prompt_tokens": 3, "total_tokens": 5}, - "elapsed_seconds": 2.0, - }, - ), - ("final", {"ok": True, "text": "done"}), - ] - run_events = [ - entry for entry in store.read("test-tape") or [] if entry.kind == "event" and entry.payload.get("name") == "run" - ] - assert len(run_events) == 1 - assert run_events[0].payload["data"]["usage"] == { - "completion_tokens": 2, - "prompt_tokens": 3, - "total_tokens": 5, - } - - -@pytest.mark.asyncio -async def test_anthropic_prompt_caching_is_requested() -> None: - llm = _FakeStreamingAnthropicProvider() - runner = _FakeAnthropicModelRunner( - AgentSettings.model_construct(model="anthropic:claude-test", max_tokens=100), - llm, + "candidates": [ + { + "content": { + "parts": [ + opaque, + { + "functionCall": {"id": "call-1", "name": "lookup", "args": {}}, + "thoughtSignature": "signature", + }, + ] + }, + "finishReason": "STOP", + } + ] + } + ]) ) - - await runner.completion_response(model="claude-test", messages=[{"role": "user", "content": "hello"}], tools=[]) - - assert llm.completion_kwargs is not None - assert llm.completion_kwargs["stream"] is True - assert llm.completion_kwargs["cache_control"] == {"type": "ephemeral"} - assert "stream_options" not in llm.completion_kwargs - - -@pytest.mark.asyncio -async def test_run_applies_reasoning_effort_from_tape_state(tmp_path: Path) -> None: - tape = Tape( - tmp_path, - AsyncTapeStoreAdapter(InMemoryTapeStore()), - TapeContext(state={"reasoning_effort": "high"}), - ).scoped("test-tape") - llm = _FakeStreamingOpenAIProvider() - runner = _FakeOpenAIModelRunner( - AgentSettings.model_construct( - model="openai:gpt-test", - max_tokens=100, - model_timeout_seconds=None, - completion_args={"reasoning_effort": "low"}, - ), - llm, + provider_service.reply( + sse( + chat_events() + if next_model.startswith("openai:") + else [{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}] + ) ) - - await tape.ensure_bootstrap_anchor() - events = runner.run(tape=tape, model="gpt-test", tools=[], system_prompt=None, prompt="hello") - [event async for event in events] - - assert llm.completion_kwargs is not None - assert llm.completion_kwargs["reasoning_effort"] == "high" + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("metadata") + await tape.ensure_bootstrap_anchor() + tools = [Tool(name="lookup", handler=lambda: "lookup result")] + _ = [ + event + async for event in runner.run( + tape=tape, model="google:model-one", tools=tools, system_prompt=None, prompt="lookup" + ) + ] + reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( + "metadata" + ) + if next_model.startswith("openai:"): + runner.settings.client_args["api_format"] = "chat" + events = [ + event + async for event in runner.run( + tape=reopened, model=next_model, tools=tools, system_prompt=None, prompt="Continue." + ) + ] + request = json.dumps(provider_service.body()) + assert "lookup result" in request + assert ("signature" in request) is (next_model == "google:model-one") + assert ("executableCode" in request) is (next_model == "google:model-one") + assert events[-1].data == {"ok": True, "text": "done"} @pytest.mark.asyncio -async def test_completion_args_are_forwarded_without_overriding_managed_args() -> None: - llm = _FakeStreamingOpenAIProvider() - runner = _FakeOpenAIModelRunner( - AgentSettings.model_construct( - model="openai:gpt-test", - max_tokens=100, - completion_args={ - "reasoning_effort": "high", - "model": "ignored-model", - "max_tokens": 1, - "stream": False, - "stream_options": {"include_usage": False}, - }, - ), - llm, +async def test_named_tool_choice_is_sent_to_anthropic(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply( + sse([ + {"type": "message_start", "message": {"id": "test", "usage": {}}}, + {"type": "message_stop"}, + ]) ) - - await runner.completion_response( - model="gpt-test", - messages=[{"role": "user", "content": "hello"}], - tools=[], - max_tokens=42, - ) - - assert llm.completion_kwargs is not None - assert llm.completion_kwargs["reasoning_effort"] == "high" - assert llm.completion_kwargs["model"] == "gpt-test" - assert llm.completion_kwargs["max_tokens"] == 42 - assert llm.completion_kwargs["stream"] is True - assert llm.completion_kwargs["stream_options"] == {"include_usage": True} + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings(client_args={"http_client": client}, completion_args={"tool_choice": republic.Tool("echo")}) + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("choice") + _ = [ + event + async for event in runner.run( + tape=tape, + model="anthropic:test", + tools=[Tool(name="echo", handler=lambda: "done")], + system_prompt=None, + prompt="hello", + ) + ] + assert provider_service.body()["tool_choice"] == {"type": "tool", "name": "echo"} diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 343775d2..ab8107fe 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -54,10 +54,10 @@ async def test_tape_info_formats_token_cache_hit_rate(tmp_path) -> None: new_messages=[], response_text=None, usage={ - "prompt_tokens": 8, - "completion_tokens": 2, + "input_tokens": 8, + "output_tokens": 2, "total_tokens": 10, - "prompt_tokens_details": {"cached_tokens": 3}, + "cached_tokens": 3, }, ) diff --git a/tests/test_channels.py b/tests/test_channels.py index 41b945db..52d55474 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1166,7 +1166,7 @@ async def source() -> asyncio.AsyncIterator[StreamEvent]: yield StreamEvent("text", {"delta": " "}) yield StreamEvent("text", {"delta": "first paragraph\n\n"}) yield StreamEvent("text", {"delta": "second paragraph"}) - yield StreamEvent("usage", {"usage": {"completion_tokens": 84}, "elapsed_seconds": 2.0}) + yield StreamEvent("usage", {"usage": {"output_tokens": 84}, "elapsed_seconds": 2.0}) yield StreamEvent("final", {}) yielded = [event async for event in channel.stream_events(message, source())] @@ -1181,15 +1181,15 @@ async def source() -> asyncio.AsyncIterator[StreamEvent]: @pytest.mark.parametrize( ("usage", "elapsed", "expected"), [ - ({"completion_tokens": 84, "prompt_tokens": 1000}, 2.0, "42.0"), + ({"output_tokens": 84, "input_tokens": 1000}, 2.0, "42.0"), ({"output_tokens": 21}, 2.0, "10.5"), - ({"completion_tokens": 0}, 2.0, "0.0"), + ({"output_tokens": 0}, 2.0, "0.0"), (None, 2.0, "-"), ({"total_tokens": 100}, 2.0, "-"), - ({"completion_tokens": True}, 2.0, "-"), - ({"completion_tokens": -1}, 2.0, "-"), - ({"completion_tokens": 84}, 0.0, "-"), - ({"completion_tokens": 84}, float("nan"), "-"), + ({"output_tokens": True}, 2.0, "-"), + ({"output_tokens": -1}, 2.0, "-"), + ({"output_tokens": 84}, 0.0, "-"), + ({"output_tokens": 84}, float("nan"), "-"), ], ) def test_cli_toolbar_shows_latest_call_token_speed(usage, elapsed, expected) -> None: diff --git a/tests/test_image_message.py b/tests/test_image_message.py index fb19fc03..0444c5e2 100644 --- a/tests/test_image_message.py +++ b/tests/test_image_message.py @@ -328,9 +328,9 @@ async def test_build_prompt_returns_multimodal_parts_with_image_media(tmp_path: assert "describe this" in text_part["text"] image_part = result[1] - assert image_part["type"] == "image_url" + assert image_part["type"] == "image" expected = base64.b64encode(b"\xff\xd8").decode("utf-8") - assert image_part["image_url"]["url"] == f"data:image/jpeg;base64,{expected}" + assert image_part["url"] == f"data:image/jpeg;base64,{expected}" @pytest.mark.asyncio @@ -350,12 +350,12 @@ async def test_build_prompt_with_multiple_images(tmp_path: Path) -> None: assert isinstance(result, list) assert len(result) == 3 - assert result[1]["type"] == "image_url" - assert result[2]["type"] == "image_url" + assert result[1]["type"] == "image" + assert result[2]["type"] == "image" @pytest.mark.asyncio -async def test_build_prompt_returns_video_url_part_with_video_media(tmp_path: Path) -> None: +async def test_build_prompt_returns_video_part_with_media(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -372,8 +372,9 @@ async def test_build_prompt_returns_video_url_part_with_video_media(tmp_path: Pa assert "describe this video" in result[0]["text"] expected = base64.b64encode(b"video").decode("utf-8") assert result[1] == { - "type": "video_url", - "video_url": {"url": f"data:video/mp4;base64,{expected}"}, + "type": "video", + "media_type": "video/mp4", + "url": f"data:video/mp4;base64,{expected}", } @@ -395,10 +396,10 @@ async def test_build_prompt_skips_video_when_download_is_too_large(tmp_path: Pat @pytest.mark.asyncio @pytest.mark.parametrize( - ("mime_type", "expected_format"), - [("audio/mpeg", "mp3"), ("audio/ogg", "ogg"), ("audio/x-wav", "wav")], + "mime_type", + ["audio/mpeg", "audio/ogg", "audio/x-wav"], ) -async def test_build_prompt_returns_input_audio_part(tmp_path: Path, mime_type: str, expected_format: str) -> None: +async def test_build_prompt_returns_audio_part(tmp_path: Path, mime_type: str) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -413,16 +414,14 @@ async def test_build_prompt_returns_input_audio_part(tmp_path: Path, mime_type: assert result[0]["type"] == "text" assert "listen to this" in result[0]["text"] assert result[1] == { - "type": "input_audio", - "input_audio": { - "data": base64.b64encode(b"audio").decode("utf-8"), - "format": expected_format, - }, + "type": "audio", + "media_type": mime_type, + "url": f"data:{mime_type};base64,{base64.b64encode(b'audio').decode('utf-8')}", } @pytest.mark.asyncio -async def test_build_prompt_skips_remote_audio_url(tmp_path: Path) -> None: +async def test_build_prompt_preserves_remote_audio_url(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -433,8 +432,8 @@ async def test_build_prompt_skips_remote_audio_url(tmp_path: Path) -> None: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, str) - assert "listen to this" in result + assert isinstance(result, list) + assert result[1] == {"type": "audio", "media_type": "audio/ogg", "url": "https://example.com/audio.ogg"} @pytest.mark.asyncio @@ -464,7 +463,7 @@ def test_extract_text_from_parts() -> None: parts = [ {"type": "text", "text": "hello"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,X"}}, + {"type": "image", "media_type": "image/jpeg", "url": "data:image/jpeg;base64,X"}, {"type": "text", "text": "world"}, ] assert _extract_text_from_parts(parts) == "hello\nworld" @@ -479,5 +478,5 @@ def test_extract_text_from_parts_empty() -> None: def test_extract_text_from_parts_no_text_parts() -> None: from bub.builtin.agent import _extract_text_from_parts - parts = [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,X"}}] + parts = [{"type": "image", "media_type": "image/jpeg", "url": "data:image/jpeg;base64,X"}] assert _extract_text_from_parts(parts) == "" diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 2ae8478b..22bfe09e 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -3,26 +3,24 @@ import asyncio import os from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch -import httpx +import httpx2 as httpx import pytest +import republic import typer -from any_llm import AnyLLM -from any_llm.constants import LLMProvider -from any_llm.exceptions import AuthenticationError +from republic.errors import AuthenticationError, UnsupportedFeatureError from typer.testing import CliRunner from bub import configure, inquirer -from bub.builtin import codex_provider, onboarding +from bub.builtin import onboarding +from bub.builtin.onboarding import discover_models from bub.framework import BubFramework from bub.hooks import hookimpl @pytest.fixture(autouse=True) def isolated_environment(monkeypatch): - monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: None) with patch.dict(os.environ, {}, clear=True): yield @@ -64,7 +62,7 @@ def discover(provider, api_base=None, api_key=None, **kwargs): return answers, calls -@pytest.mark.parametrize("provider", [p for p in onboarding.PROVIDERS if p != "custom"]) +@pytest.mark.parametrize("provider", [p for p in onboarding.PROVIDERS if p not in {"custom", "codex"}]) def test_all_providers_check_connection_before_selecting_models(prompts, provider): answers, calls = prompts answers["provider"] = provider @@ -73,7 +71,7 @@ def test_all_providers_check_connection_before_selecting_models(prompts, provide names = [name for name, _ in calls] assert names[0] == "LLM provider" assert names.index("API key (optional)") < names.index("discover") < names.index("LLM model (type to search)") - if provider in {"openai-compatible", "azure", "ollama"}: + if provider in {"openai-compatible", "azure-openai", "ollama"}: assert names.index("API base URL") < names.index("API key (optional)") else: assert "API base URL" not in names @@ -120,7 +118,12 @@ def discover(provider, api_base=None, api_key=None, **kwargs): config = onboarding.collect_model_config({}) assert probes == [("openai", "https://wrong.test/v1", "bad-key"), ("openai", "https://correct.test/v1", "good-key")] - assert config == {"model": "openai:model-b", "api_base": "https://correct.test/v1", "api_key": "good-key"} + assert config == { + "model": "openai:model-b", + "api_base": "https://correct.test/v1", + "api_key": "good-key", + "client_args": {"api_format": "chat"}, + } assert [name for name, _ in calls].count("LLM provider") == 1 assert "bad-key" not in capsys.readouterr().out @@ -163,17 +166,23 @@ def discover(provider, **kwargs): monkeypatch.setattr(onboarding, "discover_models", discover) config = onboarding.collect_model_config({}) assert probes == [ - {"api_base": "https://first.test/v1", "api_key": "first-key"}, - {"api_base": "https://first.test/v1", "api_key": "first-key"}, - {"api_base": "https://second.test/v1", "api_key": "second-key"}, + {"api_base": "https://first.test/v1", "api_key": "first-key", "env_prefix": "BUB_OPENAI", "api_format": "chat"}, + {"api_base": "https://first.test/v1", "api_key": "first-key", "env_prefix": "BUB_OPENAI", "api_format": "chat"}, + { + "api_base": "https://second.test/v1", + "api_key": "second-key", + "env_prefix": "BUB_OPENAI", + "api_format": "chat", + }, ] + assert config["client_args"] == {"api_format": "chat"} assert probes[-1] == onboarding.AgentSettings.model_validate(config).model_client_kwargs("openai") assert [name for name, _ in calls].count("LLM provider") == 1 def test_unsupported_discovery_goes_directly_to_manual_entry(prompts, monkeypatch, capsys): _, calls = prompts - monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=NotImplementedError)) + monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=UnsupportedFeatureError("not supported"))) menu = Mock(side_effect=AssertionError("Unavailable discovery must not offer edit/retry")) monkeypatch.setattr(inquirer, "ask_select", menu) @@ -185,28 +194,15 @@ def test_unsupported_discovery_goes_directly_to_manual_entry(prompts, monkeypatc menu.assert_not_called() -def test_azure_capability_skips_client_creation_and_prompts_for_model(prompts, monkeypatch, capsys): +def test_unavailable_discovery_goes_to_manual_entry(prompts, monkeypatch, capsys): answers, _ = prompts - answers["provider"] = "azure" - provider_class = SimpleNamespace( - SUPPORTS_LIST_MODELS=False, API_BASE=None, ENV_API_BASE_NAME="AZURE_AI_CHAT_ENDPOINT" - ) - monkeypatch.setattr(AnyLLM, "get_provider_class", lambda provider: provider_class) - create = Mock(side_effect=AssertionError("Unsupported discovery must not construct an SDK client")) - monkeypatch.setattr(AnyLLM, "create", create) - monkeypatch.setattr( - onboarding, - "discover_models", - lambda provider, **kwargs: asyncio.run(onboarding._discover_models(provider, **kwargs)), - ) + answers["provider"] = "azure-openai" + monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=UnsupportedFeatureError("not supported"))) menu = Mock(side_effect=AssertionError("Unsupported discovery must not offer retry")) monkeypatch.setattr(inquirer, "ask_select", menu) - config = onboarding.collect_model_config({}) - - assert config == {"model": "azure:model-b", "api_base": answers["base"], "api_key": answers["key"]} + assert config["model"] == "azure-openai:model-b" assert "Model discovery is unavailable" in capsys.readouterr().out - create.assert_not_called() menu.assert_not_called() @@ -231,12 +227,12 @@ def test_successful_discovery_still_allows_unlisted_model(prompts, monkeypatch): assert onboarding.collect_model_config({})["model"] == "openai:private-model" -def test_compatible_server_without_auth_gets_a_working_saved_key(prompts): +def test_compatible_server_without_auth_keeps_auth_unset(prompts): answers, calls = prompts answers.update(provider="openai-compatible", key="") config = onboarding.collect_model_config({}) - assert config["api_key"] == "not-required" - assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "not-required") + assert "api_key" not in config + assert dict(calls)["discover"] == ("openai", "https://example.test/v1", None) def test_environment_key_is_used_for_check_but_not_written_to_config(prompts, monkeypatch): @@ -249,64 +245,60 @@ def test_environment_key_is_used_for_check_but_not_written_to_config(prompts, mo assert "api_base" not in config -@pytest.mark.parametrize("variable", ["BUB_API_BASE", "BUB_OPENAI_API_BASE"]) -def test_connection_check_honors_environment_overrides_and_client_options(prompts, monkeypatch, capsys, variable): - monkeypatch.setenv(variable, "https://override.test/v1") - received = [] +@pytest.fixture +def model_discovery(prompts, monkeypatch): + requests = [] + constructor = httpx.AsyncClient - def discover(*args, **kwargs): - received.append((args, kwargs)) - return ["model-a", "model-b"] + def respond(request): + requests.append(request) + return httpx.Response(200, json={"data": [{"id": "model-a"}, {"id": "model-b"}]}) - monkeypatch.setattr(onboarding, "discover_models", discover) - onboarding.collect_model_config({"client_args": {"default_headers": {"X-Workspace": "test"}}}) - assert received == [ - ( - ("openai",), - {"api_base": "https://override.test/v1", "api_key": "test-key", "default_headers": {"X-Workspace": "test"}}, - ) - ] - assert "environment settings override" in capsys.readouterr().out + monkeypatch.setattr(onboarding, "discover_models", discover_models) + monkeypatch.setattr( + httpx, "AsyncClient", lambda **kwargs: constructor(**kwargs, transport=httpx.MockTransport(respond)) + ) + return requests -def test_connection_check_overrides_auth_fields_in_client_args_like_runtime(prompts, monkeypatch): - received = [] +@pytest.mark.parametrize("variable", ["BUB_API_BASE", "BUB_OPENAI_API_BASE"]) +def test_connection_check_honors_environment_endpoint_and_headers(model_discovery, monkeypatch, capsys, variable): + monkeypatch.setenv(variable, "https://override.test/v1") + config = onboarding.collect_model_config({"client_args": {"headers": {"X-Workspace": "test"}}}) + assert str(model_discovery[0].url) == "https://override.test/v1/models" + assert model_discovery[0].headers["X-Workspace"] == "test" + assert model_discovery[0].headers["authorization"] == "Bearer test-key" + assert config["model"] == "openai:model-b" + assert "environment settings override" in capsys.readouterr().out - def discover(*args, **kwargs): - received.append((args, kwargs)) - return ["model-a", "model-b"] - monkeypatch.setattr(onboarding, "discover_models", discover) - onboarding.collect_model_config({ - "client_args": { - "api_key": "unused-key", - "api_base": "https://unused.test/v1", - "timeout": 5, - } +def test_connection_check_uses_entered_credentials_and_endpoint(model_discovery): + config = onboarding.collect_model_config({ + "client_args": {"api_key": "unused-key", "api_base": "https://unused.test/v1", "timeout": 5} }) - assert received == [(("openai",), {"api_base": None, "api_key": "test-key", "timeout": 5})] + assert str(model_discovery[0].url) == "https://api.openai.com/v1/models" + assert model_discovery[0].headers["authorization"] == "Bearer test-key" + assert config["model"] == "openai:model-b" @pytest.mark.parametrize("compatible", [False, True]) -def test_other_provider_config_does_not_shadow_environment_connection(prompts, monkeypatch, compatible): - answers, calls = prompts +def test_other_provider_config_does_not_shadow_environment_connection( + prompts, model_discovery, monkeypatch, compatible +): + answers, _ = prompts answers.update(provider="openai-compatible" if compatible else "openai", key="") monkeypatch.setenv("BUB_OPENAI_API_KEY", "environment-key") monkeypatch.setenv("BUB_OPENAI_API_BASE", "https://example.test/v1") - current = { - "api_key": {"anthropic": "other-key"}, - "api_base": {"anthropic": "https://other.test"}, - } + current = {"api_key": {"anthropic": "other-key"}, "api_base": {"anthropic": "https://other.test"}} update = onboarding.collect_model_config(current) - assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "environment-key") + assert str(model_discovery[0].url) == "https://example.test/v1/models" + assert model_discovery[0].headers["authorization"] == "Bearer environment-key" assert "api_key" not in update merged = configure.merge({}, current, update) - settings = onboarding.AgentSettings.model_validate(merged) - assert settings.model_client_kwargs(LLMProvider.OPENAI) == { - "api_base": "https://example.test/v1", - "api_key": "environment-key", - } assert merged["api_key"] == {"anthropic": "other-key"} + assert merged["model"] == "openai:model-b" + if compatible: + assert merged["client_args"]["api_format"] == "chat" def test_provider_credentials_keep_other_providers_when_merged(prompts): @@ -354,9 +346,9 @@ def test_changed_endpoint_clears_saved_credentials_even_with_the_same_menu_choic "api_base": "https://old.test/v1", "api_key": "old-key", }) - assert dict(calls)["discover"] == ("openai", answers["base"], "not-required") + assert dict(calls)["discover"] == ("openai", answers["base"], None) assert "API key (Enter to keep current key)" not in dict(calls) - assert config["api_key"] == "not-required" + assert config["api_key"] is None def test_editing_endpoint_clears_key_before_blank_key_is_reused(prompts, monkeypatch): @@ -379,7 +371,7 @@ def discover(provider, **kwargs): "api_base": "https://first.test/v1", "api_key": "saved-key", }) - assert [probe["api_key"] for probe in probes] == ["saved-key", "not-required"] + assert [probe["api_key"] for probe in probes] == ["saved-key", None] assert [name for name, _ in calls].count("API key (Enter to keep current key)") == 1 assert config["api_base"] == "https://second.test/v1" @@ -431,7 +423,7 @@ def test_blank_compatible_key_uses_environment_for_discovery_and_saved_config(pr monkeypatch.setattr( onboarding, "discover_models", lambda *args, **kwargs: asyncio.run(real_discover(*args, **kwargs)) ) - real_create = AnyLLM.create + real_create = republic.get_provider requests = [] def respond(request): @@ -450,11 +442,11 @@ def respond(request): def create_client(provider, **kwargs): return real_create(provider, **kwargs, http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond))) - monkeypatch.setattr(AnyLLM, "create", create_client) + monkeypatch.setattr(republic, "get_provider", create_client) config = onboarding.collect_model_config({}) assert "api_key" not in config runtime_settings = onboarding.AgentSettings.model_validate(config) - onboarding.discover_models("openai", **runtime_settings.model_client_kwargs(LLMProvider.OPENAI)) + onboarding.discover_models("openai", **runtime_settings.model_client_kwargs("openai")) assert len(requests) == 2 assert all(request.headers["authorization"] == "Bearer environment-key" for request in requests) assert all(str(request.url) == "https://example.test/v1/models" for request in requests) @@ -469,42 +461,21 @@ def test_compatible_key_detects_provider_mapping_in_environment(prompts, monkeyp assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "environment-key") -def test_oauth_login_skips_api_key_discovery_and_preserves_runtime_auth(prompts, monkeypatch, capsys): +def test_codex_skips_api_key_and_model_discovery(prompts, capsys): answers, calls = prompts - answers["key"] = "" - monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object()) + answers.update(provider="codex", key="") config = onboarding.collect_model_config({}) - assert config == {"model": "openai:model-b"} + assert config == {"model": "codex:model-b"} assert "discover" not in dict(calls) - assert "OAuth login" in capsys.readouterr().out - assert codex_provider.should_use_openai_codex_provider("openai", "model-b", api_key=None, api_base=None) - - -@pytest.mark.parametrize("initial", [{}, {"api_base": None}, {"api_base": ""}, {"api_base": {"openai": ""}}]) -def test_leaving_default_url_blank_during_edit_does_not_disable_oauth(prompts, monkeypatch, initial): - answers, _ = prompts - answers.update(key="", base="") - actions = iter([onboarding.EDIT_CONNECTION, onboarding.MANUAL_MODEL]) - monkeypatch.setattr(inquirer, "ask_select", lambda *args, **kwargs: next(actions)) + assert "API key (optional)" not in dict(calls) + assert "Codex account login" in capsys.readouterr().out - def fail(*args, **kwargs): - raise AuthenticationError("no API key") - monkeypatch.setattr(onboarding, "discover_models", fail) - config = onboarding.collect_model_config(initial) - settings = onboarding.AgentSettings.model_validate(config) - api_base = settings.model_client_kwargs("openai")["api_base"] - assert not api_base - monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object()) - assert codex_provider.should_use_openai_codex_provider("openai", "model-b", api_key=None, api_base=api_base) - - -def test_explicit_official_base_does_not_switch_to_oauth(prompts, monkeypatch): +def test_openai_does_not_implicitly_select_codex(prompts): answers, calls = prompts answers["key"] = "" - monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object()) config = onboarding.collect_model_config({"model": "openai:model-b", "api_base": onboarding.OPENAI_BASE}) - assert config["api_base"] == onboarding.OPENAI_BASE + assert config["model"] == "openai:model-b" assert "discover" in dict(calls) @@ -553,27 +524,18 @@ def test_compatible_url_is_validated_before_sending_credentials(monkeypatch, inv def test_discovery_uses_entered_endpoint_and_credentials_and_closes_client(monkeypatch): requests = [] clients = [] - create = AnyLLM.create + constructor = httpx.AsyncClient def respond(request): requests.append(request) - return httpx.Response( - 200, - json={ - "object": "list", - "data": [ - {"id": name, "object": "model", "created": 0, "owned_by": "test"} - for name in ["model-b", "model-a", "model-a"] - ], - }, - ) + return httpx.Response(200, json={"data": [{"id": name} for name in ["model-b", "model-a", "model-a"]]}) - def create_client(provider, **kwargs): - client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + def create_client(**kwargs): + client = constructor(**kwargs, transport=httpx.MockTransport(respond)) clients.append(client) - return create(provider, **kwargs, http_client=client) + return client - monkeypatch.setattr(AnyLLM, "create", create_client) + monkeypatch.setattr(httpx, "AsyncClient", create_client) assert onboarding.discover_models("openai", api_base="https://example.test/custom/v1", api_key="test-key") == [ "model-a", "model-b", @@ -586,22 +548,42 @@ def create_client(provider, **kwargs): def test_discovery_timeout_cancels_request_and_closes_client(monkeypatch): - closed = AsyncMock() + clients = [] cancelled = [] + constructor = httpx.AsyncClient - async def list_models(): + async def respond(request): try: await asyncio.Event().wait() finally: cancelled.append(True) + def create_client(**kwargs): + client = constructor(**kwargs, transport=httpx.MockTransport(respond)) + clients.append(client) + return client + monkeypatch.setattr(onboarding, "CONNECTION_TIMEOUT", 0.01) - monkeypatch.setattr( - AnyLLM, - "create", - lambda *args, **kwargs: SimpleNamespace(alist_models=list_models, client=SimpleNamespace(close=closed)), - ) + monkeypatch.setattr(httpx, "AsyncClient", create_client) with pytest.raises(TimeoutError): onboarding.discover_models("openai", api_base="https://example.test/v1", api_key="test-key") assert cancelled == [True] - closed.assert_awaited_once() + assert clients[0].is_closed + + +def test_onboarding_updates_named_endpoint_and_discovers_its_provider_type(prompts, monkeypatch): + _, calls = prompts + monkeypatch.setattr( + inquirer, "ask_fuzzy", lambda message, **kwargs: "relay" if message == "LLM provider" else "model-b" + ) + original = { + "model": "relay:sample", + "providers": {"relay": {"type": "openai", "api_base": "https://old.test/v1", "api_key": "old-key"}}, + } + update = onboarding.collect_model_config(original) + settings = onboarding.AgentSettings.model_validate(configure.merge({}, original, update)) + assert update["model"] == "relay:model-b" + assert settings.providers["relay"].type == "openai" + assert settings.providers["relay"].api_base == "https://example.test/v1" + assert settings.providers["relay"].api_key == "test-key" + assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "test-key") diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py new file mode 100644 index 00000000..13734b30 --- /dev/null +++ b/tests/test_provider_messages.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path + +import httpx2 +import pytest + +from bub.builtin.context import default_tape_context +from bub.builtin.model_runner import ModelRunner +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.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.tape import Tape, TapeContext +from bub.tools import Tool +from tests.model_fakes import ProviderService, chat_events, sse + + +@pytest.mark.asyncio +async def test_channel_media_reaches_gemini_and_survives_tape_reload( + tmp_path: Path, provider_service: ProviderService +) -> None: + async def image(): + return b"image" + + async def audio(): + return b"audio" + + async def video(): + return b"video" + + framework = BubFramework(config_file=tmp_path / "config.yml") + framework.load_builtin_hooks() + channel = ChannelMessage( + session_id="media", + channel="telegram", + content="Describe the attachments.", + media=[ + MediaItem("image", "image/png", data_fetcher=image), + MediaItem("audio", "audio/ogg", data_fetcher=audio), + MediaItem("video", "video/mp4", data_fetcher=video), + ], + ) + prompt = await framework.build_prompt(channel, channel.session_id, {}) + for _ in range(2): + provider_service.reply( + sse([{"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}]) + ) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("media") + await tape.ensure_bootstrap_anchor() + events = [ + event + async for event in runner.run(tape=tape, model="google:test", tools=[], system_prompt=None, prompt=prompt) + ] + reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( + "media" + ) + follow_up = [ + event + async for event in runner.run( + tape=reopened, model="google:test", tools=[], system_prompt=None, prompt="Continue." + ) + ] + expected = [ + {"inlineData": {"mimeType": "image/png", "data": "aW1hZ2U="}}, + {"inlineData": {"mimeType": "audio/ogg", "data": "YXVkaW8="}}, + {"inlineData": {"mimeType": "video/mp4", "data": "dmlkZW8="}}, + ] + assert provider_service.body(0)["contents"][0]["parts"][1:] == expected + assert provider_service.body(1)["contents"][0]["parts"][1:] == expected + assert events[-1].data == {"ok": True, "text": "ready"} + assert follow_up[-1].data == {"ok": True, "text": "ready"} + + +@pytest.mark.asyncio +async def test_consumer_close_releases_the_request_without_closing_the_shared_client(tmp_path: Path) -> None: + closed = asyncio.Event() + + class Body(httpx2.AsyncByteStream): + async def __aiter__(self): + yield b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n' + await asyncio.Event().wait() + + async def aclose(self): + closed.set() + + async with httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda _: httpx2.Response(200, stream=Body())) + ) as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cancel") + events = runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + assert (await anext(events)).data == {"delta": "first"} + await events.aclose() + assert closed.is_set() + assert not client.is_closed + assert not await tape.store.fetch_all(tape.query().kinds("message")) + + +@pytest.mark.asyncio +async def test_primary_success_does_not_require_an_available_fallback( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings( + fallback_models=["not-installed:test"], client_args={"http_client": client, "api_format": "chat"} + ) + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") + events = [ + event + async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + ] + assert len(provider_service.requests) == 1 + assert events[-1].data == {"ok": True, "text": "done"} + + +@pytest.mark.asyncio +async def test_fresh_process_replays_tool_signatures_to_the_provider( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply( + sse([ + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": {"id": "call-1", "name": "lookup", "args": {}}, + "thoughtSignature": "signature", + } + ] + }, + "finishReason": "STOP", + } + ] + } + ]) + ) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( + "persisted" + ) + await tape.ensure_bootstrap_anchor() + _ = [ + event + async for event in runner.run( + tape=tape, + model="google:test", + tools=[Tool(name="lookup", handler=lambda: "lookup result")], + system_prompt=None, + prompt="Look it up.", + ) + ] + code = """ +import asyncio, json, sys +from pathlib import Path +import httpx2 +from bub.builtin.context import default_tape_context +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings +from bub.store import FileTapeStore, AsyncTapeStoreAdapter +from bub.tape import Tape +async def main(): + bodies = [] + def respond(request): + bodies.append(json.loads(request.content)) + event = {"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]} + return httpx2.Response(200, headers={"content-type": "text/event-stream"}, text="data: " + json.dumps(event) + "\\n\\n") + async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as client: + directory = Path(sys.argv[1]) + tape = Tape(directory, AsyncTapeStoreAdapter(FileTapeStore(directory)), default_tape_context()).scoped("persisted") + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + events = [event async for event in runner.run(tape=tape, model="google:test", tools=[], system_prompt=None, prompt="Continue.")] + print(json.dumps({"request": bodies[0], "result": events[-1].data})) +asyncio.run(main()) +""" + result = subprocess.run([sys.executable, "-c", code, str(tmp_path)], capture_output=True, text=True, check=True) + replay = json.loads(result.stdout) + assistant = next(content for content in replay["request"]["contents"] if content["role"] == "model") + call = next(part for part in assistant["parts"] if "functionCall" in part) + assert call["functionCall"] == {"id": "call-1", "name": "lookup", "args": {}} + assert call["thoughtSignature"] == "signature" + assert "lookup result" in json.dumps(replay["request"]) + assert replay["result"] == {"ok": True, "text": "ready"} + + +@pytest.mark.asyncio +async def test_media_urls_preserve_explicit_mime_types(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply(sse([{"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}])) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("urls") + events = [ + event + async for event in runner.run( + tape=tape, + model="google:test", + tools=[], + system_prompt=None, + prompt=[ + { + "type": "image", + "media_type": "image/png", + "url": "https://example.test/photo.png?signature=opaque", + }, + {"type": "image", "media_type": "image/webp", "url": "https://example.test/attachment"}, + ], + ) + ] + assert provider_service.body()["contents"][0]["parts"] == [ + {"fileData": {"mimeType": "image/png", "fileUri": "https://example.test/photo.png?signature=opaque"}}, + {"fileData": {"mimeType": "image/webp", "fileUri": "https://example.test/attachment"}}, + ] + assert events[-1].data == {"ok": True, "text": "ready"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "block", + [ + {"type": "image", "url": "https://example.test/photo"}, + {"type": "audio", "media_type": "audio/wav"}, + {"type": "document", "media_type": "application/pdf", "url": "https://example.test/file.pdf"}, + ], +) +async def test_invalid_content_fails_without_a_provider_request( + tmp_path: Path, provider_service: ProviderService, block: dict +) -> None: + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid") + with pytest.raises(BubError) as exc: + _ = [ + event + async for event in runner.run( + tape=tape, model="google:test", tools=[], system_prompt=None, prompt=[block] + ) + ] + assert exc.value.kind == ErrorKind.INVALID_INPUT + assert not provider_service.requests diff --git a/tests/test_settings.py b/tests/test_settings.py index 5c34d950..a6d445f6 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,15 +1,20 @@ from __future__ import annotations import os +from pathlib import Path from unittest.mock import patch +import httpx2 import pytest -from any_llm.constants import LLMProvider from pydantic import ValidationError +from bub.builtin.model_runner import ModelRunner from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, load_settings from bub.builtin.spill import SpillSettings from bub.configure import ensure_config +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.tape import Tape, TapeContext +from tests.model_fakes import ProviderService, chat_events, sse def _settings_with_env(env: dict[str, str]) -> AgentSettings: @@ -53,20 +58,6 @@ def test_settings_no_keys_return_none() -> None: assert settings.completion_args == {} -@pytest.mark.parametrize("provider", ["openai", LLMProvider.OPENAI, "acme"]) -def test_client_options_resolve_provider_names_and_enum_values(provider: str) -> None: - settings = _settings_with_env({ - f"BUB_{provider.upper()}_API_KEY": "environment-key", - f"BUB_{provider.upper()}_API_BASE": "https://example.test/v1", - "BUB_CLIENT_ARGS": '{"api_key": "ignored-key", "api_base": "https://ignored.test", "timeout": 5}', - }) - assert settings.model_client_kwargs(provider) == { - "api_key": "environment-key", - "api_base": "https://example.test/v1", - "timeout": 5, - } - - def test_settings_provider_names_are_lowercased() -> None: settings = _settings_with_env({"BUB_OPENROUTER_API_KEY": "sk-or"}) @@ -98,7 +89,7 @@ def test_settings_load_values_from_yaml(load_config) -> None: api_base: openai: https://api.openai.com client_args: - extra_headers: + headers: HTTP-Referer: https://openclaw.ai X-Title: OpenClaw completion_args: @@ -114,7 +105,7 @@ def test_settings_load_values_from_yaml(load_config) -> None: assert settings.api_key == {"openai": "sk-yaml"} assert settings.api_base == {"openai": "https://api.openai.com"} assert settings.client_args == { - "extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"}, + "headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"}, } assert settings.completion_args == {"reasoning_effort": "high"} @@ -125,7 +116,7 @@ def test_env_settings_override_yaml(load_config) -> None: api_key: sk-yaml max_steps: 77 client_args: - extra_headers: + headers: HTTP-Referer: https://yaml.example X-Title: YAML App """.strip() @@ -135,7 +126,7 @@ def test_env_settings_override_yaml(load_config) -> None: { "BUB_MODEL": "anthropic:claude-3-7-sonnet", "BUB_API_KEY": "sk-env", - "BUB_CLIENT_ARGS": '{"extra_headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}', + "BUB_CLIENT_ARGS": '{"headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}', "BUB_COMPLETION_ARGS": '{"reasoning_effort":"medium"}', "BUB_MAX_STEPS": "12", }, @@ -148,7 +139,7 @@ def test_env_settings_override_yaml(load_config) -> None: assert settings.api_key == "sk-env" assert settings.max_steps == 12 assert settings.client_args == { - "extra_headers": {"HTTP-Referer": "https://env.example", "X-Title": "Env App"}, + "headers": {"HTTP-Referer": "https://env.example", "X-Title": "Env App"}, } assert settings.completion_args == {"reasoning_effort": "medium"} @@ -194,41 +185,163 @@ def test_load_settings_returns_loaded_config(load_config) -> None: assert settings.model == "openrouter:openrouter/free" -def test_custom_provider_names_an_endpoint_beside_the_builtin_ones(load_config) -> None: +async def _run(settings: AgentSettings, tmp_path: Path) -> str: + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("settings") + events = [ + event + async for event in ModelRunner(settings).run( + tape=tape, model=settings.model, tools=[], system_prompt=None, prompt="Hello" + ) + ] + assert events[-1].data["ok"] + return events[-1].data["text"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider", ["openai", "acme"]) +async def test_client_options_resolve_provider_names( + provider: str, tmp_path: Path, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat() + env = { + f"BUB_{provider.upper()}_API_KEY": "environment-key", + f"BUB_{provider.upper()}_API_BASE": "https://example.test/v1", + "BUB_CLIENT_ARGS": '{"api_key": "ignored-key", "api_base": "https://ignored.test", "api_format": "chat"}', + } + if provider == "acme": + env["BUB_PROVIDERS"] = '{"acme": {"type": "openai"}}' + settings = _settings_with_env(env) + settings.model = f"{provider}:model" + assert await _run(settings, tmp_path) == "done" + assert provider_transport.requests[0].url.host == "example.test" + assert provider_transport.requests[0].headers["authorization"] == "Bearer environment-key" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider,header", [("google", "x-goog-api-key"), ("azure-openai", "api-key"), ("openrouter", "authorization")] +) +@pytest.mark.parametrize("explicit_key", [False, True]) +async def test_provider_requests_use_environment_or_explicit_credentials( + provider: str, header: str, explicit_key: bool, tmp_path: Path, provider_service: ProviderService +) -> None: + prefix = provider.upper().replace("-", "_") + provider_service.reply( + sse( + [{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}] + if provider == "google" + else chat_events() + ) + ) + with patch.dict( + os.environ, + {f"{prefix}_API_KEY": "environment-key", f"{prefix}_API_BASE": "https://provider.test/v1"}, + clear=True, + ): + async with provider_service.client() as client: + settings = AgentSettings( + model=f"{provider}:model:variant", + api_key={provider: "explicit-key"} if explicit_key else None, + client_args={"http_client": client, "api_format": "gemini" if provider == "google" else "chat"}, + ) + assert await _run(settings, tmp_path) == "done" + request = provider_service.requests[0] + key = "explicit-key" if explicit_key else "environment-key" + assert request.headers[header] == (f"Bearer {key}" if header == "authorization" else key) + assert request.url.host == "provider.test" + if provider == "google": + assert "model:variant" in request.url.path + else: + assert provider_service.body()["model"] == "model:variant" + + +@pytest.mark.asyncio +async def test_wire_extras_preserve_gemini_options_without_replacing_the_prompt_or_token_budget( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse([{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}])) + async with provider_service.client() as client: + settings = AgentSettings( + model="google:test", + max_tokens=100, + client_args={ + "http_client": client, + "extra_body": {"contents": [], "generationConfig": {"maxOutputTokens": 1, "temperature": 0.4}}, + }, + ) + assert await _run(settings, tmp_path) == "done" + assert provider_service.body()["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}] + assert provider_service.body()["generationConfig"]["maxOutputTokens"] == 100 + assert provider_service.body()["generationConfig"]["temperature"] == 0.4 + + +@pytest.mark.asyncio +async def test_named_endpoint_and_builtin_fallback_use_separate_credentials( + load_config, tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}})) + provider_service.reply(sse(chat_events())) with patch.dict(os.environ, {}, clear=True): - load_config( - """ + load_config(""" model: relay:qwen/kimi-k3 fallback_models: - openai:gpt-5 api_key: - openai: sk-openai + openai: openai-key +api_base: + openai: https://openai.test/v1 providers: relay: type: openai api_base: https://relay.test/v1 api_key: relay-key -""".strip(), - ) - - settings = load_settings() - - relay, openai = settings.model_candidates(settings.model) - assert (relay.provider, relay.model_id, relay.provider_name) == (LLMProvider.OPENAI, "qwen/kimi-k3", "relay") - assert (openai.provider, openai.model_id, openai.provider_name) == (LLMProvider.OPENAI, "gpt-5", None) - assert settings.model_client_kwargs("relay") == {"api_key": "relay-key", "api_base": "https://relay.test/v1"} - assert settings.model_client_kwargs("openai") == {"api_key": "sk-openai", "api_base": None} - - -def test_custom_provider_falls_back_to_per_provider_settings() -> None: - settings = _settings_with_env({ - "BUB_PROVIDERS": '{"relay": {"type": "anthropic", "api_base": "https://relay.test"}}', - "BUB_RELAY_API_KEY": "env-key", - }) - - assert settings.model_client_kwargs("relay") == {"api_key": "env-key", "api_base": "https://relay.test"} +""") + async with provider_service.client() as client: + settings = load_settings() + settings.client_args = {"http_client": client, "api_format": "chat", "max_retries": 0} + assert await _run(settings, tmp_path) == "done" + assert [request.url.host for request in provider_service.requests] == ["relay.test", "openai.test"] + assert [request.headers["authorization"] for request in provider_service.requests] == [ + "Bearer relay-key", + "Bearer openai-key", + ] + assert [provider_service.body(index)["model"] for index in range(2)] == ["qwen/kimi-k3", "gpt-5"] + + +@pytest.mark.asyncio +async def test_named_endpoint_uses_its_environment_credentials( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply( + sse([ + {"type": "message_start", "message": {"id": "test", "usage": {}}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "done"}}, + {"type": "message_stop"}, + ]) + ) + with patch.dict( + os.environ, + { + "BUB_MODEL": "relay:test", + "BUB_PROVIDERS": '{"relay": {"type": "anthropic", "api_base": "https://relay.test"}}', + "BUB_RELAY_API_KEY": "environment-key", + }, + clear=True, + ): + async with provider_service.client() as client: + settings = AgentSettings(client_args={"http_client": client}) + assert await _run(settings, tmp_path) == "done" + assert provider_service.requests[0].url.host == "relay.test" + assert provider_service.requests[0].headers["x-api-key"] == "environment-key" def test_custom_provider_rejects_unknown_type() -> None: with pytest.raises(ValidationError, match="providers"): _settings_with_env({"BUB_PROVIDERS": '{"relay": {"type": "nope"}}'}) + + +@pytest.mark.parametrize("provider", ["azure-openai", "github-copilot"]) +def test_hyphenated_provider_names_resolve_environment_credentials(provider: str) -> None: + prefix = provider.upper().replace("-", "_") + settings = _settings_with_env({f"BUB_{prefix}_API_KEY": "provider-key"}) + assert settings.api_key == {provider: "provider-key"} diff --git a/tests/test_spill.py b/tests/test_spill.py index 1e05cb01..7faaef3e 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -6,7 +6,6 @@ import pluggy import pytest -from any_llm.types.completion import ChatCompletion from bub.builtin.context import default_tape_context from bub.builtin.hook_impl import BuiltinImpl @@ -27,6 +26,7 @@ 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 class _SpillHooks: @@ -121,7 +121,7 @@ async def test_oversized_result_is_bounded_and_readable_across_merge(tmp_path: P system_prompt=None, new_messages=[], response_text=None, - tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "large", "arguments": "{}"}}], + tool_calls=[{"id": "call-1", "name": "large", "arguments": "{}"}], tool_results=execution.tool_results, ) request_messages = await tape.read_messages() @@ -333,7 +333,7 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp system_prompt=None, new_messages=[{"role": "user", "content": "archive this"}], response_text=None, - tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "large", "arguments": "{}"}}], + tool_calls=[{"id": "call-1", "name": "large", "arguments": "{}"}], tool_results=execution.tool_results, ) @@ -366,7 +366,7 @@ async def test_code_mode_results_stay_structured_and_are_not_spilled(tmp_path: P @pytest.mark.parametrize("fails", [False, True]) @pytest.mark.asyncio async def test_result_spill_requires_reader_in_current_model_tools( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fails: bool + tmp_path: Path, provider_transport: ProviderService, fails: bool ) -> None: class SpillPlugin: @hookimpl(trylast=True) @@ -377,7 +377,7 @@ async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: d plugin_manager.add_hookspecs(BubHookSpecs) plugin_manager.register(SpillPlugin()) runner = ModelRunner( - AgentSettings.model_construct(model="test-model", model_timeout_seconds=None), + AgentSettings(model="openai:test", client_args={"api_format": "chat"}), hooks=AgentHooks(HookRuntime(plugin_manager)), ) output = "large output " * 2000 @@ -387,32 +387,8 @@ def large() -> str: raise RuntimeError(output) return output - async def complete(**kwargs: Any) -> ChatCompletion: - return ChatCompletion.model_validate({ - "id": "completion-1", - "object": "chat.completion", - "created": 0, - "model": "test-model", - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call-large", - "type": "function", - "function": {"name": "large", "arguments": "{}"}, - } - ], - }, - } - ], - }) - - monkeypatch.setattr(runner, "completion_response", complete) + for _ in range(3): + provider_transport.reply_chat(calls=[{"id": "call-large", "name": "large", "arguments": "{}"}]) root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) async with root.fork_tape() as tape: for reader_available in (False, True, False): @@ -422,7 +398,7 @@ async def complete(**kwargs: Any) -> ChatCompletion: events = [ event async for event in runner.run( - tape=tape, model="test-model", tools=model_tools(tools), system_prompt=None, prompt="Run large." + tape=tape, model="openai:test", tools=model_tools(tools), system_prompt=None, prompt="Run large." ) ] result = next(event.data["tool_results"][0] for event in events if event.kind == "tool_result") diff --git a/tests/test_tape.py b/tests/test_tape.py index 242b4433..8d0dfa15 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -33,7 +33,7 @@ async def test_legacy_tool_call_without_content_replays_with_its_result(tmp_path store = InMemoryTapeStore() tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape") await tape.ensure_bootstrap_anchor() - calls = [{"id": "call-1", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}] + calls = [{"id": "call-1", "name": "inspect", "arguments": "{}"}] store.append("test-tape", TapeEntry(id=0, kind="tool_call", payload={"calls": calls})) store.append("test-tape", TapeEntry.tool_result(["files found"])) @@ -87,10 +87,10 @@ async def test_tape_info_reports_last_token_cache_hit_rate(tmp_path: Path) -> No new_messages=[], response_text=None, usage={ - "prompt_tokens": 80, - "completion_tokens": 20, + "input_tokens": 80, + "output_tokens": 20, "total_tokens": 100, - "prompt_tokens_details": {"cached_tokens": 60}, + "cached_tokens": 60, }, ) @@ -108,7 +108,7 @@ async def test_tape_info_omits_cache_hit_rate_when_usage_has_no_cache_details(tm system_prompt=None, new_messages=[], response_text=None, - usage={"prompt_tokens": 80, "completion_tokens": 20, "total_tokens": 100}, + usage={"input_tokens": 80, "output_tokens": 20, "total_tokens": 100}, ) info = await tape.info() diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py index 2ef62e4c..19adac98 100644 --- a/tests/test_tool_loading.py +++ b/tests/test_tool_loading.py @@ -5,31 +5,17 @@ from typing import Any import pytest -from any_llm.types.completion import ChatCompletion from bub.builtin.agent import Agent from bub.framework import BubFramework from bub.tools import Tool - - -def _completion(message: dict[str, Any]) -> ChatCompletion: - return ChatCompletion.model_validate({ - "id": "reply", - "model": "test-model", - "created": 0, - "object": "chat.completion", - "choices": [ - {"index": 0, "finish_reason": "tool_calls" if "tool_calls" in message else "stop", "message": message} - ], - }) +from tests.model_fakes import ProviderService def _tool_call(call_id: str, name: str, arguments: dict[str, Any]) -> dict[str, Any]: return { "role": "assistant", - "tool_calls": [ - {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments)}} - ], + "tool_calls": [{"id": call_id, "name": name, "arguments": json.dumps(arguments)}], } @@ -44,29 +30,22 @@ def _system_prompt(request: dict[str, Any]) -> str: @pytest.fixture def framework(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> BubFramework: monkeypatch.setenv("BUB_HOME", str(tmp_path)) + monkeypatch.setenv("BUB_CLIENT_ARGS", '{"api_format":"chat"}') framework = BubFramework(config_file=tmp_path / "config.yml") framework.workspace = tmp_path framework.load_builtin_hooks() return framework -def _scripted_provider(monkeypatch: pytest.MonkeyPatch, replies: list[dict[str, Any]]) -> list[dict[str, Any]]: - requests: list[dict[str, Any]] = [] - - class Provider: - SUPPORTS_COMPLETION_STREAMING = False - - async def acompletion(self, **kwargs: Any) -> ChatCompletion: - requests.append(kwargs) - return _completion(replies[len(requests) - 1]) - - monkeypatch.setattr("bub.builtin.model_runner.AnyLLM.create", lambda *args, **kwargs: Provider()) - return requests +def _scripted_provider(provider: ProviderService, replies: list[dict[str, Any]]) -> ProviderService: + for message in replies: + provider.reply_chat(message.get("content", ""), message.get("tool_calls")) + return provider @pytest.mark.asyncio async def test_deferred_tool_is_loaded_through_tool_describe_and_persists_on_tape( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: calls: list[str] = [] @@ -78,7 +57,7 @@ def lookup(name: str) -> str: supplied = Tool.from_callable(lookup, name="provider.lookup", deferred=True) other = Tool.from_callable(lambda: "other", name="other", deferred=True) requests = _scripted_provider( - monkeypatch, + provider_transport, [ _tool_call("describe", "tool_describe", {"names": ["provider_lookup"]}), _tool_call("lookup", "provider_lookup", {"name": "Ada"}), @@ -93,30 +72,30 @@ def lookup(name: str) -> str: assert any(event.data.get("text") == "Hello Ada" for event in events if event.kind == "final") assert calls == ["Ada"] - assert _tool_names(requests[0]) == ["direct", "tool_describe"] - assert _tool_names(requests[1]) == ["direct", "tool_describe", "provider_lookup"] - describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + assert _tool_names(requests.body(0)) == ["direct", "tool_describe"] + assert _tool_names(requests.body(1)) == ["direct", "tool_describe", "provider_lookup"] + describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert json.loads(describe_result["content"])["tools"][0]["name"] == "provider_lookup" - prompt = _system_prompt(requests[0]) + prompt = _system_prompt(requests.body(0)) assert "" in prompt assert "- provider_lookup" in prompt assert "- other" in prompt stream = await agent.run_stream(session_id="loading", prompt="Again.", model="openrouter:test-model") _ = [event async for event in stream] - assert _tool_names(requests[3]) == ["direct", "tool_describe", "provider_lookup"] + assert _tool_names(requests.body(3)) == ["direct", "tool_describe", "provider_lookup"] @pytest.mark.asyncio async def test_allowed_tools_filter_covers_direct_and_deferred_tools( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: direct = Tool.from_callable(lambda: "direct", name="direct") denied = Tool.from_callable(lambda: "denied", name="denied") allowed_deferred = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True) denied_deferred = Tool.from_callable(lambda: "secret", name="secret", deferred=True) requests = _scripted_provider( - monkeypatch, + provider_transport, [ _tool_call("describe", "tool_describe", {"names": ["provider_lookup", "secret"]}), {"role": "assistant", "content": "done"}, @@ -132,40 +111,40 @@ async def test_allowed_tools_filter_covers_direct_and_deferred_tools( ) _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["direct", "tool_describe"] - assert _tool_names(requests[1]) == ["direct", "tool_describe", "provider_lookup"] - prompt = _system_prompt(requests[0]) + assert _tool_names(requests.body(0)) == ["direct", "tool_describe"] + assert _tool_names(requests.body(1)) == ["direct", "tool_describe", "provider_lookup"] + prompt = _system_prompt(requests.body(0)) assert "- provider_lookup" in prompt assert "secret" not in prompt - describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert json.loads(describe_result["content"])["unknown"] == ["secret"] @pytest.mark.asyncio async def test_tool_describe_is_hidden_without_deferred_tools( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: from bub.builtin.tools import tool_describe direct = Tool.from_callable(lambda: "direct", name="direct") - requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}]) + requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) agent = Agent(framework, tools=[direct, tool_describe], skill_dirs=[]) stream = await agent.run_stream(session_id="plain", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["direct"] - assert "" not in _system_prompt(requests[0]) + assert _tool_names(requests.body(0)) == ["direct"] + assert "" not in _system_prompt(requests.body(0)) @pytest.mark.asyncio async def test_calling_unloaded_deferred_tool_returns_load_guidance( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: calls: list[str] = [] supplied = Tool.from_callable(lambda: calls.append("lookup") or "ok", name="provider.lookup", deferred=True) requests = _scripted_provider( - monkeypatch, + provider_transport, [_tool_call("lookup", "provider_lookup", {}), {"role": "assistant", "content": "done"}], ) agent = Agent(framework, tools=[supplied], skill_dirs=[]) @@ -174,33 +153,33 @@ async def test_calling_unloaded_deferred_tool_returns_load_guidance( _ = [event async for event in stream] assert calls == [] - result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert result["content"] == "Tool `provider_lookup` is not loaded. Call `tool_describe` with its name first." @pytest.mark.asyncio async def test_code_mode_exposes_deferred_tools_to_code_without_loading( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + framework: BubFramework, provider_transport: ProviderService, tmp_path: Path ) -> None: from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, run_code direct = Tool.from_callable(lambda: "direct", name="direct") supplied = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True) - requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}]) + requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) agent = Agent(framework, tools=[direct, supplied, run_code], skill_dirs=[]) state: dict[str, Any] = {"code_mode": True, "_runtime_workspace": str(tmp_path)} stream = await agent.run_stream(session_id="code", prompt="Hi.", model="openrouter:test-model", state=state) _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["run_code"] + assert _tool_names(requests.body(0)) == ["run_code"] assert [tool.name for tool in state[CODE_TOOLS_STATE_KEY]] == ["direct", "provider_lookup"] - assert "" not in _system_prompt(requests[0]) + assert "" not in _system_prompt(requests.body(0)) @pytest.mark.asyncio async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: from bub.builtin.tools import tool_describe @@ -208,7 +187,7 @@ async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode( code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code") deferred_code_tool = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True, exposure="code") requests = _scripted_provider( - monkeypatch, + provider_transport, [_tool_call("secret", "secret", {}), {"role": "assistant", "content": "done"}], ) agent = Agent(framework, tools=[direct, code_tool, deferred_code_tool, tool_describe], skill_dirs=[]) @@ -216,41 +195,41 @@ async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode( stream = await agent.run_stream(session_id="plain", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["direct"] - assert "" not in _system_prompt(requests[0]) - result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + assert _tool_names(requests.body(0)) == ["direct"] + assert "" not in _system_prompt(requests.body(0)) + result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert result["content"].startswith("Tool `secret` does not exist.") @pytest.mark.asyncio async def test_code_exposure_tools_are_exposed_to_code_in_code_mode( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + framework: BubFramework, provider_transport: ProviderService, tmp_path: Path ) -> None: from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, run_code direct = Tool.from_callable(lambda: "direct", name="direct") code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code") - requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}]) + requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) agent = Agent(framework, tools=[direct, code_tool, run_code], skill_dirs=[]) state: dict[str, Any] = {"code_mode": True, "_runtime_workspace": str(tmp_path)} stream = await agent.run_stream(session_id="code", prompt="Hi.", model="openrouter:test-model", state=state) _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["run_code"] + assert _tool_names(requests.body(0)) == ["run_code"] assert [tool.name for tool in state[CODE_TOOLS_STATE_KEY]] == ["direct", "secret"] @pytest.mark.asyncio async def test_tool_describe_does_not_describe_code_exposure_tools( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: from bub.builtin.tools import tool_describe deferred = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True) code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code") requests = _scripted_provider( - monkeypatch, + provider_transport, [_tool_call("describe", "tool_describe", {"names": ["secret"]}), {"role": "assistant", "content": "done"}], ) agent = Agent(framework, tools=[deferred, code_tool, tool_describe], skill_dirs=[]) @@ -258,5 +237,5 @@ async def test_tool_describe_does_not_describe_code_exposure_tools( stream = await agent.run_stream(session_id="describe", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] - describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert json.loads(describe_result["content"]) == {"tools": [], "unknown": ["secret"]} diff --git a/tests/test_tools.py b/tests/test_tools.py index ebf30e45..d7cb900f 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -30,12 +30,9 @@ def test_tool_builds_completion_payload() -> None: ) assert sample_tool.to_schema() == { - "type": "function", - "function": { - "name": "tests_sample_tool", - "description": "Sample tool", - "parameters": parameters, - }, + "name": "tests_sample_tool", + "description": "Sample tool", + "parameters": parameters, } diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 1f3a9a3c..eb842fb0 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -10,14 +10,12 @@ from typing import Any import pytest -from any_llm.constants import LLMProvider -from any_llm.types.completion import ChatCompletion, ChatCompletionChunk from bub import tracing from bub.builtin.agent import Agent from bub.builtin.context import default_tape_context from bub.builtin.model_runner import ModelRunner -from bub.builtin.settings import AgentSettings, ModelCandidate +from bub.builtin.settings import AgentSettings from bub.framework import BubFramework from bub.hooks import hookimpl from bub.hooks.interception import LlmCallDecision, ToolCallDecision, ToolCallResult @@ -26,6 +24,7 @@ from bub.tape import Tape from bub.tools import Tool, ToolContext, ToolExecutor from bub.utils import workspace_from_state +from tests.model_fakes import ProviderService, chat_events, sse @pytest.fixture @@ -43,35 +42,18 @@ def spans(monkeypatch: pytest.MonkeyPatch) -> Iterator[Any]: @pytest.fixture -def agent(tmp_path: Path) -> Agent: +def agent(tmp_path: Path, provider_transport: ProviderService) -> Agent: framework = BubFramework(config_file=tmp_path / "config.yml") framework.load_builtin_hooks() agent = Agent(framework) - agent.settings = AgentSettings.model_construct(model="openai:test", api_key="unused", api_base=None) + agent.settings = AgentSettings(model="openai:test", api_key="unused", client_args={"api_format": "chat"}) agent.model_runner = ModelRunner(agent.settings, hooks=framework.get_agent_hooks()) agent.__dict__["tape"] = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()) return agent -def completion(text: str = "done", calls: list[dict[str, Any]] | None = None) -> ChatCompletion: - return ChatCompletion.model_validate({ - "id": "response-1", - "object": "chat.completion", - "created": 0, - "model": "actual-model", - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls" if calls else "stop", - "message": {"role": "assistant", "content": text, "tool_calls": calls}, - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14}, - }) - - def call(name: str, call_id: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: - return {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments or {})}} + return {"id": call_id, "name": name, "arguments": json.dumps(arguments or {})} @pytest.mark.asyncio @@ -79,6 +61,7 @@ async def test_agent_trajectory_has_parallel_tools_messages_and_tape_links( spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch, + provider_transport: ProviderService, ) -> None: started = 0 both_started = asyncio.Event() @@ -92,12 +75,10 @@ async def handler() -> dict[str, str]: return {"value": "result"} monkeypatch.setitem(agent.tools, "trace_tool", Tool(name="trace_tool", handler=handler)) - replies = iter([completion("checking", [call("trace_tool", "call-1"), call("trace_tool", "call-2")]), completion()]) - - async def respond(**kwargs: Any) -> ChatCompletion: - return next(replies) - - monkeypatch.setattr(agent.model_runner, "completion_response", respond) + provider_transport.reply_chat( + "checking", [call("trace_tool", "call-1"), call("trace_tool", "call-2")], input_tokens=10, output_tokens=4 + ) + provider_transport.reply_chat(input_tokens=10, output_tokens=4) events = await agent.run_stream(session_id="trace-test", prompt="hello", state={}, allowed_tools=["trace_tool"]) async for _ in events: assert tracing.current_span() is None @@ -131,17 +112,10 @@ async def respond(**kwargs: Any) -> ChatCompletion: @pytest.mark.asyncio -async def test_subagent_is_nested_under_its_tool(spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch) -> None: - replies = iter([ - completion("", [call("subagent", "child", {"prompt": "child task"})]), - completion("child done"), - completion(), - ]) - - async def respond(**kwargs: Any) -> ChatCompletion: - return next(replies) - - monkeypatch.setattr(agent.model_runner, "completion_response", respond) +async def test_subagent_is_nested_under_its_tool(spans: Any, agent: Agent, provider_transport: ProviderService) -> None: + provider_transport.reply_chat(calls=[call("subagent", "child", {"prompt": "child task"})]) + provider_transport.reply_chat("child done") + provider_transport.reply_chat() events = await agent.run_stream( session_id="parent", prompt="delegate", state={"_runtime_agent": agent}, allowed_tools=["subagent"] ) @@ -163,30 +137,24 @@ async def respond(**kwargs: Any) -> ChatCompletion: async def test_stream_close_and_cancel_finish_spans_and_provider( spans: Any, agent: Agent, - monkeypatch: pytest.MonkeyPatch, + provider_transport: ProviderService, cancel: bool, ) -> None: closed = asyncio.Event() waiting = asyncio.Event() - async def chunks() -> AsyncIterator[ChatCompletionChunk]: - try: - yield ChatCompletionChunk.model_validate({ - "id": "chunk-1", - "object": "chat.completion.chunk", - "created": 0, - "model": "actual", - "choices": [{"index": 0, "delta": {"content": "first"}}], - }) + import httpx2 + + class Body(httpx2.AsyncByteStream): + async def __aiter__(self): + yield b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n' waiting.set() await asyncio.Event().wait() - finally: - closed.set() - async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - return chunks() + async def aclose(self): + closed.set() - monkeypatch.setattr(agent.model_runner, "completion_response", respond) + provider_transport.reply(httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=Body())) events = await agent.run_stream(session_id="cancel-test", prompt="hello", state={}, allowed_tools=[]) assert (await anext(events)).kind == "text" assert tracing.current_span() is None @@ -321,24 +289,19 @@ def after_tool_call(self, result: ToolCallResult) -> None: @pytest.mark.asyncio async def test_provider_fallback_records_actual_model( - spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch + spans: Any, agent: Agent, provider_service: ProviderService ) -> None: - class Client: - SUPPORTS_COMPLETION_STREAMING = False - - async def acompletion(self, **kwargs: Any) -> ChatCompletion: - if kwargs["model"] == "unavailable": - raise RuntimeError("try next") - return completion() - - candidates = [ - ModelCandidate(name=f"openai:{name}", provider=LLMProvider.OPENAI, model_id=name) - for name in ["unavailable", "fallback"] - ] - monkeypatch.setattr(agent.model_runner, "iter_llm_clients", lambda model: iter((c, Client()) for c in candidates)) - events = await agent.run_stream(session_id="fallback", prompt="hello", state={}, allowed_tools=[]) - async for _ in events: - pass + import httpx2 + + provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}})) + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + agent.model_runner.settings.fallback_models = ["openai:fallback"] + agent.model_runner.settings.model = "openai:unavailable" + agent.model_runner.settings.client_args = {"http_client": client, "api_format": "chat", "max_retries": 0} + events = await agent.run_stream(session_id="fallback", prompt="hello", state={}, allowed_tools=[]) + async for _ in events: + pass model = next(s for s in spans.get_finished_spans() if s.name.startswith("chat ")) assert model.attributes["gen_ai.request.model"] == "fallback" assert model.attributes["gen_ai.provider.name"] == "openai" @@ -374,28 +337,10 @@ async def main(): @pytest.mark.asyncio -async def test_streaming_usage_arrives_on_last_chunk(spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch) -> None: - async def chunks() -> AsyncIterator[ChatCompletionChunk]: - yield ChatCompletionChunk.model_validate({ - "id": "stream", - "object": "chat.completion.chunk", - "created": 0, - "model": "stream-model", - "choices": [{"index": 0, "delta": {"content": "done"}, "finish_reason": "stop"}], - }) - yield ChatCompletionChunk.model_validate({ - "id": "stream", - "object": "chat.completion.chunk", - "created": 0, - "model": "stream-model", - "choices": [], - "usage": {"prompt_tokens": 19, "completion_tokens": 3, "total_tokens": 22}, - }) - - async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - return chunks() - - monkeypatch.setattr(agent.model_runner, "completion_response", respond) +async def test_streaming_usage_arrives_on_last_chunk( + spans: Any, agent: Agent, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat(model="stream-model", input_tokens=19, output_tokens=3) events = await agent.run_stream(session_id="stream", prompt="hello", state={}, allowed_tools=[]) assert (await anext(events)).data == {"delta": "done"} assert not spans.get_finished_spans() @@ -410,15 +355,17 @@ async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: @pytest.mark.asyncio async def test_timeout_marks_model_and_agent_as_failed( - spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch + spans: Any, agent: Agent, provider_transport: ProviderService ) -> None: - agent.settings.model_timeout_seconds = 0 + import httpx2 - async def respond(**kwargs: Any) -> ChatCompletion: - await asyncio.Event().wait() - return completion() + class Body(httpx2.AsyncByteStream): + async def __aiter__(self): + await asyncio.Event().wait() + yield b"" - monkeypatch.setattr(agent.model_runner, "completion_response", respond) + agent.settings.model_timeout_seconds = 0.01 + provider_transport.reply(httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=Body())) events = await agent.run_stream(session_id="timeout", prompt="hello", state={}, allowed_tools=[]) with pytest.raises(TimeoutError): async for _ in events: @@ -431,13 +378,10 @@ async def respond(**kwargs: Any) -> ChatCompletion: @pytest.mark.asyncio async def test_concurrent_sessions_have_independent_traces( - spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch + spans: Any, agent: Agent, provider_transport: ProviderService ) -> None: - async def respond(**kwargs: Any) -> ChatCompletion: - await asyncio.sleep(0) - return completion() - - monkeypatch.setattr(agent.model_runner, "completion_response", respond) + provider_transport.reply_chat() + provider_transport.reply_chat() async def run(session: str) -> None: events = await agent.run_stream(session_id=session, prompt="hello", state={}, allowed_tools=[]) diff --git a/uv.lock b/uv.lock index 57cb7973..6a1e5e5d 100644 --- a/uv.lock +++ b/uv.lock @@ -146,43 +146,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "anthropic" -version = "0.94.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "docstring-parser" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/d7/11a649b986da06aaeb81334632f1843d70e3797f54ca4a9c5d604b7987d0/anthropic-0.94.0.tar.gz", hash = "sha256:dde8c57de73538c5136c1bca9b16da92e75446b53a73562cc911574e4934435c", size = 654236, upload-time = "2026-04-10T22:27:59.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/ac/7185750e8688f6ff79b0e3d6a61372c88b81ba81fcda8798c70598e18aca/anthropic-0.94.0-py3-none-any.whl", hash = "sha256:42550b401eed8fcd7f6654234560f99c428306301bca726d32bca4bfb6feb748", size = 627519, upload-time = "2026-04-10T22:27:57.541Z" }, -] - -[[package]] -name = "any-llm-sdk" -version = "1.22.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anthropic" }, - { name = "httpx" }, - { name = "openai" }, - { name = "openresponses-types" }, - { name = "pydantic" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bf/de/a29738e1c8702a79a514f9521430fe77686fb9f9122980f4899a7baf58b5/any_llm_sdk-1.22.1.tar.gz", hash = "sha256:b07c6fcef6d7fc13e0f0419bc1464f9316b17e663c34382fb7722a906241f635", size = 164931, upload-time = "2026-07-22T15:43:29.12Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/5a/85c558cc9fb6fd8a7fd3e7523c9c20545316190d22372026ddbf53cdc0c9/any_llm_sdk-1.22.1-py3-none-any.whl", hash = "sha256:950339b68ca1d99fb123c523d246140e1c947b0ff26e3fb9bc37194a49f3b509", size = 217209, upload-time = "2026-07-22T15:43:27.664Z" }, -] - [[package]] name = "anyio" version = "4.14.2" @@ -207,15 +170,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.7.2" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, ] [[package]] @@ -223,8 +186,6 @@ name = "bub" source = { editable = "." } dependencies = [ { name = "aiohttp" }, - { name = "any-llm-sdk" }, - { name = "authlib" }, { name = "httpx", extra = ["socks"] }, { name = "inquirer-textual" }, { name = "loguru" }, @@ -235,6 +196,7 @@ dependencies = [ { name = "python-telegram-bot" }, { name = "pyyaml" }, { name = "rapidfuzz" }, + { name = "republic" }, { name = "rich" }, { name = "typer" }, { name = "typing-extensions" }, @@ -267,8 +229,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.14.3" }, - { name = "any-llm-sdk", extras = ["anthropic"], specifier = ">=1.22.1" }, - { name = "authlib", specifier = ">=1.7.2" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "inquirer-textual", specifier = ">=0.8.0" }, { name = "logfire", marker = "extra == 'logfire'", specifier = ">=4.32.0" }, @@ -286,6 +246,7 @@ requires-dist = [ { name = "python-telegram-bot", specifier = ">=22.7" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rapidfuzz", specifier = ">=3.14.5" }, + { name = "republic", git = "https://github.com/bubbuild/republic.git?rev=449debdfed5b45dffb4744091db84fbbeb0704b7" }, { name = "rich", specifier = ">=14.3.4" }, { name = "typer", specifier = ">=0.24.1" }, { name = "typing-extensions", specifier = ">=4.15.0" }, @@ -531,24 +492,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, -] - -[[package]] -name = "docstring-parser" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - [[package]] name = "executing" version = "2.2.1" @@ -560,11 +503,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.2" +version = "4.0.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" }, ] [[package]] @@ -690,6 +633,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -710,13 +666,39 @@ socks = [ { name = "socksio" }, ] +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" -version = "3.15" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -752,78 +734,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/05/f990751e8577b5b3c902acea8a3b03fe8909eada8a5372ada050a169ad7d/inquirer_textual-0.8.0-py3-none-any.whl", hash = "sha256:b2d2afa7fa619c24baad23b3a1bc4dc55cb6b10ad3d4bad09bc747c93ae569fb", size = 36436, upload-time = "2026-09-25T12:49:17.425Z" }, ] -[[package]] -name = "jiter" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, - { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, - { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, - { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, - { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, - { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, - { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, - { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, - { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, - { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, - { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, - { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, - { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, - { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, -] - [[package]] name = "joserfc" version = "1.7.2" @@ -1128,37 +1038,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "openai" -version = "2.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/fe/64b3d035780b3188f86c4f6f1bc202e7bb74757ef028802112273b9dcacf/openai-2.31.0.tar.gz", hash = "sha256:43ca59a88fc973ad1848d86b98d7fac207e265ebbd1828b5e4bdfc85f79427a5", size = 684772, upload-time = "2026-04-08T21:01:41.797Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/bc/a8f7c3aa03452fedbb9af8be83e959adba96a6b4a35e416faffcc959c568/openai-2.31.0-py3-none-any.whl", hash = "sha256:44e1344d87e56a493d649b17e2fac519d1368cbb0745f59f1957c4c26de50a0a", size = 1153479, upload-time = "2026-04-08T21:01:39.217Z" }, -] - -[[package]] -name = "openresponses-types" -version = "2.3.0.post1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/26/b612c3215f5599714fa94d63eb5ee59b4eb66dbdeeaf86bb4d848359484d/openresponses_types-2.3.0.post1.tar.gz", hash = "sha256:11b8896d3621d2ac2439f6ff106f34ddcb1bbd517c317a6c852a9df2e98a0753", size = 19254, upload-time = "2026-01-22T20:02:03.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/5f/e16dad89ed24f586da5b01b9b206d3adbf21fe1af8e4dc55d5b93158fde6/openresponses_types-2.3.0.post1-py3-none-any.whl", hash = "sha256:88f6abcef9cad839203abff420dd080978bf6eb33cc06ddc5d78da4ccdba7613", size = 13847, upload-time = "2026-01-22T20:02:02.582Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.39.1" @@ -1438,7 +1317,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1446,80 +1325,115 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" }, + { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" }, + { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" }, + { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, + { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" }, + { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" }, ] [[package]] @@ -1588,15 +1502,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, ] [[package]] @@ -1730,6 +1643,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, ] +[[package]] +name = "republic" +version = "0.5.9.dev23+g449debdfe" +source = { git = "https://github.com/bubbuild/republic.git?rev=449debdfed5b45dffb4744091db84fbbeb0704b7#449debdfed5b45dffb4744091db84fbbeb0704b7" } +dependencies = [ + { name = "authlib" }, + { name = "filelock" }, + { name = "httpx2" }, + { name = "pydantic" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -1792,15 +1716,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "socksio" version = "1.0.0" @@ -1883,15 +1798,12 @@ wheels = [ ] [[package]] -name = "tqdm" -version = "4.67.3" +name = "truststore" +version = "0.10.4" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] [[package]] @@ -1920,23 +1832,23 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] @@ -1985,17 +1897,18 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.1" +version = "21.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/c5/aff062c66b42e2183201a7ace10c6b2e959a9a16525c8e8ca8e59410d27a/virtualenv-21.2.1.tar.gz", hash = "sha256:b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b537fa78", size = 5844770, upload-time = "2026-04-09T18:47:11.482Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/0e/f083a76cb590e60dff3868779558eefefb8dfb7c9ed020babc7aa014ccbf/virtualenv-21.2.1-py3-none-any.whl", hash = "sha256:bd16b49c53562b28cf1a3ad2f36edb805ad71301dee70ddc449e5c88a9f919a2", size = 5828326, upload-time = "2026-04-09T18:47:09.331Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da", size = 5487482, upload-time = "2026-10-02T17:42:23.38Z" }, ] [[package]] diff --git a/website/src/content/docs/docs/concepts/tape-and-context.mdx b/website/src/content/docs/docs/concepts/tape-and-context.mdx index 8fc73204..897a580f 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -64,9 +64,9 @@ Spill writes are recorded as `spill.write`. Framework-managed lifecycle outcomes Before the first turn on a tape, `TapeService.ensure_bootstrap_anchor` checks for an anchor entry. If none exists, it writes a `session/start` handoff with `state={"owner": "human"}`. This guarantees that context reconstruction has a starting anchor on every tape. -### default_tape_context: entries → OpenAI messages +### default_tape_context: entries → model messages -`default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and emits OpenAI-compatible messages. The mapping is: +`default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and emits Bub message dictionaries for conversion to Republic messages. The mapping is: - `anchor` → an `assistant` message of the form `[Anchor created: ]: `. - `message` → the entry payload, used as a chat message dict. diff --git a/website/src/content/docs/docs/getting-started/first-turn.mdx b/website/src/content/docs/docs/getting-started/first-turn.mdx index 67501223..4a17ac42 100644 --- a/website/src/content/docs/docs/getting-started/first-turn.mdx +++ b/website/src/content/docs/docs/getting-started/first-turn.mdx @@ -12,7 +12,7 @@ This tutorial walks through one full turn — the framework's name for one inbou You should have: - [Bub installed](/docs/getting-started/install/) and `bub hooks` reporting the `builtin` plugin -- one model access path: an API key exported as `BUB_API_KEY` or a provider-specific `BUB__API_KEY`; alternatively, OpenAI Codex OAuth via `bub login openai` with `BUB_MODEL=openai:` +- one model access path: an API key exported as `BUB_API_KEY` or a provider-specific `BUB__API_KEY`; alternatively, OpenAI Codex OAuth via `bub login codex` with `BUB_MODEL=codex:` ## 1. Create a workspace @@ -80,15 +80,15 @@ For interactive use, start a chat session instead: bub chat ``` -## 5. Optional: log in with OpenAI OAuth +## 5. Optional: use your ChatGPT plan -If you want to use the OpenAI Codex OAuth path instead of an API key, log in with OAuth: +Install the Codex CLI, then log in to use your ChatGPT plan: ```bash -bub login openai +bub login codex ``` -Bub stores the credential locally. Use it by setting `BUB_MODEL=openai:` for a model available to your Codex account, leaving `BUB_API_KEY` unset, and not setting a custom `BUB_API_BASE`. +The Codex CLI stores the credential in its local file store; Republic reads and refreshes it. Use it by setting `BUB_MODEL=codex:` for a model available to your Codex account, leaving `BUB_API_KEY` unset, and not setting a custom `BUB_API_BASE`. ## Next steps diff --git a/website/src/content/docs/docs/getting-started/index.mdx b/website/src/content/docs/docs/getting-started/index.mdx index f30b93b8..30f2177c 100644 --- a/website/src/content/docs/docs/getting-started/index.mdx +++ b/website/src/content/docs/docs/getting-started/index.mdx @@ -17,7 +17,7 @@ You should have: - a network connection that can reach `bub.build`, `astral.sh`, and PyPI - one model access path for the model-backed step: - an API key exported as `BUB_API_KEY` or as a provider-specific `BUB__API_KEY`, or - - OpenAI Codex OAuth via `bub login openai` with `BUB_MODEL=openai:` + - OpenAI Codex OAuth via `bub login codex` with `BUB_MODEL=codex:` If you only want to inspect Bub without running a model turn, the API key is optional — comma commands such as `,help` work without one. diff --git a/website/src/content/docs/docs/operate/configure.mdx b/website/src/content/docs/docs/operate/configure.mdx index 1208fb89..1656f4af 100644 --- a/website/src/content/docs/docs/operate/configure.mdx +++ b/website/src/content/docs/docs/operate/configure.mdx @@ -13,16 +13,16 @@ Bub reads its own configuration from these places, in order of precedence (highe 2. `.env` values loaded during CLI startup or by settings classes that explicitly declare `env_file=".env"`. 3. `~/.bub/config.yml` (created by `bub onboard`). -Downstream model SDKs may still read their own provider variables, such as `OPENROUTER_API_KEY`, when no explicit `BUB_API_KEY` is supplied. Bub's settings layer only maps `BUB_*` variables. +When no explicit Bub key is supplied, Bub reads `_API_KEY` and `_API_BASE`, using Republic provider names with hyphens replaced by underscores. Examples include `OPENROUTER_API_KEY`, `GOOGLE_API_KEY`, and `AZURE_OPENAI_API_KEY`. Explicit `BUB_*` settings take precedence. ## Before you begin - Bub is installed (`bub --help` works). -- You have one model provider's API key, or you have run `bub login openai` for OAuth. +- You have one model provider's API key, or you have run `bub login codex` for OAuth. ## 1. Run the interactive setup -`bub onboard` guides you through provider selection, API key and URL (when needed), connection checking, searchable model selection, channels, and streaming settings. Hosted providers use their default URL; choose **OpenAI-compatible** for a custom OpenAI-compatible server. Leave the API key blank to use environment credentials. If the model list is unavailable, you can edit the connection, retry, or enter a model ID manually. The connection check fetches models without generating a completion. An existing OpenAI OAuth login skips model discovery and uses manual model entry. The collected configuration replaces `~/.bub/config.yml`; existing file contents are not merged into the onboarding prompts. Plugins may add prompts through the `onboard_config` hook. +`bub onboard` guides you through provider selection, API key and URL (when needed), connection checking, searchable model selection, channels, and streaming settings. Hosted providers use their default URL; choose **OpenAI-compatible** for a custom OpenAI-compatible server. Leave the API key blank to use environment credentials. If the model list is unavailable, you can edit the connection, retry, or enter a model ID manually. The connection check fetches models without generating a completion. Selecting Codex skips model discovery and uses manual model entry. The collected configuration replaces `~/.bub/config.yml`; existing values supply prompt defaults and are preserved unless updated. Plugins may add prompts through the `onboard_config` hook. ```bash bub onboard @@ -55,14 +55,14 @@ Every YAML key has a matching environment variable. The pattern is `BUB_`. | -| `BUB_API_KEY` | API key for the active provider. Leave unset when using `bub login openai`. | -| `BUB_API_BASE` | Override the provider's base URL. Leave unset for Codex OAuth so Bub can route through the Codex backend. | +| `BUB_MODEL` | Model identifier, e.g. `openrouter:openrouter/free` or `codex:`. | +| `BUB_API_KEY` | API key for the active provider. Leave unset when using `bub login codex`. | +| `BUB_API_BASE` | Override the provider's base URL. Leave unset to use the selected provider's default endpoint. | | `BUB_HOME` | Bub's runtime data directory (history, tapes, managed plugin project). Default `~/.bub`; does not move the default config file. | | `BUB__API_KEY` | Per-provider key, e.g. `BUB_OPENROUTER_API_KEY`. | | `BUB__API_BASE` | Per-provider base URL. | -When you use `bub login openai`, Bub reads Codex OAuth credentials from `CODEX_HOME` or `~/.codex` and refreshes them automatically. Use a model id under the `openai:` provider and keep both `BUB_API_KEY` and `BUB_API_BASE` unset for that path. +Republic reads and refreshes Codex file credentials from `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Install the Codex CLI for a new login, then select `codex:` and leave `BUB_API_KEY` unset. Use `openai:` with API credentials for OpenAI API access. To reach an endpoint that speaks a supported API under a name of its own (a company proxy, a relay, a second account), declare it under `providers` and use that name as the model prefix; see [Custom providers](/docs/reference/settings/#custom-providers). diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index 81fe0aa8..44150b5a 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -109,13 +109,13 @@ Omit the name to run all onboarding hooks. Behavior notes: - All providers follow the same flow: select a provider, enter connection details, check the connection, choose a model, then configure channels and streaming. -- Provider labels distinguish **OpenAI (official API)** from **OpenAI-compatible (custom URL / local server)**. Compatible services are saved as `openai:` with your API base URL. -- Hosted providers use their default endpoint and normally ask only for an API key. Compatible services, Azure, Ollama, other providers, and custom endpoints supplied by preceding hooks also prompt for a URL. Blank API keys retain a key supplied in this run for the same endpoint or use environment credentials, including `OPENAI_API_KEY`, `BUB_API_KEY`, and `BUB_OPENAI_API_KEY`. Environment keys are not copied into the saved configuration. A compatible server gets the SDK's placeholder key only when no key is supplied or available from the environment. -- Bub fetches the provider's model list with a 10-second asynchronous timeout, then offers searchable model selection and manual entry. Blocking SDK calls may take longer. This checks access to the models endpoint; it does not test a model completion. If discovery fails, edit the URL/key, retry, or enter a model ID manually. Providers that do not support model discovery, including Azure in the current SDK, explain the limitation and proceed directly to manual entry without offering retries. -- When the effective connection uses OpenAI OAuth, model discovery is skipped and you enter a model ID manually. Displayed default URLs are not written to the configuration. Leave the URL blank to preserve the default and OAuth routing; entering a URL explicitly saves it, even if it matches the displayed default. +- Provider labels distinguish **OpenAI (official API)** from **OpenAI-compatible (custom URL / local server)**. Compatible services are saved as `openai:` with your API base URL and `client_args.api_format: chat`. +- Hosted providers use their default endpoint and normally ask only for an API key. Compatible services, Azure OpenAI, Ollama, other providers, and custom endpoints supplied by preceding hooks also prompt for a URL. Blank API keys retain a key supplied in this run for the same endpoint or use environment credentials, including `OPENAI_API_KEY`, `BUB_API_KEY`, and `BUB_OPENAI_API_KEY`. Environment keys are not copied into the saved configuration. +- Bub fetches the provider's model list with a 10-second asynchronous timeout, then offers searchable model selection and manual entry. This checks access to the models endpoint; it does not test a model completion. If discovery fails, edit the URL/key, retry, or enter a model ID manually. Providers that do not support model discovery, including Codex, explain the limitation and proceed directly to manual entry without offering retries. +- When you select the Codex provider, model discovery is skipped and you enter a model ID manually. Displayed default URLs are not written to the configuration. Leave the URL blank to preserve the default endpoint; entering a URL explicitly saves it, even if it matches the displayed default. - Each plugin's `onboard_config` hook receives a copy of the loaded configuration plus updates from earlier hooks. Existing fields are preserved unless a hook overrides them; non-dict returns abort with `TypeError`. - The merged dict is validated through `configure.validate` before being written. -- `onboard` is interactive. The provider labels and prompt order have changed, and connection results determine subsequent prompts. Scripts that supply a fixed sequence of answers must be updated; for unattended setup, write the configuration file directly. The bundled installers launch `onboard` only in interactive mode. +- `onboard` is interactive, and connection results determine subsequent prompts. For unattended setup, write the configuration file directly. The bundled installers launch `onboard` only in interactive mode. - After saving a configuration with at least one channel on Linux or Windows, onboarding asks whether to install the gateway as a per-user background service. The default is no. Installation failure leaves the saved configuration intact and exits with code `1`. Exit codes: @@ -189,25 +189,25 @@ bub login [OPTIONS] COMMAND [ARGS]... | --- | --- | --- | --- | | `--help` | flag | — | Show help and exit. | -### `bub login openai` +### `bub login codex` -Run the Codex OAuth flow to obtain OpenAI credentials, then save them under the resolved Codex home. +Run the installed Codex CLI with file credential storage. ```text -bub login openai [OPTIONS] +bub login codex [OPTIONS] ``` | Option | Type | Default | Description | | --- | --- | --- | --- | -| `--codex-home` | PATH | `$CODEX_HOME` or `~/.codex` | Directory to store the resulting `auth.json`. | -| `--browser` / `--no-browser` | flag | `--browser` | Open the OAuth authorize URL in the default browser. | -| `--manual` | flag | off | Skip the local callback server and prompt for the callback URL or code. | -| `--timeout` | FLOAT | `300.0` | OAuth wait timeout in seconds. | +| `--executable` | TEXT | `codex` | Codex CLI executable. | +| `--device-auth` | flag | off | Use device authorization for a terminal without a browser. | +| `--codex-home` | PATH | `$CODEX_HOME` or `~/.codex` | Credential directory for the login subprocess. | Behavior notes: -- On success prints `login: ok`, the account id, the auth file path, and a usage hint to set `BUB_MODEL=openai:` and omit `BUB_API_KEY`. -- On `CodexOAuthLoginError` exits with code `1`. +- On success prints `login: ok`, the account id, and a usage hint to set `BUB_MODEL=codex:` and omit `BUB_API_KEY`. +- Authentication failure exits with code `1`. Missing executables produce an installation hint. +- Credentials are stored in `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Use `--codex-home` to select a directory and `--device-auth` for headless login. ## `bub hooks` diff --git a/website/src/content/docs/docs/reference/settings.mdx b/website/src/content/docs/docs/reference/settings.mdx index 2f14762c..59ce4105 100644 --- a/website/src/content/docs/docs/reference/settings.mdx +++ b/website/src/content/docs/docs/reference/settings.mdx @@ -65,9 +65,9 @@ Loaded under the YAML root section. | `BUB_API_BASE` | unset | `api_base` | Default API base URL or per-provider mapping. | | `BUB__API_KEY` | unset | — | Provider-scoped API key, e.g. `BUB_OPENAI_API_KEY`. | | `BUB__API_BASE` | unset | — | Provider-scoped API base URL, e.g. `BUB_OPENROUTER_API_BASE`. | -| `BUB_PROVIDERS` | `{}` | `providers` | Named endpoints, each speaking one any-llm provider's API (`type`) with its own `api_base` and `api_key`. See [Custom providers](#custom-providers). | +| `BUB_PROVIDERS` | `{}` | `providers` | Named endpoints, each speaking one Republic provider's API (`type`) with its own `api_base` and `api_key`. See [Custom providers](#custom-providers). | | `BUB_MAX_STEPS` | unlimited | `max_steps` | Maximum agent loop iterations per turn. Must be a positive integer when set. | -| `BUB_MAX_TOKENS` | `16384` | `max_tokens` | Maximum tokens per model call. | +| `BUB_MAX_TOKENS` | `16384` | `max_tokens` | Maximum tokens per model call; omitted for Codex. | | `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | Per-call timeout in seconds. | | `BUB_CLIENT_ARGS` | `{}` | `client_args` | Extra kwargs passed to the underlying model client (JSON / dict). | | `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | Extra kwargs passed to each completion call, e.g. `{"reasoning_effort":"high"}`. Bub-managed arguments take precedence. | @@ -77,11 +77,19 @@ For example, `command_prefix: "!"` enables `!help` in builtin channels; `,help` CLI controls (`quit`, `exit`, `thinking`), completion, and shell mode use the same prefix. Telegram keeps its existing native slash-command filtering; with `/` as the prefix, use `/bub /help`. -Provider-specific defaults are gathered at startup by scanning `os.environ` for `^BUB_(.+)_(API_KEY|API_BASE)$` and lowercasing the captured provider name. +Provider-specific defaults are gathered at startup by scanning `os.environ` for `^BUB_(.+)_(API_KEY|API_BASE)$` and resolving the captured environment prefix to the Republic provider name (for example, `AZURE_OPENAI` → `azure-openai`). + +Bub uses Republic for model requests. Use `codex:` for ChatGPT plan access and `openai:` for OpenAI API access. A stored Codex login never changes the `openai:` route. Provider identifiers follow Republic, including `google:` and `azure-openai:`. + +`client_args` accepts Republic provider options such as `headers`, `api_format`, `timeout`, and `max_retries`. Protocol defaults follow Republic. Set `api_format: chat` explicitly for a Chat Completions endpoint. An injected HTTP client must be an `httpx2.AsyncClient` and stays caller-owned. + +`completion_args` accepts Republic chat options; provider-specific fields must be supplied in `extra_body`. Bub owns the model, messages, tools, streaming, and token limit. Session reasoning settings take precedence over configured reasoning fields. `max_tokens` is omitted for Codex because that endpoint does not support it. Republic handles wire-format conversion and Anthropic requests enable prompt caching. + +Provider names and available protocols follow [Republic's provider directory](https://github.com/bubbuild/republic/blob/main/docs/providers/index.md). Custom provider implementations must be registered with Republic. ### Custom providers -The model prefix normally names an any-llm provider, and `api_key` / `api_base` hold one value per provider. Use `providers` when an endpoint speaks an API any-llm already supports but needs a name of its own: a company proxy, a relay or local gateway, a self-hosted server, or a second account with the same vendor. Each name becomes a model prefix, and `type` is the any-llm provider whose API it speaks: +The model prefix normally names a Republic provider, and `api_key` / `api_base` hold one value per provider. Use `providers` when an endpoint speaks an API Republic already supports but needs a name of its own: a company proxy, a relay or local gateway, a self-hosted server, or a second account with the same vendor. Each name becomes a model prefix, and `type` is the Republic provider whose API it speaks: ```yaml model: proxy:gpt-5.5 @@ -99,9 +107,7 @@ providers: api_key: sk-relay-... ``` -Here `proxy:gpt-5.5` and `openai:gpt-5.5` both use the OpenAI API but go to different endpoints with different keys; `openai:` keeps its own `api_key` / `api_base`, or Codex OAuth. A field left out of an entry falls back to `api_key` / `api_base` and `BUB__API_KEY` / `BUB__API_BASE`. An unknown `type` is rejected when settings load. - -OpenAI Codex OAuth does not need a separate request-format setting. After `bub login openai`, Bub detects the stored Codex OAuth token when the selected model uses the `openai:` provider and no custom API base is set. +Here `proxy:gpt-5.5` and `openai:gpt-5.5` both use the OpenAI API but go to different endpoints with different keys; `openai:` keeps its own `api_key` / `api_base`. A field left out of an entry falls back to `api_key` / `api_base` and `BUB__API_KEY` / `BUB__API_BASE`. An unknown `type` is rejected when settings load. ## Spill sidecar — `SpillSettings` @@ -173,11 +179,11 @@ See [Operate › Channels › Telegram](/docs/operate/channels/telegram/) for de ## Login — third-party env vars -`bub login openai` reads one non-`BUB_*` env var: +`bub login codex` reads one non-`BUB_*` env var: | Env var | Default | Read by | Description | | --- | --- | --- | --- | -| `CODEX_HOME` | `~/.codex` | `bub login openai` (`src/bub/builtin/auth.py`) | Directory to store Codex OAuth `auth.json`. Overridden by `--codex-home`. | +| `CODEX_HOME` | `~/.codex` | `bub login codex` (`src/bub/builtin/auth.py`) | Directory to store Codex OAuth `auth.json`. Overridden by `--codex-home`. | ## Plugin-specific settings diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index eb3b0e8c..6fb886d5 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -67,6 +67,10 @@ from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState `StreamEvent.kind` is one of `text`, `reasoning`, `tool_call`, `tool_result`, `usage`, `error`, or `final`. `AsyncStreamEvents` wraps an async iterator and exposes terminal `error` and `usage` state. +Builtin `tool_call` events use Republic fields: `id`, `name`, `arguments`, and `metadata`. Usage dictionaries contain `input_tokens`, `output_tokens`, `reasoning_tokens`, `cached_tokens`, `cache_write_tokens`, and `total_tokens`. + +Builtin multipart prompts contain text blocks (`type: text`, `text`) or media blocks (`type: image|audio|video`, `media_type`, `url`). The URL can be remote or a base64 data URL. Bub converts these persisted blocks into native Republic parts; Republic owns provider encoding. Hooks supply these text and media blocks. + ### Errors — `bub.errors` ```python diff --git a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx index 0415add1..c61e8048 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 @@ -64,9 +64,9 @@ spill 写入以 `spill.write` event 记录。框架管理的生命周期结果 在某条 tape 的第一次 turn 之前,`TapeService.ensure_bootstrap_anchor` 会检查是否存在 anchor entry。如果没有,则写入一条 `session/start` handoff,`state={"owner": "human"}`。这保证每条 tape 在 context 重建时都有起始 anchor。 -### default_tape_context:entry → OpenAI 消息 +### default_tape_context:entry → 模型消息 -`default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并产出 OpenAI 兼容的消息。映射规则如下: +`default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并产出 Bub 消息字典,再转换为 Republic 消息。映射规则如下: - `anchor` → 形如 `[Anchor created: ]: ` 的 `assistant` 消息。 - `message` → 直接以 entry payload 作为 chat 消息 dict。 diff --git a/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx b/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx index a056182c..b7ed31f8 100644 --- a/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx +++ b/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx @@ -12,7 +12,7 @@ sidebar: 你需要准备: - 已 [安装 Bub](/zh-cn/docs/getting-started/install/) 且 `bub hooks` 报告 `builtin` 插件 -- 一种模型访问方式:通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key;也可以通过 `bub login openai` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=openai:` +- 一种模型访问方式:通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key;也可以通过 `bub login codex` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=codex:` ## 1. 创建 workspace @@ -80,15 +80,15 @@ Bub 会解析 session、加载 state、构建用户 prompt,并用包含 `AGENT bub chat ``` -## 5. 可选:使用 OpenAI OAuth 登录 +## 5. 可选:使用 ChatGPT 套餐 -如果想使用 OpenAI Codex OAuth 路径而不是 API key,使用 OAuth 登录: +安装 Codex CLI,然后登录以使用 ChatGPT 套餐: ```bash -bub login openai +bub login codex ``` -Bub 会在本地保存凭据。使用该路径时,选择你的 Codex 账号可用的模型并设置 `BUB_MODEL=openai:`,同时保持 `BUB_API_KEY` 未设置,也不要设置自定义 `BUB_API_BASE`。 +Codex CLI 将凭据保存到本地文件,Republic 负责读取和刷新。使用该路径时,选择你的 Codex 账号可用的模型并设置 `BUB_MODEL=codex:`,同时保持 `BUB_API_KEY` 未设置,也不要设置自定义 `BUB_API_BASE`。 ## 下一步 diff --git a/website/src/content/docs/zh-cn/docs/getting-started/index.mdx b/website/src/content/docs/zh-cn/docs/getting-started/index.mdx index e5dc30c2..1aeb76f8 100644 --- a/website/src/content/docs/zh-cn/docs/getting-started/index.mdx +++ b/website/src/content/docs/zh-cn/docs/getting-started/index.mdx @@ -17,7 +17,7 @@ Bub 是一个用于在共享环境中构建 agent 的小型 Python 框架。内 - 可以访问 `bub.build`、`astral.sh` 和 PyPI 的网络连接 - 一种模型访问方式,用于 model-backed 阶段: - 通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key,或 - - 通过 `bub login openai` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=openai:` + - 通过 `bub login codex` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=codex:` 如果只想检查 Bub 而不实际跑一次 model turn,API key 是可选的——`,help` 等 comma 命令无需它即可使用。 diff --git a/website/src/content/docs/zh-cn/docs/operate/configure.mdx b/website/src/content/docs/zh-cn/docs/operate/configure.mdx index 90a68854..d2da0a36 100644 --- a/website/src/content/docs/zh-cn/docs/operate/configure.mdx +++ b/website/src/content/docs/zh-cn/docs/operate/configure.mdx @@ -13,16 +13,16 @@ Bub 自己的配置按以下优先级(从高到低)读取: 2. CLI 启动阶段加载的 `.env` 值,或显式声明 `env_file=".env"` 的 settings class 读取到的 `.env` 值。 3. `~/.bub/config.yml`(由 `bub onboard` 创建)。 -当未显式提供 `BUB_API_KEY` 时,下游模型 SDK 仍可能读取自己的提供商变量,例如 `OPENROUTER_API_KEY`。Bub 的 settings 层只映射 `BUB_*` 变量。 +当没有显式配置 Bub 凭证时,Bub 读取 `_API_KEY` 和 `_API_BASE`。provider 使用 Republic 名称,将连字符替换为下划线,例如 `OPENROUTER_API_KEY`、`GOOGLE_API_KEY` 和 `AZURE_OPENAI_API_KEY`。显式的 `BUB_*` 设置优先。 ## 开始之前 - Bub 已安装(`bub --help` 可用)。 -- 你已拥有某个模型提供商的 API key,或已运行 `bub login openai` 完成 OAuth。 +- 你已拥有某个模型提供商的 API key,或已运行 `bub login codex` 完成 OAuth。 ## 1. 运行交互式初始化 -`bub onboard` 依次引导你选择 provider、填写 API key 和必要的 URL、检查连接、搜索选择模型,再配置 channel 与流式输出。托管服务使用默认 URL;自定义 OpenAI 兼容服务请选择 **OpenAI-compatible**。API key 留空可使用环境变量中的凭证。获取模型列表失败时,可以修改连接、重试或手动输入模型 ID。连接检查只获取模型列表,不发起模型生成请求。已登录 OpenAI OAuth 时会跳过模型发现,直接手动输入模型。收集到的配置会替换 `~/.bub/config.yml`;已有文件内容不会合并到本轮交互中。插件可通过 `onboard_config` hook 添加自己的交互提示。 +`bub onboard` 依次引导你选择 provider、填写 API key 和必要的 URL、检查连接、搜索选择模型,再配置 channel 与流式输出。托管服务使用默认 URL;自定义 OpenAI 兼容服务请选择 **OpenAI-compatible**。API key 留空可使用环境变量中的凭证。获取模型列表失败时,可以修改连接、重试或手动输入模型 ID。连接检查只获取模型列表,不发起模型生成请求。选择 Codex 时会跳过模型发现,直接手动输入模型。收集到的配置会替换 `~/.bub/config.yml`;已有配置会作为本轮交互的默认值,未更新的字段会保留。插件可通过 `onboard_config` hook 添加自己的交互提示。 ```bash bub onboard @@ -55,16 +55,16 @@ telegram: | 变量 | 用途 | |---|---| -| `BUB_MODEL` | 模型标识,如 `openrouter:openrouter/free` 或 `openai:`。 | -| `BUB_API_KEY` | 当前提供商的 API key。使用 `bub login openai` 时保持未设置。 | - -如果某个端点说的是已支持的协议,但需要单独起名(公司代理、中转站、第二个账号),就在 `providers` 下声明它,再用这个名字当模型前缀,见[自定义 provider](/zh-cn/docs/reference/settings/#自定义-provider)。 -| `BUB_API_BASE` | 覆盖提供商的基础 URL。使用 Codex OAuth 时保持未设置,以便 Bub 路由到 Codex backend。 | +| `BUB_MODEL` | 模型标识,如 `openrouter:openrouter/free` 或 `codex:`。 | +| `BUB_API_KEY` | 当前提供商的 API key。使用 `bub login codex` 时保持未设置。 | +| `BUB_API_BASE` | 覆盖提供商的基础 URL。保持未设置可使用所选 provider 的默认端点。 | | `BUB_HOME` | Bub 的运行时数据目录(history、tapes、托管插件项目)。默认 `~/.bub`;不会移动默认配置文件。 | | `BUB__API_KEY` | 单个提供商的 key,如 `BUB_OPENROUTER_API_KEY`。 | | `BUB__API_BASE` | 单个提供商的基础 URL。 | -使用 `bub login openai` 时,Bub 会从 `CODEX_HOME` 或 `~/.codex` 读取 Codex OAuth 凭据,并自动 refresh。这个路径下使用 `openai:` provider 的模型标识,同时保持 `BUB_API_KEY` 和 `BUB_API_BASE` 未设置。 +Republic 从 `$CODEX_HOME/auth.json` 或 `~/.codex/auth.json` 读取并刷新 Codex 文件凭据。新登录需要安装 Codex CLI,然后选择 `codex:` 并保持 `BUB_API_KEY` 未设置。OpenAI API 访问使用 `openai:` 和 API 凭据。 + +公司代理、中转站或第二个账号可在 `providers` 下单独命名,再使用该名称作为模型前缀,见[自定义 provider](/zh-cn/docs/reference/settings/#自定义-provider)。 完整变量表(包括 channel 防抖参数和高级模型客户端设置)见 [Settings 参考](/zh-cn/docs/reference/settings/)。 diff --git a/website/src/content/docs/zh-cn/docs/reference/cli.mdx b/website/src/content/docs/zh-cn/docs/reference/cli.mdx index 74f92a23..d46edf11 100644 --- a/website/src/content/docs/zh-cn/docs/reference/cli.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/cli.mdx @@ -109,13 +109,13 @@ bub onboard [OPTIONS] [PLUGIN_NAME] 行为说明: - 所有 provider 使用同一流程:选择 provider、填写连接信息、检查连接、选择模型,再配置 channel 和流式输出。 -- 选项明确区分 **OpenAI (official API)**(官方接口)与 **OpenAI-compatible (custom URL / local server)**(兼容接口或本地服务)。兼容服务保存为 `openai:`,并使用你填写的 API base URL。 -- 托管服务使用默认地址,通常只询问 API key。兼容服务、Azure、Ollama、其他 provider 以及前序钩子提供的自定义地址会额外询问 URL。API key 留空会保留本轮为相同地址提供的 key,或使用环境变量中的凭证,包括 `OPENAI_API_KEY`、`BUB_API_KEY` 和 `BUB_OPENAI_API_KEY`。环境变量中的 key 不会复制到配置文件;仅在没有输入 key 且环境变量也没有凭证时,才为兼容服务保存 SDK 所需的占位 key。 -- Bub 获取 provider 的模型列表,异步超时为 10 秒,随后支持搜索选择或手动输入模型。阻塞式 SDK 调用可能等待更久。检查只验证模型列表接口可访问,不发起模型生成请求。获取失败时可修改 URL/key、重试或手动输入;不支持模型发现的 provider(如当前 SDK 中的 Azure)会说明限制并直接进入手动输入,不提供重试菜单。 -- 实际连接使用 OpenAI OAuth 时会跳过模型发现,直接手动输入模型。界面显示的默认 URL 不会写入配置。URL 留空可保留默认地址和 OAuth 路由;明确输入 URL 时会保存该地址,即使它与显示的默认值相同。 +- 选项明确区分 **OpenAI (official API)**(官方接口)与 **OpenAI-compatible (custom URL / local server)**(兼容接口或本地服务)。兼容服务保存为 `openai:`,并使用你填写的 API base URL 和 `client_args.api_format: chat`。 +- 托管服务使用默认地址,通常只询问 API key。兼容服务、Azure OpenAI、Ollama、其他 provider 以及前序钩子提供的自定义地址会额外询问 URL。API key 留空会保留本轮为相同地址提供的 key,或使用环境变量中的凭证,包括 `OPENAI_API_KEY`、`BUB_API_KEY` 和 `BUB_OPENAI_API_KEY`。环境变量中的 key 不会复制到配置文件。 +- Bub 获取 provider 的模型列表,异步超时为 10 秒,随后支持搜索选择或手动输入模型。检查只验证模型列表接口可访问,不发起模型生成请求。获取失败时可修改 URL/key、重试或手动输入;不支持模型发现的 provider(如 Codex)会说明限制并直接进入手动输入,不提供重试菜单。 +- 选择 Codex provider 时会跳过模型发现,直接手动输入模型。界面显示的默认 URL 不会写入配置。URL 留空可保留默认地址;明确输入 URL 时会保存该地址,即使它与显示的默认值相同。 - 每个插件的 `onboard_config` 钩子拿到已加载配置的副本,以及此前钩子的累计更新;已有字段会保留,除非被钩子覆盖。返回值不是 dict 时抛 `TypeError` 并中止。 - 合并后的 dict 会经过 `configure.validate` 验证后才写入。 -- `onboard` 是交互命令。provider 标签和提示顺序已调整,连接检查结果也会决定后续提示。按固定顺序输入答案的脚本需要更新;无人值守配置请直接生成配置文件。内置安装器仅在交互模式下启动 `onboard`。 +- `onboard` 是交互命令,连接检查结果会决定后续提示。无人值守配置请直接生成配置文件。内置安装器仅在交互模式下启动 `onboard`。 - Linux 或 Windows 上保存了至少一个 channel 后,onboarding 会询问是否把 gateway 安装为当前用户的后台服务,默认选择否。安装失败不会回滚已保存的配置,并以退出码 `1` 结束。 退出码: @@ -189,25 +189,25 @@ bub login [OPTIONS] COMMAND [ARGS]... | --- | --- | --- | --- | | `--help` | flag | — | 显示帮助并退出。 | -### `bub login openai` +### `bub login codex` -跑 Codex OAuth 流程获取 OpenAI 凭据,并保存到解析后的 Codex home 目录下。 +调用已安装的 Codex CLI,并使用文件凭据存储。 ```text -bub login openai [OPTIONS] +bub login codex [OPTIONS] ``` | 选项 | 类型 | 默认值 | 描述 | | --- | --- | --- | --- | -| `--codex-home` | PATH | `$CODEX_HOME` 或 `~/.codex` | 写入 `auth.json` 的目录。 | -| `--browser` / `--no-browser` | flag | `--browser` | 在默认浏览器中打开 OAuth authorize URL。 | -| `--manual` | flag | off | 跳过本地回调服务器,改为提示粘贴 callback URL 或 code。 | -| `--timeout` | FLOAT | `300.0` | OAuth 等待超时时间(秒)。 | +| `--executable` | TEXT | `codex` | Codex CLI 可执行文件。 | +| `--device-auth` | flag | off | 使用设备授权,适用于没有浏览器的终端。 | +| `--codex-home` | PATH | `$CODEX_HOME` 或 `~/.codex` | 登录子进程使用的凭据目录。 | 行为说明: -- 成功时打印 `login: ok`、account id、auth 文件路径,并提示设置 `BUB_MODEL=openai:` 且不再提供 `BUB_API_KEY`。 -- 抛出 `CodexOAuthLoginError` 时以退出码 `1` 退出。 +- 成功时打印 `login: ok`、account id,并提示设置 `BUB_MODEL=codex:` 且不再提供 `BUB_API_KEY`。 +- 认证失败时以退出码 `1` 退出;找不到可执行文件时提示安装 CLI。 +- 凭据保存到 `$CODEX_HOME/auth.json` 或 `~/.codex/auth.json`。使用 `--codex-home` 指定目录,无浏览器登录使用 `--device-auth`。 ## `bub hooks` diff --git a/website/src/content/docs/zh-cn/docs/reference/settings.mdx b/website/src/content/docs/zh-cn/docs/reference/settings.mdx index 3fde61cc..149665f5 100644 --- a/website/src/content/docs/zh-cn/docs/reference/settings.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/settings.mdx @@ -65,7 +65,7 @@ class AgentSettings(Settings): | `BUB_API_BASE` | unset | `api_base` | 默认 API base URL,或按 provider 索引的映射。 | | `BUB__API_KEY` | unset | — | 按 provider 划分的 API key,例如 `BUB_OPENAI_API_KEY`。 | | `BUB__API_BASE` | unset | — | 按 provider 划分的 API base URL,例如 `BUB_OPENROUTER_API_BASE`。 | -| `BUB_PROVIDERS` | `{}` | `providers` | 自定义命名的端点,每个用 `type` 指定走哪个 any-llm provider 的协议,并有自己的 `api_base` 和 `api_key`。见[自定义 provider](#自定义-provider)。 | +| `BUB_PROVIDERS` | `{}` | `providers` | 自定义命名的端点,每个用 `type` 指定走哪个 Republic provider 的协议,并有自己的 `api_base` 和 `api_key`。见[自定义 provider](#自定义-provider)。 | | `BUB_MAX_STEPS` | 不限制 | `max_steps` | 单次 turn 内 agent 循环的最大步数;配置时必须为正整数。 | | `BUB_MAX_TOKENS` | `16384` | `max_tokens` | 单次模型调用的最大 token 数。 | | `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | 单次调用的超时秒数。 | @@ -77,11 +77,19 @@ class AgentSettings(Settings): CLI 控制命令(`quit`、`exit`、`thinking`)、补全和 shell 模式使用同一前缀。 Telegram 保留现有的原生斜杠命令过滤;前缀设为 `/` 时,使用 `/bub /help`。 -启动时 `ProviderSpecificEnvSource` 会扫描 `os.environ` 中匹配 `^BUB_(.+)_(API_KEY|API_BASE)$` 的变量,把捕获到的 provider 名称小写后作为 key 收集。 +启动时 `ProviderSpecificEnvSource` 会扫描 `os.environ` 中匹配 `^BUB_(.+)_(API_KEY|API_BASE)$` 的变量,并将环境变量前缀解析为 Republic provider 名称,例如 `AZURE_OPENAI` → `azure-openai`。 + +Bub 使用 Republic 发起模型请求。ChatGPT 套餐访问使用 `codex:`,OpenAI API 访问使用 `openai:`。本地 Codex 登录不会改变 `openai:` 路由。provider 标识使用 Republic 的名称,例如 `google:` 和 `azure-openai:`。 + +`client_args` 接受 Republic provider 参数,例如 `headers`、`api_format`、`timeout` 和 `max_retries`。协议默认值遵循 Republic。Chat Completions 端点需显式设置 `api_format: chat`。注入的 HTTP client 必须是 `httpx2.AsyncClient`,生命周期由调用方管理。 + +`completion_args` 接受 Republic chat 参数;提供商专用字段需显式放入 `extra_body`。模型、消息、工具、流式输出和 token 上限由 Bub 管理。会话 reasoning 设置优先于配置中的 reasoning 字段。Codex 端点不支持 `max_tokens`,因此不发送该参数。Republic 负责协议转换,Anthropic 请求开启 prompt caching。 + +Provider 名称和协议能力以 [Republic provider 目录](https://github.com/bubbuild/republic/blob/main/docs/providers/index.md)为准。自定义 provider 实现需注册到 Republic。 ### 自定义 provider -模型前缀通常是 any-llm 的 provider 名,`api_key` / `api_base` 每个 provider 只能放一个值。如果某个端点说的是 any-llm 已支持的协议,但需要单独起名,就用 `providers`:比如公司代理、中转站或本地网关、自建服务,或同一厂商的第二个账号。每个名字都可以当模型前缀用,`type` 指定它走哪个 any-llm provider 的协议: +模型前缀通常是 Republic 的 provider 名,`api_key` / `api_base` 每个 provider 只能放一个值。如果某个端点说的是 Republic 已支持的协议,但需要单独起名,就用 `providers`:比如公司代理、中转站或本地网关、自建服务,或同一厂商的第二个账号。每个名字都可以当模型前缀用,`type` 指定它走哪个 Republic provider 的协议: ```yaml model: proxy:gpt-5.5 @@ -99,9 +107,8 @@ providers: api_key: sk-relay-... ``` -这里 `proxy:gpt-5.5` 和 `openai:gpt-5.5` 都走 OpenAI 协议,但发往不同的地址、用不同的 key;`openai:` 仍使用它自己的 `api_key` / `api_base`,或 Codex OAuth。条目里没写的字段会回退到 `api_key` / `api_base` 以及 `BUB__API_KEY` / `BUB__API_BASE`。`type` 不是已知 provider 时,加载配置会直接报错。 +这里 `proxy:gpt-5.5` 和 `openai:gpt-5.5` 都走 OpenAI 协议,但发往不同的地址、用不同的 key;`openai:` 仍使用它自己的 `api_key` / `api_base`。条目里没写的字段会回退到 `api_key` / `api_base` 以及 `BUB__API_KEY` / `BUB__API_BASE`。`type` 不是已知 provider 时,加载配置会直接报错。 -OpenAI Codex OAuth 不需要单独的请求格式设置。运行 `bub login openai` 后,当模型使用 `openai:` provider 且没有自定义 API base 时,Bub 会检测并使用本地保存的 Codex OAuth token。 ## Spill sidecar —— `SpillSettings` @@ -173,11 +180,11 @@ class TelegramSettings(Settings): ## Login —— 第三方环境变量 -`bub login openai` 读取一个非 `BUB_*` 环境变量: +`bub login codex` 读取一个非 `BUB_*` 环境变量: | 环境变量 | 默认值 | 读取方 | 描述 | | --- | --- | --- | --- | -| `CODEX_HOME` | `~/.codex` | `bub login openai`(`src/bub/builtin/auth.py`) | Codex OAuth `auth.json` 的存放目录。可被 `--codex-home` 覆盖。 | +| `CODEX_HOME` | `~/.codex` | `bub login codex`(`src/bub/builtin/auth.py`) | Codex OAuth `auth.json` 的存放目录。可被 `--codex-home` 覆盖。 | ## 插件自定义配置 diff --git a/website/src/content/docs/zh-cn/docs/reference/types.mdx b/website/src/content/docs/zh-cn/docs/reference/types.mdx index b55aee87..edc47890 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -67,6 +67,10 @@ from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState `StreamEvent.kind` 可以是 `text`、`reasoning`、`tool_call`、`tool_result`、`usage`、`error` 或 `final`。`AsyncStreamEvents` 包装异步迭代器,并暴露最终 `error` 与 `usage` 状态。 +builtin 的 `tool_call` 事件使用 Republic 字段:`id`、`name`、`arguments` 和 `metadata`。usage 字典包含 `input_tokens`、`output_tokens`、`reasoning_tokens`、`cached_tokens`、`cache_write_tokens` 和 `total_tokens`。 + +builtin 的多部分 prompt 包含文本块(`type: text`、`text`)或媒体块(`type: image|audio|video`、`media_type`、`url`)。URL 可以是远程地址或 base64 data URL。Bub 将持久化内容块转换为 Republic 原生 part,provider 编码由 Republic 负责。hook 提供这些文本与媒体块。 + ### Error — `bub.errors` ```python From 8429263de4de9697961ddcdd9ed27eefc787fbaf Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 10 Oct 2026 01:15:59 +0800 Subject: [PATCH 02/16] refactor: consume native model outputs --- src/bub/builtin/model_runner.py | 192 +++++++++++++++++--------------- tests/test_agent_hooks.py | 54 +++++++++ 2 files changed, 155 insertions(+), 91 deletions(-) diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index cb0607f5..24057264 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -5,15 +5,15 @@ import asyncio import re from collections.abc import AsyncGenerator, AsyncIterator -from contextlib import aclosing, asynccontextmanager -from dataclasses import asdict, dataclass, field, replace +from contextlib import AsyncExitStack, aclosing, asynccontextmanager +from dataclasses import asdict, replace from datetime import UTC, datetime +from json import JSONDecodeError from time import monotonic from typing import Any, cast import republic from loguru import logger -from pydantic import TypeAdapter, ValidationError from republic import ChatOptions from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady @@ -34,7 +34,6 @@ r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum", re.IGNORECASE, ) -TOOL_ARGUMENTS_ADAPTER = TypeAdapter(dict[str, Any]) class ModelRunner: @@ -138,7 +137,6 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: model=model, steering_messages=steering_messages, ) - output = ModelOutput() request = LlmCallRequest( run_id=run_id, model=model, @@ -160,22 +158,18 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: yield StreamEvent("text", {"delta": decision.text}) yield StreamEvent("final", {"ok": True, "text": decision.text}) return - output.provider, _, output.model = request.model.partition(":") - llm_started = datetime.now(UTC) - try: - completion_started = monotonic() - async with aclosing(self._traced_completion(request, tools, tape, state, output)) as events: - async for event in events: - yield event + completion_started = monotonic() + async with self._traced_completion(request, tools, tape, state) as (candidate, completion, events): + async for event in events: + yield event + response = completion.response completion_elapsed = monotonic() - completion_started - except Exception as exc: - await self._fire_after_llm_call(request, output, state, llm_started, tape, error=exc) - raise - await self._fire_after_llm_call(request, output, state, llm_started, tape) + text = response.text or response.refusal or "" + provider = candidate.provider_name or candidate.provider yield StreamEvent("usage", {"usage": state.usage, "elapsed_seconds": completion_elapsed}) - tool_calls = output.tool_calls + tool_calls = response.tool_calls if tool_calls: tool_map = {tool_item.name: tool_item for tool_item in tools} serialized_tool_calls = [asdict(tool_call) for tool_call in tool_calls] @@ -191,31 +185,29 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: tool_results = execution.tool_results else: serialized_tool_calls, tool_results = [], None - assistant_fields: dict[str, Any] = {} - if response := output.response: - assistant_fields = { - key: value - for key, value in { - "reasoning": response.reasoning, - "provider_data": [ - asdict(part) for part in response.message.parts if isinstance(part, republic.ProviderData) - ], - }.items() - if value - } - if assistant_fields or any(call.metadata for call in response.tool_calls): - assistant_fields.update(source_provider=output.provider, source_model=output.model) + assistant_fields = { + key: value + for key, value in { + "reasoning": response.reasoning, + "provider_data": [ + asdict(part) for part in response.message.parts if isinstance(part, republic.ProviderData) + ], + }.items() + if value + } + if assistant_fields or any(call.metadata for call in response.tool_calls): + assistant_fields.update(source_provider=provider, source_model=candidate.model_id) await tape.record_chat( run_id=run_id, system_prompt=system_prompt, new_messages=new_messages, - response_text=(output.text or None) if tool_calls else output.text, + response_text=(text or None) if tool_calls else text, tool_calls=serialized_tool_calls, tool_results=tool_results, model=request.model, usage=state.usage, assistant_fields=assistant_fields, - provider=output.provider, + provider=provider, ) if tool_calls: yield StreamEvent("tool_result", {"tool_results": tool_results}) @@ -223,18 +215,18 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: "final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": tool_results} ) else: - yield StreamEvent("final", {"ok": True, "text": output.text}) + yield StreamEvent("final", {"ok": True, "text": text}) return AsyncStreamEvents(iterator(), state=state) - def _traced_completion( + @asynccontextmanager + async def _traced_completion( self, request: LlmCallRequest, tools: list[Tool], tape: Tape, state: StreamState, - output: ModelOutput, - ) -> AsyncStreamEvents: + ) -> AsyncIterator[tuple[ModelCandidate, republic.Stream[Any], AsyncIterator[StreamEvent]]]: provider, _, model = request.model.partition(":") span = Span( f"chat {model or request.model}", @@ -251,45 +243,36 @@ def _traced_completion( span.messages("gen_ai.input.messages", request.messages) if span.recording: span.set(**{"gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in tools]}) + started = datetime.now(UTC) + # Partial output for hook errors and cancellation traces. + text: list[str] = [] + calls: list[republic.ToolCall] = [] - async def iterator() -> AsyncGenerator[StreamEvent, None]: - async with asyncio.timeout(self.settings.model_timeout_seconds): - async with self._completion( - model=request.model, - messages=list(request.messages), - tools=tools, - max_tokens=request.max_tokens, - reasoning_effort=tape.context.state.get("reasoning_effort"), - ) as (candidate, stream): - output.provider = candidate.provider_name or candidate.provider - output.model = candidate.model_id - async for item in stream: - match item: - case TextDelta(chunk=delta) | RefusalDelta(chunk=delta): - output.text += delta - yield StreamEvent("text", {"delta": delta}) - case ReasoningDelta(chunk=delta): - yield StreamEvent("reasoning", {"delta": delta}) - case ToolCallReady(call=call): - output.tool_calls.append(call) - case Completed(response=response): - output.response = response - output.tool_calls = response.tool_calls - state.usage = { - **asdict(response.token_usage), - "total_tokens": response.token_usage.total_tokens, - } - span.set(**{ - "gen_ai.response.model": response.model, - "gen_ai.response.id": response.id, - "gen_ai.response.finish_reasons": [response.finish_reason] - if response.finish_reason - else None, - }) - - async def finish() -> None: - if not span.recording: - return + try: + async with asyncio.timeout(self.settings.model_timeout_seconds), AsyncExitStack() as stack: + with span.activate(): + candidate, stream = await stack.enter_async_context( + self._completion( + model=request.model, + messages=list(request.messages), + tools=tools, + max_tokens=request.max_tokens, + reasoning_effort=tape.context.state.get("reasoning_effort"), + ) + ) + events = await stack.enter_async_context(aclosing(_stream_events(stream, span, state, text, calls))) + yield candidate, stream, events + except BaseException as exc: + span.fail(exc) + if isinstance(exc, Exception): + await self._fire_after_llm_call(request, "".join(text), calls, state, started, tape, error=exc) + raise + else: + completed = stream.response + await self._fire_after_llm_call( + request, completed.text or completed.refusal or "", completed.tool_calls, state, started, tape + ) + finally: usage = state.usage or {} span.set(**{ "gen_ai.usage.input_tokens": usage.get("input_tokens"), @@ -300,18 +283,18 @@ async def finish() -> None: [ { "role": "assistant", - "content": output.text, - "tool_calls": [asdict(call) for call in output.tool_calls], + "content": "".join(text), + "tool_calls": [asdict(call) for call in calls], } ], ) - - return AsyncStreamEvents(iterator(), state=state, span=span, on_close=finish) + span.end() async def _fire_after_llm_call( self, request: LlmCallRequest, - output: ModelOutput, + text: str, + tool_calls: list[republic.ToolCall], state: StreamState, started: datetime, tape: Tape, @@ -322,8 +305,8 @@ async def _fire_after_llm_call( duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) result = LlmCallResult( run_id=request.run_id, - text=output.text or None, - tool_calls=[asdict(call) for call in output.tool_calls], + text=text or None, + tool_calls=[asdict(call) for call in tool_calls], usage=state.usage, error=error, duration_ms=duration_ms, @@ -363,13 +346,38 @@ async def build_messages( return messages, new_messages -@dataclass -class ModelOutput: - response: republic.Response[Any] | None = None - provider: str | None = None - model: str | None = None - text: str = "" - tool_calls: list[republic.ToolCall] = field(default_factory=list) +async def _stream_events( + stream: republic.Stream[Any], + span: Span, + state: StreamState, + text: list[str], + calls: list[republic.ToolCall], +) -> AsyncGenerator[StreamEvent, None]: + native_events = aiter(stream) + while True: + with span.activate(): + try: + item = await anext(native_events) + except StopAsyncIteration: + break + match item: + case TextDelta(chunk=delta) | RefusalDelta(chunk=delta): + text.append(delta) + yield StreamEvent("text", {"delta": delta}) + case ReasoningDelta(chunk=delta): + yield StreamEvent("reasoning", {"delta": delta}) + case ToolCallReady(call=call): + calls.append(call) + case Completed(response=completed): + state.usage = { + **asdict(completed.token_usage), + "total_tokens": completed.token_usage.total_tokens, + } + span.set(**{ + "gen_ai.response.model": completed.model, + "gen_ai.response.id": completed.id, + "gen_ai.response.finish_reasons": [completed.finish_reason] if completed.finish_reason else None, + }) def _request_messages(messages: list[dict[str, Any]], provider: str, model: str) -> list[republic.Message]: @@ -451,9 +459,11 @@ def _tool_invocation( """ tool_name = tool_call.name try: - arguments = TOOL_ARGUMENTS_ADAPTER.validate_json(tool_call.arguments or "{}") - except ValidationError as exc: + arguments = tool_call.args + except JSONDecodeError as exc: raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc + if not isinstance(arguments, dict): + raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") tool_obj = tool_map.get(tool_name) if tool_obj is None: diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index f38666b5..ce519a5c 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -8,6 +8,7 @@ import pluggy import pytest +from republic.errors import StreamIncompleteError from bub.builtin.model_runner import ModelRunner from bub.builtin.settings import AgentSettings @@ -339,6 +340,59 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: assert len(observed) == 1 assert observed[0].error is None + @pytest.mark.asyncio + async def test_failed_stream_reports_partial_text_once_without_trying_fallback( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: + observed: list[LlmCallResult] = [] + + class Observe: + @hookimpl + def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + observed.append(result) + + provider_transport.reply(sse([{"choices": [{"delta": {"content": "partial"}}]}])) + runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") + runner.settings.fallback_models = ["openai:fallback"] + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + with pytest.raises(StreamIncompleteError) as exc: + async for _ in events: + pass + assert len(observed) == 1 + assert observed[0].text == "partial" + assert observed[0].error is exc.value + assert observed[0].usage is None + assert len(provider_transport.requests) == 1 + assert not await tape.read_messages() + + @pytest.mark.asyncio + async def test_refusal_text_reaches_the_hook_final_event_and_tape( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: + observed: list[LlmCallResult] = [] + + class Observe: + @hookimpl + def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + observed.append(result) + + provider_transport.reply( + sse([ + {"choices": [{"delta": {"refusal": "Cannot fulfill this request."}, "finish_reason": "stop"}]}, + "[DONE]", + ]) + ) + runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") + events = [ + event + async for event in runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + ] + assert events[-1].data == {"ok": True, "text": "Cannot fulfill this request."} + assert len(observed) == 1 + assert observed[0].text == "Cannot fulfill this request." + assert observed[0].error is None + assert (await tape.read_messages())[-1] == {"role": "assistant", "content": "Cannot fulfill this request."} + class TestToolCancellation: @pytest.mark.asyncio From 505f085e8ee0e6866621270764eae156e510b578 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 10 Oct 2026 01:21:48 +0800 Subject: [PATCH 03/16] refactor: delegate request extras to Republic --- src/bub/builtin/model_runner.py | 17 ++------- src/bub/builtin/settings.py | 26 -------------- tests/test_builtin_model_runner.py | 36 ++++++++++++++++--- tests/test_settings.py | 13 ++++--- .../content/docs/docs/reference/settings.mdx | 8 +++-- .../docs/zh-cn/docs/reference/settings.mdx | 8 +++-- 6 files changed, 53 insertions(+), 55 deletions(-) diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index 24057264..2a87eeee 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -17,7 +17,7 @@ from republic import ChatOptions from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady -from bub.builtin.settings import AgentSettings, ModelCandidate, provider_extras +from bub.builtin.settings import AgentSettings, ModelCandidate from bub.errors import BubError, ErrorKind from bub.hooks.interception import ( AgentHooks, @@ -57,11 +57,6 @@ async def _completion( stream: republic.Stream[Any] | None = None try: client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider) - if reasoning_effort is not None and "extra_body" in client_kwargs: - extra = dict(client_kwargs["extra_body"]) - extra.pop("reasoning", None) - extra.pop("reasoning_effort", None) - client_kwargs["extra_body"] = extra chat_model = republic.get_model(f"{candidate.provider}:{candidate.model_id}", **client_kwargs) if span := current_span(): span.rename(f"chat {candidate.model_id}") @@ -99,20 +94,14 @@ async def _completion( def _chat_options( self, provider: str, tools: list[Tool], max_tokens: int | None, reasoning_effort: str | None ) -> ChatOptions: - options = provider_extras(self.settings.completion_args) - extra = provider_extras(dict(options.pop("extra_body", {}))) + options = dict(self.settings.completion_args) if provider == "anthropic": - extra.setdefault("cache_control", {"type": "ephemeral"}) + options["extra_body"] = {"cache_control": {"type": "ephemeral"}} | options.get("extra_body", {}) options["tools"] = [republic.Tool(item.name, item.description, item.parameters) for item in tools] if provider != "codex": options["max_tokens"] = max_tokens if max_tokens is not None else self.settings.max_tokens if reasoning_effort is not None: options["reasoning_effort"] = reasoning_effort - # Per-session reasoning settings take precedence over provider extras. - extra.pop("reasoning_effort", None) - extra.pop("reasoning", None) - if extra: - options["extra_body"] = extra return cast("ChatOptions", options) def run( diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index f964db8a..6b615251 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -23,30 +23,6 @@ DEFAULT_MAX_TOKENS = 16384 -def provider_extras(options: dict[str, Any]) -> dict[str, Any]: - """Keep wire extras from replacing the runtime request or token budget.""" - managed = { - "model", - "messages", - "input", - "contents", - "system", - "instructions", - "tools", - "stream", - "stream_options", - "max_tokens", - "max_output_tokens", - "max_completion_tokens", - } - result = {key: value for key, value in options.items() if key not in managed} - if isinstance(result.get("generationConfig"), dict): - result["generationConfig"] = { - key: value for key, value in result["generationConfig"].items() if key != "maxOutputTokens" - } - return result - - @dataclass(frozen=True) class ModelCandidate: provider: str @@ -160,8 +136,6 @@ def model_client_kwargs(self, provider: str) -> dict[str, Any]: custom = self.providers.get(provider) provider = provider if custom else provider.lower() options = dict(self.client_args) - if "extra_body" in options: - options["extra_body"] = provider_extras(options["extra_body"]) options.setdefault("env_prefix", f"BUB_{provider.upper().replace('-', '_')}") prefix = provider.upper().replace("-", "_") return { diff --git a/tests/test_builtin_model_runner.py b/tests/test_builtin_model_runner.py index dc41741f..8d0ca2f3 100644 --- a/tests/test_builtin_model_runner.py +++ b/tests/test_builtin_model_runner.py @@ -199,7 +199,7 @@ async def test_anthropic_request_enables_caching_and_generation_options( @pytest.mark.asyncio -async def test_session_reasoning_and_runtime_arguments_take_precedence( +async def test_session_reasoning_and_runtime_options_override_completion_defaults( tmp_path: Path, provider_service: ProviderService ) -> None: provider_service.reply(sse(chat_events())) @@ -209,14 +209,13 @@ async def test_session_reasoning_and_runtime_arguments_take_precedence( client_args={ "http_client": client, "api_format": "chat", - "extra_body": {"model": "bad", "messages": [], "reasoning_effort": "low"}, + "extra_body": {"metadata": {"client": True}}, }, completion_args={ "reasoning_effort": "low", - "model": "ignored", + "tools": [republic.Tool("ignored")], "max_tokens": 1, - "stream": False, - "extra_body": {"reasoning_effort": "low", "tools": []}, + "extra_body": {"metadata": {"request": True}}, }, ) ) @@ -235,6 +234,33 @@ async def test_session_reasoning_and_runtime_arguments_take_precedence( assert request["messages"] == [{"role": "user", "content": "hello"}] assert request["max_completion_tokens"] == 16384 assert request["stream"] is True + assert "tools" not in request + assert request["metadata"] == {"client": True, "request": True} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("request_reasoning", [None, "medium"]) +async def test_wire_reasoning_follows_republic_extra_body_precedence( + tmp_path: Path, provider_service: ProviderService, request_reasoning: str | None +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings( + client_args={"http_client": client, "api_format": "chat", "extra_body": {"reasoning_effort": "low"}}, + completion_args={"extra_body": {"reasoning_effort": request_reasoning} if request_reasoning else {}}, + ) + ) + tape = Tape( + tmp_path, + AsyncTapeStoreAdapter(InMemoryTapeStore()), + TapeContext(anchor=None, state={"reasoning_effort": "high"}), + ).scoped("wire-reasoning") + _ = [ + event + async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + ] + assert provider_service.body()["reasoning_effort"] == (request_reasoning or "low") @pytest.mark.asyncio diff --git a/tests/test_settings.py b/tests/test_settings.py index a6d445f6..e7561f09 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -256,7 +256,7 @@ async def test_provider_requests_use_environment_or_explicit_credentials( @pytest.mark.asyncio -async def test_wire_extras_preserve_gemini_options_without_replacing_the_prompt_or_token_budget( +async def test_provider_and_completion_extras_follow_republic_deep_merge( tmp_path: Path, provider_service: ProviderService ) -> None: provider_service.reply(sse([{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}])) @@ -266,13 +266,18 @@ async def test_wire_extras_preserve_gemini_options_without_replacing_the_prompt_ max_tokens=100, client_args={ "http_client": client, - "extra_body": {"contents": [], "generationConfig": {"maxOutputTokens": 1, "temperature": 0.4}}, + "extra_body": {"generationConfig": {"maxOutputTokens": 1, "temperature": 0.4, "topK": 12}}, }, + completion_args={"extra_body": {"generationConfig": {"temperature": 0.9, "topP": 0.8}}}, ) assert await _run(settings, tmp_path) == "done" assert provider_service.body()["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}] - assert provider_service.body()["generationConfig"]["maxOutputTokens"] == 100 - assert provider_service.body()["generationConfig"]["temperature"] == 0.4 + assert provider_service.body()["generationConfig"] == { + "maxOutputTokens": 1, + "temperature": 0.9, + "topK": 12, + "topP": 0.8, + } @pytest.mark.asyncio diff --git a/website/src/content/docs/docs/reference/settings.mdx b/website/src/content/docs/docs/reference/settings.mdx index 59ce4105..8902c3cc 100644 --- a/website/src/content/docs/docs/reference/settings.mdx +++ b/website/src/content/docs/docs/reference/settings.mdx @@ -70,7 +70,7 @@ Loaded under the YAML root section. | `BUB_MAX_TOKENS` | `16384` | `max_tokens` | Maximum tokens per model call; omitted for Codex. | | `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | Per-call timeout in seconds. | | `BUB_CLIENT_ARGS` | `{}` | `client_args` | Extra kwargs passed to the underlying model client (JSON / dict). | -| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | Extra kwargs passed to each completion call, e.g. `{"reasoning_effort":"high"}`. Bub-managed arguments take precedence. | +| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | Extra kwargs passed to each completion call, e.g. `{"reasoning_effort":"high"}`. Bub supplies the tools and token limit; see option precedence below. | | `BUB_VERBOSE` | `0` | `verbose` | Logging verbosity level (`0`–`2`). | For example, `command_prefix: "!"` enables `!help` in builtin channels; `,help` becomes ordinary text. @@ -81,9 +81,11 @@ Provider-specific defaults are gathered at startup by scanning `os.environ` for Bub uses Republic for model requests. Use `codex:` for ChatGPT plan access and `openai:` for OpenAI API access. A stored Codex login never changes the `openai:` route. Provider identifiers follow Republic, including `google:` and `azure-openai:`. -`client_args` accepts Republic provider options such as `headers`, `api_format`, `timeout`, and `max_retries`. Protocol defaults follow Republic. Set `api_format: chat` explicitly for a Chat Completions endpoint. An injected HTTP client must be an `httpx2.AsyncClient` and stays caller-owned. +`client_args` accepts Republic provider options such as `headers`, `api_format`, `timeout`, and `max_retries`. Bub supplies the resolved connection settings (`api_key`, `api_base`); other options pass through unchanged. Protocol defaults follow Republic. Set `api_format: chat` explicitly for a Chat Completions endpoint. An injected HTTP client must be an `httpx2.AsyncClient` and stays caller-owned. -`completion_args` accepts Republic chat options; provider-specific fields must be supplied in `extra_body`. Bub owns the model, messages, tools, streaming, and token limit. Session reasoning settings take precedence over configured reasoning fields. `max_tokens` is omitted for Codex because that endpoint does not support it. Republic handles wire-format conversion and Anthropic requests enable prompt caching. +`completion_args` accepts Republic chat options. Bub supplies `tools` and the runtime token limit; a session's `reasoning_effort` overrides the configured chat option. The runtime token limit is omitted for Codex because that endpoint does not support it. Republic handles wire-format conversion and Anthropic requests enable prompt caching. + +Provider-specific fields go in `extra_body`, which is accepted in both `client_args` and `completion_args`. Republic deep-merges provider defaults with request extras, with request values taking precedence, then merges them over the encoded request body. These explicit wire fields can override chat options, including reasoning and token limits; Bub passes them through unchanged. Provider names and available protocols follow [Republic's provider directory](https://github.com/bubbuild/republic/blob/main/docs/providers/index.md). Custom provider implementations must be registered with Republic. diff --git a/website/src/content/docs/zh-cn/docs/reference/settings.mdx b/website/src/content/docs/zh-cn/docs/reference/settings.mdx index 149665f5..0eb6998a 100644 --- a/website/src/content/docs/zh-cn/docs/reference/settings.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/settings.mdx @@ -70,7 +70,7 @@ class AgentSettings(Settings): | `BUB_MAX_TOKENS` | `16384` | `max_tokens` | 单次模型调用的最大 token 数。 | | `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | 单次调用的超时秒数。 | | `BUB_CLIENT_ARGS` | `{}` | `client_args` | 传递给底层模型 client 的额外 kwargs(JSON / dict)。 | -| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | 传递给每次 completion 调用的额外 kwargs,例如 `{"reasoning_effort":"high"}`;Bub 管理的参数优先。 | +| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | 传递给每次 completion 调用的额外 kwargs,例如 `{"reasoning_effort":"high"}`;工具和 token 上限由 Bub 提供,优先级见下文。 | | `BUB_VERBOSE` | `0` | `verbose` | 日志详细级别(`0`–`2`)。 | 例如,配置 `command_prefix: "!"` 后,内置 channel 使用 `!help`,`,help` 按普通文本处理。 @@ -81,9 +81,11 @@ Telegram 保留现有的原生斜杠命令过滤;前缀设为 `/` 时,使用 Bub 使用 Republic 发起模型请求。ChatGPT 套餐访问使用 `codex:`,OpenAI API 访问使用 `openai:`。本地 Codex 登录不会改变 `openai:` 路由。provider 标识使用 Republic 的名称,例如 `google:` 和 `azure-openai:`。 -`client_args` 接受 Republic provider 参数,例如 `headers`、`api_format`、`timeout` 和 `max_retries`。协议默认值遵循 Republic。Chat Completions 端点需显式设置 `api_format: chat`。注入的 HTTP client 必须是 `httpx2.AsyncClient`,生命周期由调用方管理。 +`client_args` 接受 Republic provider 参数,例如 `headers`、`api_format`、`timeout` 和 `max_retries`。Bub 提供解析后的连接设置(`api_key`、`api_base`),其他参数原样传递。协议默认值遵循 Republic。Chat Completions 端点需显式设置 `api_format: chat`。注入的 HTTP client 必须是 `httpx2.AsyncClient`,生命周期由调用方管理。 -`completion_args` 接受 Republic chat 参数;提供商专用字段需显式放入 `extra_body`。模型、消息、工具、流式输出和 token 上限由 Bub 管理。会话 reasoning 设置优先于配置中的 reasoning 字段。Codex 端点不支持 `max_tokens`,因此不发送该参数。Republic 负责协议转换,Anthropic 请求开启 prompt caching。 +`completion_args` 接受 Republic chat 参数。Bub 提供 `tools` 和运行时 token 上限;会话中的 `reasoning_effort` 覆盖配置中的同名 chat 参数。Codex 端点不支持 token 上限,因此不附加运行时的该参数。Republic 负责协议转换,Anthropic 请求开启 prompt caching。 + +提供商专用字段放入 `extra_body`,`client_args` 和 `completion_args` 都支持它。Republic 将 provider 默认值与请求级 extras 深合并,请求级值优先,再覆盖编码后的请求体。这些显式协议字段可覆盖 reasoning、token 上限等 chat 参数;Bub 原样传递。 Provider 名称和协议能力以 [Republic provider 目录](https://github.com/bubbuild/republic/blob/main/docs/providers/index.md)为准。自定义 provider 实现需注册到 Republic。 From 9dac8109c84acaa78069120b7bb94569d0a69a13 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 10 Oct 2026 01:30:47 +0800 Subject: [PATCH 04/16] refactor: simplify builtin options and naming --- src/bub/builtin/model_runner.py | 13 ++++++++----- src/bub/builtin/settings.py | 10 +++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index 2a87eeee..5ad5f20e 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -10,11 +10,10 @@ from datetime import UTC, datetime from json import JSONDecodeError from time import monotonic -from typing import Any, cast +from typing import Any import republic from loguru import logger -from republic import ChatOptions from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady from bub.builtin.settings import AgentSettings, ModelCandidate @@ -93,7 +92,7 @@ async def _completion( def _chat_options( self, provider: str, tools: list[Tool], max_tokens: int | None, reasoning_effort: str | None - ) -> ChatOptions: + ) -> dict[str, Any]: options = dict(self.settings.completion_args) if provider == "anthropic": options["extra_body"] = {"cache_control": {"type": "ephemeral"}} | options.get("extra_body", {}) @@ -102,7 +101,7 @@ def _chat_options( options["max_tokens"] = max_tokens if max_tokens is not None else self.settings.max_tokens if reasoning_effort is not None: options["reasoning_effort"] = reasoning_effort - return cast("ChatOptions", options) + return options def run( self, @@ -117,7 +116,7 @@ def run( state = StreamState() async def iterator() -> AsyncGenerator[StreamEvent, None]: - run_id = f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}" + run_id = self.generate_run_id() messages, new_messages = await self.build_messages( tape=tape, run_id=run_id, @@ -279,6 +278,10 @@ async def _traced_completion( ) span.end() + @staticmethod + def generate_run_id() -> str: + return f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}" + async def _fire_after_llm_call( self, request: LlmCallRequest, diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 6b615251..886cd796 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -23,6 +23,10 @@ DEFAULT_MAX_TOKENS = 16384 +def _provider_env_name(provider: str) -> str: + return provider.upper().replace("-", "_") + + @dataclass(frozen=True) class ModelCandidate: provider: str @@ -61,7 +65,7 @@ def __call__(self) -> dict[str, Any]: @staticmethod def _provider_specific(setting_name: str) -> dict[str, str]: - env_names = {name.upper().replace("-", "_"): name for name in republic.all_providers()} + env_names = {_provider_env_name(name): name for name in republic.all_providers()} setting_regex = re.compile(rf"^BUB_(.+)_{setting_name.upper()}$") result: dict[str, str] = {} for key, value in os.environ.items(): @@ -136,8 +140,8 @@ def model_client_kwargs(self, provider: str) -> dict[str, Any]: custom = self.providers.get(provider) provider = provider if custom else provider.lower() options = dict(self.client_args) - options.setdefault("env_prefix", f"BUB_{provider.upper().replace('-', '_')}") - prefix = provider.upper().replace("-", "_") + prefix = _provider_env_name(provider) + options.setdefault("env_prefix", f"BUB_{prefix}") return { **options, "api_key": (custom and custom.api_key) or self._credential_value(self.api_key, provider, "api_key", prefix), From 23aa5209cdef8b9d4658b65d309eaca9efa40a1e Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 10 Oct 2026 13:15:24 +0800 Subject: [PATCH 05/16] chore: use Republic 0.6 preview --- pyproject.toml | 5 +- src/bub/builtin/model_runner.py | 76 +++++++++---------- src/bub/builtin/onboarding.py | 4 +- tests/test_builtin_model_runner.py | 37 +++++++++ tests/test_provider_messages.py | 13 +++- uv.lock | 10 ++- .../content/docs/docs/reference/settings.mdx | 2 +- .../docs/zh-cn/docs/reference/settings.mdx | 2 +- 8 files changed, 94 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ea2082ff..6235fc2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "pluggy>=1.6.0", "inquirer-textual>=0.8.0", "typer>=0.24.1", - "republic @ git+https://github.com/bubbuild/republic.git@449debdfed5b45dffb4744091db84fbbeb0704b7", + "republic>=0.6.0a1", "rich>=14.3.4", "prompt-toolkit>=3.0.52", "python-telegram-bot>=22.7", @@ -72,9 +72,6 @@ dev = [ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" -[tool.hatch.metadata] -allow-direct-references = true - [tool.hatch.version] source = "vcs" fallback-version = "0.3.0" diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index 5ad5f20e..63192c7f 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -53,40 +53,39 @@ async def _completion( completion_error: Exception | None = None candidates = self.settings.model_candidates(model) for index, candidate in enumerate(candidates): - stream: republic.Stream[Any] | None = None - try: - client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider) - chat_model = republic.get_model(f"{candidate.provider}:{candidate.model_id}", **client_kwargs) - if span := current_span(): - span.rename(f"chat {candidate.model_id}") - span.set(**{ - "gen_ai.provider.name": candidate.provider, - "gen_ai.request.model": candidate.model_id, - }) - options = self._chat_options(candidate.provider, tools, max_tokens, reasoning_effort) - stream = chat_model.stream( - _request_messages(messages, candidate.provider_name or candidate.provider, candidate.model_id), - **options, - ) - await stream.__aenter__() - except Exception as exc: - if stream is not None: - await stream.__aexit__(type(exc), exc, exc.__traceback__) - event("bub.model.attempt_failed", model=candidate.name, error=type(exc).__name__) - if completion_error is None: - completion_error = exc - if index == len(candidates) - 1: - raise completion_error from None - logger.warning( - "model candidate failed; trying fallback model={} error={}", candidate.name, type(exc).__name__ - ) - - else: + async with AsyncExitStack() as stack: try: + client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider) + chat_model = await stack.enter_async_context( + republic.get_model(f"{candidate.provider}:{candidate.model_id}", **client_kwargs) + ) + if span := current_span(): + span.rename(f"chat {candidate.model_id}") + span.set(**{ + "gen_ai.provider.name": candidate.provider, + "gen_ai.request.model": candidate.model_id, + }) + options = self._chat_options(candidate.provider, tools, max_tokens, reasoning_effort) + stream = await stack.enter_async_context( + chat_model.stream( + _request_messages( + messages, candidate.provider_name or candidate.provider, candidate.model_id + ), + **options, + ) + ) + except Exception as exc: + event("bub.model.attempt_failed", model=candidate.name, error=type(exc).__name__) + if completion_error is None: + completion_error = exc + if index == len(candidates) - 1: + raise completion_error from None + logger.warning( + "model candidate failed; trying fallback model={} error={}", candidate.name, type(exc).__name__ + ) + else: yield candidate, stream - finally: - await stream.__aexit__(None, None, None) - return + return raise RuntimeError("no model candidates available") @@ -382,15 +381,10 @@ def _request_messages(messages: list[dict[str, Any]], provider: str, model: str) raise BubError(ErrorKind.INVALID_INPUT, f"Unknown message role: {role}") if role == "tool": result.append( - republic.Message( - "tool", - tool_results=( - republic.tool_result( - calls[message["tool_call_id"]], - render_result(message.get("content", "")), - is_error=bool(message.get("is_error")), - ), - ), + republic.tool( + calls[message["tool_call_id"]], + render_result(message.get("content", "")), + is_error=bool(message.get("is_error")), ) ) continue diff --git a/src/bub/builtin/onboarding.py b/src/bub/builtin/onboarding.py index 1e122145..e19b27f1 100644 --- a/src/bub/builtin/onboarding.py +++ b/src/bub/builtin/onboarding.py @@ -24,6 +24,7 @@ "ollama": "Ollama (local server)", "mistral": "Mistral AI", "deepseek": "DeepSeek", + "magpie": "Magpie (local model gateway)", "custom": "Other registered Republic provider", } OPENAI_BASE = "https://api.openai.com/v1" @@ -75,8 +76,7 @@ def _ask_base(default: str, *, required: bool) -> str: async def _discover_models(provider: str, **client_args: Any) -> list[str]: - async with asyncio.timeout(CONNECTION_TIMEOUT): - client = republic.get_provider(provider, **client_args) + async with asyncio.timeout(CONNECTION_TIMEOUT), republic.get_provider(provider, **client_args) as client: models = await client.list_models() return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()}) diff --git a/tests/test_builtin_model_runner.py b/tests/test_builtin_model_runner.py index 8d0ca2f3..77fedafd 100644 --- a/tests/test_builtin_model_runner.py +++ b/tests/test_builtin_model_runner.py @@ -1,10 +1,13 @@ from __future__ import annotations import json +from contextlib import nullcontext from pathlib import Path +import httpx2 import pytest import republic +from republic.errors import APIStatusError, StreamIncompleteError from bub.builtin.context import default_tape_context from bub.builtin.model_runner import ModelRunner @@ -16,6 +19,40 @@ from tests.model_fakes import ProviderService, chat_events, sse, tool_events +@pytest.mark.asyncio +@pytest.mark.parametrize("outcome", ["success", "http_error", "incomplete"]) +async def test_model_call_closes_its_owned_client( + tmp_path: Path, provider_service: ProviderService, monkeypatch: pytest.MonkeyPatch, outcome: str +) -> None: + clients = [] + constructor = httpx2.AsyncClient + + def create_client(**kwargs): + client = constructor(**kwargs, transport=httpx2.MockTransport(provider_service._respond)) + clients.append(client) + return client + + monkeypatch.setattr(httpx2, "AsyncClient", create_client) + error = None + if outcome == "http_error": + provider_service.reply(httpx2.Response(401, json={"error": "unauthorized"})) + error = APIStatusError + else: + provider_service.reply(sse(chat_events() if outcome == "success" else chat_events()[:-1])) + if outcome == "incomplete": + error = StreamIncompleteError + runner = ModelRunner(AgentSettings(client_args={"api_format": "chat", "max_retries": 0})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("owned") + with pytest.raises(error) if error is not None else nullcontext(): + events = [ + event + async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + ] + assert events[-1].data == {"ok": True, "text": "done"} + assert len(clients) == 1 + assert clients[0].is_closed + + @pytest.mark.asyncio async def test_unknown_tool_returns_an_error_result(tmp_path: Path, provider_service: ProviderService) -> None: provider_service.reply( diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index 13734b30..9c006a0b 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -80,7 +80,10 @@ async def video(): @pytest.mark.asyncio -async def test_consumer_close_releases_the_request_without_closing_the_shared_client(tmp_path: Path) -> None: +@pytest.mark.parametrize("external_client", [False, True]) +async def test_consumer_close_releases_the_request_and_respects_client_ownership( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, external_client: bool +) -> None: closed = asyncio.Event() class Body(httpx2.AsyncByteStream): @@ -94,13 +97,17 @@ async def aclose(self): async with httpx2.AsyncClient( transport=httpx2.MockTransport(lambda _: httpx2.Response(200, stream=Body())) ) as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + monkeypatch.setattr(httpx2, "AsyncClient", lambda **kwargs: client) + client_args = {"api_format": "chat"} + if external_client: + client_args["http_client"] = client + runner = ModelRunner(AgentSettings(client_args=client_args)) tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cancel") events = runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") assert (await anext(events)).data == {"delta": "first"} await events.aclose() assert closed.is_set() - assert not client.is_closed + assert client.is_closed is not external_client assert not await tape.store.fetch_all(tape.query().kinds("message")) diff --git a/uv.lock b/uv.lock index 6a1e5e5d..6480ddab 100644 --- a/uv.lock +++ b/uv.lock @@ -246,7 +246,7 @@ requires-dist = [ { name = "python-telegram-bot", specifier = ">=22.7" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rapidfuzz", specifier = ">=3.14.5" }, - { name = "republic", git = "https://github.com/bubbuild/republic.git?rev=449debdfed5b45dffb4744091db84fbbeb0704b7" }, + { name = "republic", specifier = ">=0.6.0a1" }, { name = "rich", specifier = ">=14.3.4" }, { name = "typer", specifier = ">=0.24.1" }, { name = "typing-extensions", specifier = ">=4.15.0" }, @@ -1645,14 +1645,18 @@ wheels = [ [[package]] name = "republic" -version = "0.5.9.dev23+g449debdfe" -source = { git = "https://github.com/bubbuild/republic.git?rev=449debdfed5b45dffb4744091db84fbbeb0704b7#449debdfed5b45dffb4744091db84fbbeb0704b7" } +version = "0.6.0a1" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, { name = "filelock" }, { name = "httpx2" }, { name = "pydantic" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/02/a2/4c36a93722932d29095c6aca053829d77a5ae1d9a5b2e989e61544d528af/republic-0.6.0a1.tar.gz", hash = "sha256:3eb72270bb8f0e52c3fbe66aed37e88edf7b8f31bb3926db508aeb8f71306a93", size = 227363, upload-time = "2026-10-10T03:59:28.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/1e/c70546f7ab7947ccb44f305e297fb42924db6fc82ec23ecd9d58ecebd692/republic-0.6.0a1-py3-none-any.whl", hash = "sha256:7970fd013890e7c8b121bdc91f3d7cf93b2fa30605cf4540afb5b790a4a86516", size = 74563, upload-time = "2026-10-10T03:59:29.577Z" }, +] [[package]] name = "requests" diff --git a/website/src/content/docs/docs/reference/settings.mdx b/website/src/content/docs/docs/reference/settings.mdx index 8902c3cc..59f1ad01 100644 --- a/website/src/content/docs/docs/reference/settings.mdx +++ b/website/src/content/docs/docs/reference/settings.mdx @@ -87,7 +87,7 @@ Bub uses Republic for model requests. Use `codex:` for ChatGPT plan acces Provider-specific fields go in `extra_body`, which is accepted in both `client_args` and `completion_args`. Republic deep-merges provider defaults with request extras, with request values taking precedence, then merges them over the encoded request body. These explicit wire fields can override chat options, including reasoning and token limits; Bub passes them through unchanged. -Provider names and available protocols follow [Republic's provider directory](https://github.com/bubbuild/republic/blob/main/docs/providers/index.md). Custom provider implementations must be registered with Republic. +Provider names and available protocols follow [Republic's provider directory](https://getrepublic.org/providers/). Custom provider implementations must be registered with Republic. ### Custom providers diff --git a/website/src/content/docs/zh-cn/docs/reference/settings.mdx b/website/src/content/docs/zh-cn/docs/reference/settings.mdx index 0eb6998a..9644b27e 100644 --- a/website/src/content/docs/zh-cn/docs/reference/settings.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/settings.mdx @@ -87,7 +87,7 @@ Bub 使用 Republic 发起模型请求。ChatGPT 套餐访问使用 `codex: Date: Sat, 10 Oct 2026 15:09:40 +0800 Subject: [PATCH 06/16] Refactor prompt handling and improve compatibility with legacy content - Introduced `republic` module to manage media types and user content. - Updated tests to validate new prompt structure and legacy content conversion. - Modified `build_prompt` to return a list of `UserContent` instead of legacy formats. - Enhanced documentation to reflect changes in prompt handling and hook signatures. - Adjusted various tests to ensure compatibility with new prompt formats and legacy support. Signed-off-by: Frost Ming --- src/bub/builtin/agent.py | 66 +++++------ src/bub/builtin/context.py | 97 ++++++----------- src/bub/builtin/hook_impl.py | 29 ++--- src/bub/builtin/model_runner.py | 95 +++------------- src/bub/builtin/onboarding.py | 10 +- src/bub/framework.py | 28 ++--- src/bub/hooks/interception.py | 5 +- src/bub/hooks/runtime.py | 13 ++- src/bub/hooks/specs.py | 18 ++- src/bub/prompt.py | 58 ++++++++++ src/bub/tape.py | 103 +++++++++++++++--- src/bub/tracing.py | 12 +- src/bub/turn.py | 7 +- tests/conftest.py | 9 ++ tests/test_agent_hooks.py | 22 ++-- tests/test_builtin_agent.py | 34 +++--- tests/test_builtin_codex.py | 13 ++- tests/test_builtin_hook_impl.py | 6 +- tests/test_builtin_model_runner.py | 77 ++++++++++--- tests/test_framework.py | 25 ++++- tests/test_hook_runtime.py | 25 +++++ tests/test_image_message.py | 99 +++++++---------- tests/test_onboarding.py | 27 ++--- tests/test_provider_messages.py | 23 ++-- tests/test_settings.py | 3 +- tests/test_spill.py | 5 +- tests/test_tape.py | 52 ++++++++- .../docs/docs/concepts/tape-and-context.mdx | 10 +- .../src/content/docs/docs/reference/hooks.mdx | 8 +- .../src/content/docs/docs/reference/types.mdx | 2 +- .../zh-cn/docs/concepts/tape-and-context.mdx | 10 +- .../docs/zh-cn/docs/reference/hooks.mdx | 8 +- .../docs/zh-cn/docs/reference/types.mdx | 2 +- 33 files changed, 605 insertions(+), 396 deletions(-) create mode 100644 src/bub/prompt.py diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 8e336934..14a904cb 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Any +import republic from loguru import logger from bub.builtin.commands import strip_command_prefix, validate_command_prefix @@ -25,6 +26,7 @@ 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, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState @@ -115,7 +117,7 @@ async def run_stream( self, *, session_id: str, - prompt: str | list[dict], + prompt: list[UserContent] | LegacyPrompt, state: TurnState | None = None, model: str | None = None, allowed_skills: Collection[str] | None = None, @@ -127,8 +129,9 @@ async def run_stream( Args: session_id: Session identity within the workspace. A ``temp/`` prefix prevents the turn's fork from merging back into its parent tape. - prompt: Text or multimodal content parts. Text beginning with this - agent's command prefix after stripping whitespace invokes a command. + prompt: User content (text, images, audio and video); legacy text and content blocks are converted. + A text-only prompt beginning with this agent's command prefix after + stripping whitespace invokes a command. state: Mutable turn state. None loads state through framework hooks using this agent's store; supplied state skips that loading. The current agent is always bound into the state. @@ -154,11 +157,12 @@ async def run_stream( "gen_ai.conversation.id": session_id, }, ) - span.messages("gen_ai.input.messages", [{"role": "user", "content": prompt}]) stack = AsyncExitStack() try: with span.activate(): - if not prompt: + content = to_content(prompt) + span.messages("gen_ai.input.messages", [republic.user(*content)]) + if not any(content): events = self._events_from_iterable([ StreamEvent("text", {"delta": "error: empty prompt"}), StreamEvent("final", {"text": "error: empty prompt", "ok": False}), @@ -180,7 +184,11 @@ async def run_stream( tape.fork_tape(merge_back=not session_id.startswith("temp/")) ) await tape.ensure_bootstrap_anchor() - command = strip_command_prefix(prompt, self.command_prefix) if isinstance(prompt, str) else None + command = ( + strip_command_prefix(content[0], self.command_prefix) + if len(content) == 1 and isinstance(content[0], str) + else None + ) if command is not None: result = await self._run_command(tape=tape, line=command) events = self._events_from_iterable([ @@ -190,7 +198,7 @@ async def run_stream( else: events = await self._agent_loop( tape=tape, - prompt=prompt, + prompt=content, model=model, allowed_skills=allowed_skills, allowed_tools=allowed_tools, @@ -283,18 +291,18 @@ async def _agent_loop( self, *, tape: Tape, - prompt: str | list[dict], + prompt: list[UserContent], model: str | None = None, allowed_skills: Collection[str] | None = None, allowed_tools: Collection[str] | None = None, ) -> AsyncStreamEvents: - next_prompt: str | list[dict] = prompt + next_prompt = prompt display_model = model or self.settings.model await tape.append_event( "loop.start", { "model": display_model, - "prompt": prompt, + "prompt": _prompt_payload(prompt), "allowed_skills": list(allowed_skills) if allowed_skills else None, "allowed_tools": list(allowed_tools) if allowed_tools else None, }, @@ -313,7 +321,7 @@ async def _agent_loop( async def _stream_events_with_auto_handoff( self, tape: Tape, - prompt: str | list[dict], + prompt: list[UserContent], state: StreamState, model: str | None = None, allowed_skills: Collection[str] | None = None, @@ -321,12 +329,15 @@ async def _stream_events_with_auto_handoff( ) -> AsyncGenerator[StreamEvent, None]: auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES display_model = model or self.settings.model - next_prompt: str | list[dict] | None = prompt + next_prompt: list[UserContent] | None = prompt for step in range(1, self.settings.max_steps + 1): start = time.monotonic() should_continue = False logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model) - await tape.append_event("loop.step.start", {"step": step, "prompt": next_prompt}) + await tape.append_event( + "loop.step.start", + {"step": step, "prompt": None if next_prompt is None else _prompt_payload(next_prompt)}, + ) try: output = await self._run_once( tape=tape, @@ -407,7 +418,8 @@ async def _stream_events_with_auto_handoff( ) return - next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state) + continuation = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state) + next_prompt = None if continuation is None else [continuation] await tape.append_event( "loop.step", { @@ -433,24 +445,17 @@ async def _run_once( self, *, tape: Tape, - prompt: str | list[dict] | None, + prompt: list[UserContent] | None, model: str | None = None, allowed_tools: Collection[str] | None = None, allowed_skills: Collection[str] | None = None, ) -> AsyncStreamEvents: - if isinstance(prompt, str): - prompt_text = prompt - elif prompt is None: - prompt_text = "" - else: - prompt_text = _extract_text_from_parts(prompt) if allowed_skills is not None: allowed_skills = {name.casefold() for name in allowed_skills} tape.context.state["allowed_skills"] = list(allowed_skills) return await self._run_once_stream( tape=tape, prompt=prompt, - prompt_text=prompt_text, model=model, allowed_skills=allowed_skills, tools=self._allowed_tools(tape, allowed_tools), @@ -492,8 +497,7 @@ async def _run_once_stream( self, *, tape: Tape, - prompt: str | list[dict] | None, - prompt_text: str, + prompt: list[UserContent] | None, model: str | None, allowed_skills: set[str] | None, tools: list[Tool], @@ -502,7 +506,7 @@ async def _run_once_stream( tools, code_mode_prompt = await self._prepare_code_mode(tools, tape) tools, deferred_prompt = await self._prepare_deferred_tools(tools, tape) system_prompt = self._system_prompt( - prompt_text, + prompt or [], state=tape.context.state, allowed_skills=allowed_skills, tools_prompt="\n\n".join(block for block in (code_mode_prompt, deferred_prompt) if block), @@ -516,7 +520,7 @@ async def _run_once_stream( }) steering_inbox = self.framework.get_steering_inbox() steering_envelopes = await steering_inbox.drain_messages(tape.context.state) if steering_inbox else [] - steering_messages = list( + steering_messages: list[list[UserContent]] = list( await asyncio.gather(*[ self.framework.build_prompt( message, session_id=field_of(message, "session_id"), state=tape.context.state @@ -564,7 +568,7 @@ async def _prepare_code_mode(self, tools: list[Tool], tape: Tape) -> tuple[list[ def _system_prompt( self, - prompt: str, + prompt: list[UserContent], state: TurnState, allowed_skills: set[str] | None = None, tools_prompt: str = "", @@ -575,7 +579,7 @@ def _system_prompt( if tools_prompt: blocks.append(tools_prompt) workspace = workspace_from_state(state) - if skills_prompt := self._load_skills_prompt(prompt, workspace, allowed_skills): + if skills_prompt := self._load_skills_prompt(prompt_text(prompt), workspace, allowed_skills): blocks.append(skills_prompt) return "\n\n".join(blocks) @@ -614,6 +618,6 @@ def _parse_args(args_tokens: list[str]) -> Args: return Args(positional=positional, kwargs=kwargs) -def _extract_text_from_parts(parts: list[dict]) -> str: - """Extract text content from multimodal content parts.""" - return "\n".join(p.get("text", "") for p in parts if p.get("type") == "text") +def _prompt_payload(content: list[UserContent]) -> object: + """Serialize prompt content for tape events.""" + return republic.user(*content).to_dict().get("content") diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py index 286b5dcd..54fc7ffb 100644 --- a/src/bub/builtin/context.py +++ b/src/bub/builtin/context.py @@ -6,7 +6,9 @@ from collections.abc import Iterable from typing import Any -from bub.tape import TapeContext, TapeEntry +import republic + +from bub.tape import TapeContext, TapeEntry, to_message from bub.tools import render_result @@ -16,90 +18,53 @@ def default_tape_context() -> TapeContext: return TapeContext(select=_select_messages) -def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[dict[str, Any]]: - messages: list[dict[str, Any]] = [] - pending_calls: list[dict[str, Any]] = [] +def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[republic.Message]: + messages: list[republic.Message] = [] + calls: dict[str, republic.ToolCall] = {} + pending_calls: list[republic.ToolCall] = [] for entry in entries: match entry.kind: case "anchor": - _append_anchor_entry(messages, entry) + messages.append(_anchor_message(entry)) case "message": - _append_message_entry(messages, entry) + if isinstance(entry.payload, dict): + messages.append(to_message(entry.payload, calls)) case "tool_call": - pending_calls = _append_tool_call_entry(messages, entry) + pending_calls = _append_tool_call_entry(messages, calls, entry) case "tool_result": _append_tool_result_entry(messages, pending_calls, entry) pending_calls = [] return messages -def _append_anchor_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> None: +def _anchor_message(entry: TapeEntry) -> republic.Message: payload = entry.payload content = f"[Anchor created: {payload.get('name')}]: {json.dumps(payload.get('state'), ensure_ascii=False)}" - messages.append({"role": "assistant", "content": content}) + return republic.assistant(content) -def _append_message_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> None: - payload = entry.payload - if isinstance(payload, dict): - messages.append(dict(payload)) - - -def _append_tool_call_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> list[dict[str, Any]]: - calls = _normalize_tool_calls(entry.payload.get("calls")) - if calls: - fields = { - key: entry.payload[key] - for key in ("reasoning", "provider_data", "source_provider", "source_model") - if key in entry.payload - } - messages.append({ - "role": "assistant", - "content": entry.payload.get("content") or "", - "tool_calls": calls, - **fields, - }) - return calls +def _append_tool_call_entry( + messages: list[republic.Message], calls: dict[str, republic.ToolCall], entry: TapeEntry +) -> list[republic.ToolCall]: + raw_calls = entry.payload.get("calls") + if not isinstance(raw_calls, list) or not (tool_calls := [item for item in raw_calls if isinstance(item, dict)]): + return [] + fields = {key: entry.payload[key] for key in ("reasoning", "provider_data") if key in entry.payload} + message = to_message( + {"role": "assistant", "content": entry.payload.get("content") or "", "tool_calls": tool_calls, **fields}, + calls, + ) + messages.append(message) + return list(message.tool_calls) def _append_tool_result_entry( - messages: list[dict[str, Any]], - pending_calls: list[dict[str, Any]], - entry: TapeEntry, + messages: list[republic.Message], pending_calls: list[republic.ToolCall], entry: TapeEntry ) -> None: - results = entry.payload.get("results") + results: Any = entry.payload.get("results") if not isinstance(results, list): return - for index, result in enumerate(results): - messages.append(_build_tool_result_message(result, pending_calls, index)) - - -def _build_tool_result_message( - result: object, - pending_calls: list[dict[str, Any]], - index: int, -) -> dict[str, Any]: - message: dict[str, Any] = {"role": "tool", "content": render_result(result)} - if index >= len(pending_calls): - return message - - call = pending_calls[index] - call_id = call.get("id") - if isinstance(call_id, str) and call_id: - message["tool_call_id"] = call_id - - name = call.get("name") - if isinstance(name, str) and name: - message["name"] = name - return message - - -def _normalize_tool_calls(value: object) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - calls: list[dict[str, Any]] = [] - for item in value: - if isinstance(item, dict): - calls.append(dict(item)) - return calls + # Results without a recorded call cannot be expressed as tool messages. + for call, result in zip(pending_calls, results, strict=False): + messages.append(republic.tool(call, render_result(result))) diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 2e576a6d..70e2746f 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import cast +import republic import typer from loguru import logger @@ -26,6 +27,7 @@ from bub.hooks import hookimpl from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult 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 @@ -134,27 +136,28 @@ async def save_state(self, session_id: str, state: TurnState, message: ChannelMe # events, so nothing to write here — this hook only closes the lifespan. @hookimpl - async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> str | list[dict]: + async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> list[UserContent]: content = content_of(message) if strip_command_prefix(content, self._get_agent(state).command_prefix) is not None: message.kind = "command" - return content.strip() + return [content.strip()] context = field_of(message, "context_str") now = datetime.now().astimezone().isoformat(timespec="seconds") context_prefix = f"{context}\n---Date: {now}---\n" if context else "" text = f"{context_prefix}{content}" - media = field_of(message, "media") or [] - if not media: - return text - - media_parts: list[dict] = [] - for item in cast("list[MediaItem]", media): - if item.type in {"image", "audio", "video"} and (url := await item.get_url()): - media_parts.append({"type": item.type, "media_type": item.mime_type, "url": url}) - if media_parts: - return [{"type": "text", "text": text}, *media_parts] - return text + prompt: list[UserContent] = [text] + for item in cast("list[MediaItem]", field_of(message, "media") or []): + if item.type not in {"image", "audio", "video"} or not (url := await item.get_url()): + continue + match item.type: + case "image": + prompt.append(republic.image(url, media_type=item.mime_type)) + case "audio": + prompt.append(republic.audio(url, media_type=item.mime_type)) + case "video": + prompt.append(republic.video(url, media_type=item.mime_type)) + return prompt @hookimpl async def run_model_stream(self, prompt: str | list[dict], session_id: str, state: TurnState) -> AsyncStreamEvents: diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index 63192c7f..8b4f248f 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -6,7 +6,7 @@ import re from collections.abc import AsyncGenerator, AsyncIterator from contextlib import AsyncExitStack, aclosing, asynccontextmanager -from dataclasses import asdict, replace +from dataclasses import asdict from datetime import UTC, datetime from json import JSONDecodeError from time import monotonic @@ -24,9 +24,10 @@ LlmCallRequest, LlmCallResult, ) +from bub.prompt import UserContent from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.tape import Tape -from bub.tools import Tool, ToolContext, ToolExecutor, render_result +from bub.tools import Tool, ToolContext, ToolExecutor from bub.tracing import Span, current_span, event CONTEXT_LENGTH_PATTERNS = re.compile( @@ -45,7 +46,7 @@ async def _completion( self, *, model: str, - messages: list[dict[str, Any]], + messages: list[republic.Message], tools: list[Tool], max_tokens: int | None = None, reasoning_effort: str | None = None, @@ -66,14 +67,7 @@ async def _completion( "gen_ai.request.model": candidate.model_id, }) options = self._chat_options(candidate.provider, tools, max_tokens, reasoning_effort) - stream = await stack.enter_async_context( - chat_model.stream( - _request_messages( - messages, candidate.provider_name or candidate.provider, candidate.model_id - ), - **options, - ) - ) + stream = await stack.enter_async_context(chat_model.stream(messages, **options)) except Exception as exc: event("bub.model.attempt_failed", model=candidate.name, error=type(exc).__name__) if completion_error is None: @@ -109,8 +103,8 @@ def run( model: str, tools: list[Tool], system_prompt: str | None, - prompt: str | list[dict] | None, - steering_messages: list[list[dict[str, Any]] | str] | None = None, + prompt: list[UserContent] | None, + steering_messages: list[list[UserContent]] | None = None, ) -> AsyncStreamEvents: state = StreamState() @@ -182,8 +176,6 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: }.items() if value } - if assistant_fields or any(call.metadata for call in response.tool_calls): - assistant_fields.update(source_provider=provider, source_model=candidate.model_id) await tape.record_chat( run_id=run_id, system_prompt=system_prompt, @@ -310,10 +302,10 @@ async def build_messages( tape: Tape, run_id: str, system_prompt: str | None, - prompt: str | list[dict] | None, + prompt: list[UserContent] | None, model: str, - steering_messages: list[list[dict[str, Any]] | str] | None = None, - ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + steering_messages: list[list[UserContent]] | None = None, + ) -> tuple[list[republic.Message], list[republic.Message]]: try: messages = await tape.read_messages() except BubError as exc: @@ -327,12 +319,10 @@ async def build_messages( model=model, ) raise - steering = [{"role": "user", "content": message} for message in (steering_messages or [])] + contents = [*(steering_messages or []), *([] if prompt is None else [prompt])] + new_messages = [republic.user(*content) for content in contents] if system_prompt: - messages = [{"role": "system", "content": system_prompt}, *messages] - new_messages = [*steering] - if prompt is not None: - new_messages.append({"role": "user", "content": prompt}) + messages = [republic.system(system_prompt), *messages] messages.extend(new_messages) return messages, new_messages @@ -371,65 +361,6 @@ async def _stream_events( }) -def _request_messages(messages: list[dict[str, Any]], provider: str, model: str) -> list[republic.Message]: - """Convert the tape and hook dictionaries at the request boundary.""" - result: list[republic.Message] = [] - calls: dict[str, republic.ToolCall] = {} - for message in messages: - role = message["role"] - if role not in {"system", "user", "assistant", "tool"}: - raise BubError(ErrorKind.INVALID_INPUT, f"Unknown message role: {role}") - if role == "tool": - result.append( - republic.tool( - calls[message["tool_call_id"]], - render_result(message.get("content", "")), - is_error=bool(message.get("is_error")), - ) - ) - continue - converted = republic.user(*_request_content(message.get("content"))) - keep_metadata = message.get("source_provider") == provider and message.get("source_model") == model - parts = list(converted.parts) - if keep_metadata: - if reasoning := message.get("reasoning"): - parts.append(republic.Reasoning(reasoning)) - parts.extend(republic.ProviderData(**item) for item in message.get("provider_data", [])) - tool_calls = tuple( - republic.ToolCall(**(call | {"metadata": call.get("metadata", {}) if keep_metadata else {}})) - for call in message.get("tool_calls") or [] - ) - calls.update((call.id, call) for call in tool_calls) - result.append(replace(converted, role=role, parts=tuple(parts), tool_calls=tool_calls)) - return result - - -def _request_content(content: object) -> list[str | republic.Image | republic.Audio | republic.Video]: - if content is None: - return [] - if isinstance(content, str): - return [content] - if not isinstance(content, list): - raise BubError(ErrorKind.INVALID_INPUT, "Expected text or a list of message content blocks.") - result: list[str | republic.Image | republic.Audio | republic.Video] = [] - for block in content: - match block: - case {"type": "text", "text": str(text)}: - result.append(text) - case {"type": "image", "url": str(url), "media_type": str(mime)}: - result.append(republic.image(url, media_type=mime)) - case {"type": "audio", "url": str(url), "media_type": str(mime)}: - result.append(republic.audio(url, media_type=mime)) - case {"type": "video", "url": str(url), "media_type": str(mime)}: - result.append(republic.video(url, media_type=mime)) - case _: - raise BubError( - ErrorKind.INVALID_INPUT, - f"Unsupported message content block: {block.get('type') if isinstance(block, dict) else type(block).__name__}", - ) - return result - - def _tool_invocation( tool_call: republic.ToolCall, tool_map: dict[str, Tool], diff --git a/src/bub/builtin/onboarding.py b/src/bub/builtin/onboarding.py index e19b27f1..d7518d8a 100644 --- a/src/bub/builtin/onboarding.py +++ b/src/bub/builtin/onboarding.py @@ -8,7 +8,7 @@ import republic import typer -from republic.errors import AuthenticationError, ProviderNotFoundError, UnsupportedFeatureError +from republic.errors import APITimeoutError, AuthenticationError, ProviderNotFoundError, UnsupportedFeatureError from bub import configure, inquirer from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, CustomProvider @@ -76,9 +76,9 @@ def _ask_base(default: str, *, required: bool) -> str: async def _discover_models(provider: str, **client_args: Any) -> list[str]: - async with asyncio.timeout(CONNECTION_TIMEOUT), republic.get_provider(provider, **client_args) as client: + async with republic.get_provider(provider, **{**client_args, "timeout": CONNECTION_TIMEOUT}) as client: models = await client.list_models() - return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()}) + return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()}) def discover_models(provider: str, **client_args: Any) -> list[str]: @@ -90,7 +90,7 @@ def _connection_error(exc: Exception) -> str: # Provider errors may contain request URLs, response bodies or credentials. if isinstance(exc, AuthenticationError): return "Authentication failed or API key missing. Check the key and its permissions." - if isinstance(exc, TimeoutError): + if isinstance(exc, TimeoutError | APITimeoutError): return f"Connection timed out after {CONNECTION_TIMEOUT} seconds. Check the URL and network." if isinstance(exc, NotImplementedError | UnsupportedFeatureError | ProviderNotFoundError): return "Model discovery is unavailable for this provider. You can enter a model ID manually." @@ -172,7 +172,7 @@ def _select_connection( typer.echo(f"API endpoint: {endpoint}" if api_base else f"Default API endpoint: {endpoint}") if ( choice in {"openai-compatible", "custom"} - or provider in {"azure-openai", "ollama"} + or provider in {"azure-openai", "ollama", "magpie"} or (choice != "openai" and api_base and _endpoint(provider, api_base) != _default_base(provider).rstrip("/")) ): api_base = _ask_base(api_base, required=choice == "openai-compatible" or provider == "azure-openai") diff --git a/src/bub/framework.py b/src/bub/framework.py index a8761138..600ec6ee 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import pluggy import typer @@ -24,6 +24,7 @@ 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.sidecars import TapeSidecar from bub.store import AsyncTapeStore, TapeStore from bub.streaming import StreamState @@ -131,20 +132,19 @@ def _main( self._hook_runtime.call_many_sync("register_cli_commands", app=app) return app - async def build_prompt( - self, message: Envelope, session_id: str, state: dict[str, Any] - ) -> str | list[dict[str, Any]]: - """Build prompt for one message turn.""" + async def build_prompt(self, message: Envelope, session_id: str, state: dict[str, Any]) -> list[UserContent]: + """Build prompt content for one message turn, converting legacy hook results.""" prompt = await self._hook_runtime.call_first( "build_prompt", message=message, session_id=session_id, state=state ) if not prompt: prompt = content_of(message) - return cast("str | list[dict[str, Any]]", prompt) + return to_content(prompt) - async def continue_prompt(self, prompt: str | list[dict] | None, tape: Tape, state: StreamState) -> str | None: + async def continue_prompt(self, prompt: list[UserContent] | None, tape: Tape, state: StreamState) -> str | None: """Return an optional user message for the next step of an agent loop.""" - next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=prompt, tape=tape, state=state) + legacy = None if prompt is None else to_legacy_prompt(prompt) + next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=legacy, tape=tape, state=state) if next_prompt is None or isinstance(next_prompt, str): return next_prompt raise TypeError("hook.continue_prompt must return str or None") @@ -219,7 +219,7 @@ async def resolve_session(self, message: Envelope) -> str: async def _run_model( self, inbound: Envelope, - prompt: str | list[dict], + prompt: list[UserContent], session_id: str, state: dict[str, Any], stream_output: bool, @@ -232,7 +232,7 @@ async def _run_model( error=RuntimeError("no model skill returned output"), message=inbound, ) - return prompt if isinstance(prompt, str) else content_of(inbound) + return text if isinstance(text := to_legacy_prompt(prompt), str) else content_of(inbound) return output stream = await self._hook_runtime.run_model_stream(prompt=prompt, session_id=session_id, state=state) if stream is None: @@ -241,7 +241,7 @@ async def _run_model( error=RuntimeError("no model skill returned output"), message=inbound, ) - return prompt if isinstance(prompt, str) else content_of(inbound) + return text if isinstance(text := to_legacy_prompt(prompt), str) else content_of(inbound) else: parts: list[str] = [] events = self._channel_router.wrap_stream(inbound, stream) if self._channel_router is not None else stream @@ -475,7 +475,7 @@ def get_agent_hooks(self) -> AgentHooks: """Return the model and tool interception adapter for this framework's hooks.""" return self._agent_hooks - def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) -> str: + def get_system_prompt(self, prompt: list[UserContent], state: dict[str, Any]) -> str: """Join nonempty system-prompt hook results from low to high priority. Hooks contribute additional blocks; a higher-priority hook does not replace @@ -483,7 +483,9 @@ def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) -> """ return "\n\n".join( result - for result in reversed(self._hook_runtime.call_many_sync("system_prompt", prompt=prompt, state=state)) + for result in reversed( + self._hook_runtime.call_many_sync("system_prompt", prompt=to_legacy_prompt(prompt), state=state) + ) if result ) diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index 931b321a..f94f7d47 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any, Literal +import republic from loguru import logger from bub.hooks.runtime import _SKIP_VALUE, HookRuntime @@ -19,13 +20,13 @@ class LlmCallRequest: """Outgoing agent-loop LLM request exposed to interception hooks. Hooks may return a modified copy (``dataclasses.replace``) to change the - model or messages for this call. Tool objects are not exposed; + model or messages (``republic.Message`` objects) for this call. Tool objects are not exposed; ``tool_names`` is observational and altering the toolset is out of scope. """ run_id: str model: str - messages: list[dict[str, Any]] + messages: list[republic.Message] tool_names: tuple[str, ...] = () max_tokens: int | None = None diff --git a/src/bub/hooks/runtime.py b/src/bub/hooks/runtime.py index 2b0bf112..9a353d74 100644 --- a/src/bub/hooks/runtime.py +++ b/src/bub/hooks/runtime.py @@ -11,6 +11,7 @@ from loguru import logger from bub.envelope import Envelope +from bub.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.turn import TurnState @@ -162,8 +163,11 @@ def _iter_hookimpls(self, hook_name: str) -> list[Any]: def _kwargs_for_impl(impl: Any, kwargs: dict[str, Any]) -> dict[str, Any]: return {name: kwargs[name] for name in impl.argnames if name in kwargs} - async def run_model(self, prompt: str | list[dict], session_id: str, state: TurnState) -> str | None: - """Run the first model hook found and return its text.""" + async def run_model( + self, prompt: list[UserContent] | LegacyPrompt, session_id: str, state: TurnState + ) -> str | None: + """Run the first model hook found and return its text; hooks receive the legacy prompt form.""" + prompt = to_legacy_prompt(to_content(prompt)) for _, plugin in reversed(self._plugin_manager.list_name_plugin()): if hasattr(plugin, "run_model"): @@ -182,9 +186,10 @@ async def run_model(self, prompt: str | list[dict], session_id: str, state: Turn return None async def run_model_stream( - self, prompt: str | list[dict], session_id: str, state: TurnState + self, prompt: list[UserContent] | LegacyPrompt, session_id: str, state: TurnState ) -> AsyncStreamEvents | None: - """Run the first streaming model hook, falling back to a text hook.""" + """Run the first streaming model hook, falling back to a text hook; hooks receive the legacy prompt form.""" + prompt = to_legacy_prompt(to_content(prompt)) for _, plugin in reversed(self._plugin_manager.list_name_plugin()): if hasattr(plugin, "run_model_stream"): diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index 73a0bab6..24b4f5ad 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -21,6 +21,7 @@ ToolCallResult, ) 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 @@ -36,7 +37,14 @@ class BubHookSpecs: - """Hook contract for Bub framework extensions.""" + """Hook contract for Bub framework extensions. + + ``build_prompt`` returns user content: text, ``republic.Image``, + ``republic.Audio`` and ``republic.Video``; legacy text and content-block + results are still converted by inspecting the returned value. Hooks that + receive a prompt get the legacy form: text without media, otherwise content + blocks with media URLs (see ``bub.prompt.to_legacy_prompt``). + """ @hookspec(firstresult=True) def resolve_session(self, message: Envelope) -> str: @@ -44,11 +52,11 @@ def resolve_session(self, message: Envelope) -> str: raise NotImplementedError @hookspec(firstresult=True) - def build_prompt(self, message: Envelope, session_id: str, state: TurnState) -> str | list[dict]: - """Build model prompt for this turn. + def build_prompt(self, message: Envelope, session_id: str, state: TurnState) -> list[UserContent] | LegacyPrompt: + """Build model prompt content for this turn. - Returns either a plain text string or a list of content parts - (OpenAI multimodal format) when media attachments are present. + Return a list of text and Republic media such as ``republic.image(...)``. + Legacy text and content-block lists are still accepted and converted. """ raise NotImplementedError diff --git a/src/bub/prompt.py b/src/bub/prompt.py new file mode 100644 index 00000000..d0f23aa8 --- /dev/null +++ b/src/bub/prompt.py @@ -0,0 +1,58 @@ +"""User prompt content and the legacy prompt forms accepted for compatibility.""" + +from __future__ import annotations + +from typing import Any + +import republic + +from bub.errors import BubError, ErrorKind + +type UserContent = str | republic.Image | republic.Audio | republic.Video +type LegacyPrompt = str | list[dict[str, Any]] +"""Prompt forms used before ``list[UserContent]``: text, or text and media content blocks.""" + +_MEDIA_TYPES = (republic.Image, republic.Audio, republic.Video) + + +def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: + """Return user content, converting text and legacy content blocks by their value.""" + from bub.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/tape.py b/src/bub/tape.py index ec38760c..cbae608c 100644 --- a/src/bub/tape.py +++ b/src/bub/tape.py @@ -6,14 +6,16 @@ import hashlib import inspect import json -from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping +from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping, MutableMapping from dataclasses import asdict, dataclass, field, replace from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any +import republic + from bub import tracing -from bub.errors import BubError +from bub.errors import BubError, ErrorKind from bub.sidecars import TapeSidecar, sidecar_tape_name __all__ = [ @@ -35,6 +37,8 @@ "UnavailableTapeStore", "build_messages", "is_async_tape_store", + "to_message", + "to_messages", "utc_now", ] @@ -136,7 +140,7 @@ def __repr__(self) -> str: LAST_ANCHOR = _LastAnchor() type AnchorSelector = str | None | _LastAnchor -type SelectedMessages = list[dict[str, Any]] | Coroutine[Any, Any, list[dict[str, Any]]] +type SelectedMessages = list[republic.Message] | Coroutine[Any, Any, list[republic.Message]] type ContextSelector = Callable[[Iterable[TapeEntry], "TapeContext"], SelectedMessages] @@ -162,15 +166,84 @@ def build_messages(entries: Iterable[TapeEntry], context: TapeContext) -> Select return _default_messages(entries) -def _default_messages(entries: Iterable[TapeEntry]) -> list[dict[str, Any]]: - messages: list[dict[str, Any]] = [] - for entry in entries: - if entry.kind != "message": - continue - payload = entry.payload - if isinstance(payload, dict): - messages.append(dict(payload)) - return messages +def _default_messages(entries: Iterable[TapeEntry]) -> list[republic.Message]: + return to_messages( + entry.payload for entry in entries if entry.kind == "message" and isinstance(entry.payload, dict) + ) + + +def to_messages(payloads: Iterable[Mapping[str, Any]]) -> list[republic.Message]: + """Convert stored message payloads to Republic messages, resolving tool results to earlier calls.""" + calls: dict[str, republic.ToolCall] = {} + return [to_message(payload, calls) for payload in payloads] + + +def to_message( + payload: Mapping[str, Any], calls: MutableMapping[str, republic.ToolCall] | None = None +) -> republic.Message: + """Convert one stored message payload; assistant tool calls are added to ``calls``. + + Payloads are ``republic.Message.to_dict()`` output. Older Bub payloads, with media URLs, + ``tool_call_id``, ``reasoning`` and ``provider_data`` fields, are converted to that form first. + """ + calls = {} if calls is None else calls + role = payload.get("role") + if role not in {"system", "user", "assistant", "tool"}: + raise BubError(ErrorKind.INVALID_INPUT, f"Unknown message role: {role}") + data = dict(payload) + raw_content = data.pop("content", None) + if role == "tool" and raw_content is not None and not isinstance(raw_content, str | list): + from bub.tools import render_result + + raw_content = render_result(raw_content) + content = _content_parts(raw_content) + if role == "tool" and "tool_call" not in data: + call_id = data.pop("tool_call_id", None) + if not isinstance(call_id, str) or not call_id: + raise BubError(ErrorKind.INVALID_INPUT, "Tool result without a tool call ID.") + # Republic announces calls missing from the conversation before their results. + call = calls.get(call_id) or republic.ToolCall(call_id, data.pop("name", None) or "", "{}") + data["tool_call"] = asdict(call) + if reasoning := data.pop("reasoning", None): + content.append({"type": "reasoning", "text": reasoning}) + content.extend({"type": "provider_data", **item} for item in data.pop("provider_data", None) or []) + data["content"] = content + try: + message = republic.Message.from_dict(data) + except (KeyError, TypeError, ValueError) as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"Invalid {role} message: {exc}") from exc + if role == "system" and any(not isinstance(part, republic.Text) for part in message.parts): + raise BubError(ErrorKind.INVALID_INPUT, "System messages only support text content.") + calls.update((call.id, call) for call in message.tool_calls) + return message + + +def _content_parts(content: object) -> list[dict[str, Any]]: + if content is None: + return [] + if isinstance(content, str): + return [{"type": "text", "text": content}] + if not isinstance(content, list): + raise BubError(ErrorKind.INVALID_INPUT, "Expected text or a list of message content blocks.") + parts: list[dict[str, Any]] = [] + for block in content: + match block: + case {"type": "image" | "audio" | "video" as kind, "url": str(url), "media_type": str(mime)} if ( + url.startswith("data:") and "data" not in block + ): + # Older payloads keep inline media as data URLs. + media = {"image": republic.image, "audio": republic.audio, "video": republic.video}[kind]( + url, media_type=mime + ) + parts.append({"type": kind, "media_type": media.media_type, "data": media.base64_data}) + case {"type": "image" | "audio" | "video" as kind} if "url" not in block and "data" not in block: + raise BubError(ErrorKind.INVALID_INPUT, f"The {kind} content block needs a url or data.") + case {"type": "text" | "reasoning" | "image" | "audio" | "video" | "provider_data"}: + parts.append(block) + case _: + kind = block.get("type") if isinstance(block, dict) else type(block).__name__ + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported message content block: {kind}") + return parts @dataclass(frozen=True) @@ -297,7 +370,7 @@ async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> tracing.event(f"bub.{name}", **payload) await self.store.append(self.name, TapeEntry.event(name, payload, **(tracing.correlation() | meta))) - async def read_messages(self) -> list[dict[str, Any]]: + async def read_messages(self) -> list[republic.Message]: query = self.context.build_query(self.query()) entries = await self.store.fetch_all(query) context_entries = (entry for entry in entries if entry.meta.get("context") is not False) @@ -327,7 +400,7 @@ async def record_chat( # noqa: C901 *, run_id: str, system_prompt: str | None, - new_messages: list[dict[str, Any]], + new_messages: list[republic.Message], response_text: str | None, context_error: BubError | None = None, tool_calls: list[dict[str, Any]] | None = None, @@ -345,7 +418,7 @@ async def record_chat( # noqa: C901 if context_error is not None: await self.store.append(tape_name, TapeEntry.error(context_error, **meta)) for message in new_messages: - await self.store.append(tape_name, TapeEntry.message(message, **meta)) + await self.store.append(tape_name, TapeEntry.message(message.to_dict(), **meta)) if tool_calls: call_entry = TapeEntry.tool_call(tool_calls, content=response_text or "", **meta) call_entry.payload.update(assistant_fields or {}) diff --git a/src/bub/tracing.py b/src/bub/tracing.py index 0af2faac..d9b0fe11 100644 --- a/src/bub/tracing.py +++ b/src/bub/tracing.py @@ -6,11 +6,13 @@ import importlib import json import os -from collections.abc import Iterator, Mapping +from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager, suppress from contextvars import ContextVar from typing import TYPE_CHECKING, Any +import republic + if TYPE_CHECKING: import opentelemetry.trace as otel else: @@ -70,7 +72,8 @@ def _json(value: Any) -> str: def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]: content = message.get("content") if message.get("role") == "tool": - return [{"type": "tool_call_response", "id": message.get("tool_call_id", ""), "response": content}] + call_id = message.get("tool_call_id") or (message.get("tool_call") or {}).get("id", "") + return [{"type": "tool_call_response", "id": call_id, "response": content}] parts: list[dict[str, Any]] = [] if isinstance(content, str) and content: parts.append({"type": "text", "content": content}) @@ -152,9 +155,10 @@ def rename(self, name: str) -> None: if self.recording: self._span.update_name(name) - def messages(self, key: str, messages: list[dict[str, Any]]) -> None: + def messages(self, key: str, messages: Sequence[Mapping[str, Any] | republic.Message]) -> None: if self.recording: - normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in messages] + dicts = [m.to_dict() if isinstance(m, republic.Message) else m for m in messages] + normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in dicts] direction = "input" if key == "gen_ai.input.messages" else "output" self.set(**{ key: _json(normalized), diff --git a/src/bub/turn.py b/src/bub/turn.py index cf54d8e0..8b90ed36 100644 --- a/src/bub/turn.py +++ b/src/bub/turn.py @@ -3,10 +3,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from bub.envelope import Envelope +if TYPE_CHECKING: + from bub.prompt import UserContent + type TurnState = dict[str, Any] @@ -15,7 +18,7 @@ class TurnResult: """Result of one complete message turn.""" session_id: str - prompt: str | list[dict[str, Any]] + prompt: list[UserContent] model_output: str outbounds: list[Envelope] = field(default_factory=list) state: TurnState = field(default_factory=dict) diff --git a/tests/conftest.py b/tests/conftest.py index df583803..a92497a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from collections.abc import Callable, Generator from pathlib import Path from typing import Any @@ -11,6 +12,14 @@ from tests.model_fakes import ProviderService +@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. + for name in list(os.environ): + if name.startswith("BUB_"): + monkeypatch.delenv(name) + + @pytest.fixture(autouse=True) def reset_loaded_config() -> Generator[None, None, None]: configure._global_config.clear() diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index ce519a5c..33a8885a 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -8,6 +8,7 @@ import pluggy import pytest +import republic from republic.errors import StreamIncompleteError from bub.builtin.model_runner import ModelRunner @@ -24,6 +25,7 @@ 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 @@ -39,7 +41,7 @@ def make_hooks(*plugins: Any) -> AgentHooks: def request() -> LlmCallRequest: - return LlmCallRequest(run_id="run-1", model="openai:gpt-x", messages=[{"role": "user", "content": "hi"}]) + return LlmCallRequest(run_id="run-1", model="openai:gpt-x", messages=[republic.user("hi")]) class TestBeforeLlmCall: @@ -55,14 +57,14 @@ class AppendMessage: def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: # must see SwapModel's change (registration-order chaining) assert request.model == "anthropic:claude" - return replace(request, messages=[*request.messages, {"role": "user", "content": "extra"}]) + return replace(request, messages=[*request.messages, republic.user("extra")]) # pluggy LIFO: last registered runs first -> register AppendMessage first hooks = make_hooks(AppendMessage(), SwapModel()) result, decision = await hooks.before_llm_call(request(), state={}) assert decision is None assert result.model == "anthropic:claude" - assert result.messages[-1]["content"] == "extra" + assert result.messages[-1].text == "extra" @pytest.mark.asyncio async def test_none_and_bad_returns_leave_request_unchanged(self) -> None: @@ -292,7 +294,7 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallReques ]) ) runner, tape = self._runner_and_tape(make_hooks(Reroute()), tmp_path) - events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")) async for _ in events: pass assert provider_transport.body()["model"] == "new" @@ -315,7 +317,7 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: provider_transport.reply_chat("done") runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") - events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")) iterator = events.__aiter__() await iterator.__anext__() await iterator.aclose() @@ -334,7 +336,7 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: provider_transport.reply_chat("done") runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") - events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")) async for _ in events: pass assert len(observed) == 1 @@ -354,7 +356,7 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: provider_transport.reply(sse([{"choices": [{"delta": {"content": "partial"}}]}])) runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") runner.settings.fallback_models = ["openai:fallback"] - events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")) with pytest.raises(StreamIncompleteError) as exc: async for _ in events: pass @@ -385,13 +387,15 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") events = [ event - async for event in runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + async for event in runner.run( + tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi") + ) ] assert events[-1].data == {"ok": True, "text": "Cannot fulfill this request."} assert len(observed) == 1 assert observed[0].text == "Cannot fulfill this request." assert observed[0].error is None - assert (await tape.read_messages())[-1] == {"role": "assistant", "content": "Cannot fulfill this request."} + assert (await tape.read_messages())[-1] == republic.assistant("Cannot fulfill this request.") class TestToolCancellation: diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 195d5a89..c7d75477 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest +import republic import bub.builtin.codemode import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`) @@ -15,6 +16,7 @@ from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox from bub.errors import BubError +from bub.prompt import UserContent from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.tape import TapeContext from bub.tools import REGISTRY, tool @@ -40,8 +42,8 @@ def _make_agent() -> Agent: framework.get_system_prompt.return_value = "" framework.get_tape_sidecars.return_value = () - async def build_prompt(message: dict[str, Any], session_id: str, state: dict[str, Any]) -> str: - return str(message["content"]) + async def build_prompt(message: dict[str, Any], session_id: str, state: dict[str, Any]) -> list[UserContent]: + return [str(message["content"])] framework.build_prompt = build_prompt @@ -83,7 +85,7 @@ def __init__(self, fork_capture: _ForkCapture) -> None: self._fork = fork_capture self.name = "test-tape" self.context = TapeContext(state={}) - self.messages: list[dict[str, Any]] = [] + self.messages: list[republic.Message] = [] self.events: list[tuple[str, str, dict[str, Any]]] = [] async def ensure_bootstrap_anchor(self) -> None: @@ -94,7 +96,7 @@ async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[_FakeTape, async with self._fork.fork_tape(self.name, merge_back=merge_back): yield self - async def read_messages(self) -> list[dict[str, Any]]: + async def read_messages(self) -> list[republic.Message]: return list(self.messages) async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None: @@ -105,7 +107,7 @@ async def record_chat( *, run_id: str, system_prompt: str | None, - new_messages: list[dict[str, Any]], + new_messages: list[republic.Message], response_text: str | None, context_error: BubError | None = None, tool_calls: list[dict[str, Any]] | None = None, @@ -128,7 +130,7 @@ async def record_chat( if error is not None and error is not context_error: self.events.append((self.name, "error", error.as_dict())) if response_text is not None: - self.messages.append({"role": "assistant", "content": response_text}) + self.messages.append(republic.assistant(response_text)) self.events.append((self.name, "run", {"run_id": run_id, "model": model, "error": error is not None})) @@ -242,8 +244,8 @@ async def test_agent_run_model_defaults_to_none( async def test_agent_loop_awaits_continue_prompt_hook_with_stream_state(continuation: str | None) -> None: agent = _make_agent() tape = _FakeTape(_ForkCapture()) - prompts: list[str | list[dict] | None] = [] - continuation_prompts: list[str | list[dict] | None] = [] + prompts: list[list[UserContent] | None] = [] + continuation_prompts: list[list[UserContent] | None] = [] observed_usage: list[dict[str, Any] | None] = [] async def run_once(**kwargs: Any) -> AsyncStreamEvents: @@ -255,7 +257,7 @@ async def iterator() -> AsyncIterator[StreamEvent]: return AsyncStreamEvents(iterator(), state=StreamState(usage={"step": len(prompts)})) - async def continue_prompt(*, prompt: str | list[dict] | None, tape: _FakeTape, state: StreamState) -> str | None: + async def continue_prompt(*, prompt: list[UserContent] | None, tape: _FakeTape, state: StreamState) -> str | None: continuation_prompts.append(prompt) observed_usage.append(state.usage) return continuation @@ -267,14 +269,14 @@ async def continue_prompt(*, prompt: str | list[dict] | None, tape: _FakeTape, s event async for event in agent._stream_events_with_auto_handoff( tape=tape, # type: ignore[arg-type] - prompt="initial prompt", + prompt=["initial prompt"], state=StreamState(), ) ] assert [event.kind for event in events] == ["final", "final"] - assert prompts == ["initial prompt", continuation] - assert continuation_prompts == ["initial prompt"] + assert prompts == [["initial prompt"], None if continuation is None else [continuation]] + assert continuation_prompts == [["initial prompt"]] assert observed_usage == [{"step": 1}] @@ -340,10 +342,10 @@ async def test_agent_run_injects_steering_messages_once_by_session( {"role": "user", "content": "hello"}, ] assert fake_tapes.tape.messages == [ - {"role": "user", "content": "first steer"}, - {"role": "user", "content": "second steer"}, - {"role": "user", "content": "hello"}, - {"role": "assistant", "content": "done"}, + republic.user("first steer"), + republic.user("second steer"), + republic.user("hello"), + republic.assistant("done"), ] result = await agent.run_stream(session_id="user/s1", prompt="again", state={"_runtime_workspace": "/tmp"}) # noqa: S108 diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 6886cdfa..95ef56e7 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -14,6 +14,7 @@ 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 @@ -61,7 +62,9 @@ async def test_codex_sends_content_with_file_credentials( ) events = [ event - async for event in runner.run(tape=tape, model="codex:test", tools=[], system_prompt=None, prompt=prompt) + async for event in runner.run( + tape=tape, model="codex:test", tools=[], system_prompt=None, prompt=to_content(prompt) + ) ] request = provider_service.requests[0] body = provider_service.body() @@ -118,7 +121,9 @@ async def test_codex_replays_encrypted_reasoning_and_tool_results_after_reload( tools = [Tool(name="echo", handler=lambda: "echoed")] _ = [ event - async for event in runner.run(tape=tape, model="codex:test", tools=tools, system_prompt=None, prompt="echo") + async for event in runner.run( + tape=tape, model="codex:test", tools=tools, system_prompt=None, prompt=to_content("echo") + ) ] reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( "codex" @@ -163,7 +168,9 @@ async def test_named_endpoint_falls_back_to_codex_with_its_own_credentials( tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") events = [ event - async for event in runner.run(tape=tape, model="relay:sample", tools=[], system_prompt=None, prompt="hello") + async for event in runner.run( + tape=tape, model="relay:sample", tools=[], system_prompt=None, prompt=to_content("hello") + ) ] assert [str(request.url) for request in provider_service.requests] == [ "https://relay.test/v1/responses", diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index a2cc26f6..ba85e663 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -207,9 +207,9 @@ async def test_build_prompt_marks_commands_and_prefixes_context(tmp_path: Path) command_prompt = await impl.build_prompt(command, session_id="s", state={}) normal_prompt = await impl.build_prompt(normal, session_id="s", state={}) - assert command_prompt == ",help" + assert command_prompt == [",help"] assert command.kind == "command" - prompt_lines = normal_prompt.splitlines() + prompt_lines = normal_prompt[0].splitlines() assert prompt_lines[0] == normal.context_str assert prompt_lines[2] == "hello" @@ -230,7 +230,7 @@ async def test_build_prompt_uses_system_timezone_for_context_date( prompt = await impl.build_prompt(message, session_id="s", state={}) - date_line = prompt.splitlines()[1] + date_line = prompt[0].splitlines()[1] assert date_line.startswith("---Date: ") assert date_line.endswith("+08:00---") finally: diff --git a/tests/test_builtin_model_runner.py b/tests/test_builtin_model_runner.py index 77fedafd..85a15bfd 100644 --- a/tests/test_builtin_model_runner.py +++ b/tests/test_builtin_model_runner.py @@ -13,8 +13,9 @@ from bub.builtin.model_runner import ModelRunner 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 +from bub.tape import Tape, TapeContext, TapeEntry from bub.tools import Tool from tests.model_fakes import ProviderService, chat_events, sse, tool_events @@ -68,7 +69,7 @@ async def test_unknown_tool_returns_an_error_result(tmp_path: Path, provider_ser events = [ event async for event in runner.run( - tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="Run the tool." + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("Run the tool.") ) ] result = next(event.data["tool_results"][0] for event in events if event.kind == "tool_result") @@ -104,14 +105,22 @@ async def test_tool_turn_replays_after_tape_reload( events = [ event async for event in runner.run( - tape=tape, model="openai:test", tools=tools, system_prompt=None, prompt="Compare the outputs." + tape=tape, + model="openai:test", + tools=tools, + system_prompt=None, + prompt=to_content("Compare the outputs."), ) ] reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") follow_up = [ event async for event in runner.run( - tape=reopened, model="openai:test", tools=tools, system_prompt=None, prompt=continuation_prompt + tape=reopened, + model="openai:test", + tools=tools, + system_prompt=None, + prompt=None if continuation_prompt is None else to_content(continuation_prompt), ) ] assert next(event.data["tool_results"] for event in events if event.kind == "tool_result") == [ @@ -149,12 +158,38 @@ async def test_continuation_sends_steering_without_a_prompt(tmp_path: Path, prov tools=[], system_prompt=None, prompt=None, - steering_messages=["new user direction"], + steering_messages=[["new user direction"]], ) ] assert provider_service.body()["messages"] == [{"role": "user", "content": "new user direction"}] assert events[-1].data == {"ok": True, "text": "done"} - assert {"role": "user", "content": "new user direction"} in await tape.read_messages() + assert republic.user("new user direction") in await tape.read_messages() + + +@pytest.mark.asyncio +async def test_tool_result_without_its_call_in_context_is_still_sent( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("orphan") + await tape.store.append( + tape.name, + TapeEntry.message({"role": "tool", "tool_call_id": "call-1", "name": "inspect", "content": "Ready"}), + ) + events = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt="Be brief.", prompt=to_content("Continue.") + ) + ] + messages = provider_service.body()["messages"] + assert messages[0] == {"role": "system", "content": "Be brief."} + assert messages[1]["tool_calls"][0]["id"] == "call-1" + assert messages[1]["tool_calls"][0]["function"]["name"] == "inspect" + assert messages[2] == {"role": "tool", "tool_call_id": "call-1", "content": "Ready"} + assert events[-1].data == {"ok": True, "text": "done"} @pytest.mark.asyncio @@ -177,7 +212,7 @@ async def test_invalid_tool_arguments_do_not_execute_the_handler( model="openai:test", tools=[Tool(name="inspect", handler=lambda: invoked.append(True))], system_prompt=None, - prompt="Inspect.", + prompt=to_content("Inspect."), ) ] assert exc.value.kind == ErrorKind.INVALID_INPUT @@ -195,7 +230,9 @@ async def test_streaming_reports_usage_and_records_it_in_tape( tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("usage") events = [ event - async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) ] assert provider_service.body()["stream_options"] == {"include_usage": True} usage = next(event.data for event in events if event.kind == "usage") @@ -226,7 +263,7 @@ async def test_anthropic_request_enables_caching_and_generation_options( events = [ event async for event in runner.run( - tape=tape, model="anthropic:test", tools=[], system_prompt=None, prompt="hello" + tape=tape, model="anthropic:test", tools=[], system_prompt=None, prompt=to_content("hello") ) ] assert provider_service.body()["cache_control"] == {"type": "ephemeral"} @@ -263,7 +300,9 @@ async def test_session_reasoning_and_runtime_options_override_completion_default ).scoped("reasoning") _ = [ event - async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) ] request = provider_service.body() assert request["reasoning_effort"] == "high" @@ -295,7 +334,9 @@ async def test_wire_reasoning_follows_republic_extra_body_precedence( ).scoped("wire-reasoning") _ = [ event - async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) ] assert provider_service.body()["reasoning_effort"] == (request_reasoning or "low") @@ -329,7 +370,7 @@ async def test_truncated_stream_never_executes_tools(tmp_path: Path, provider_se model="openai:test", tools=[Tool(name="echo", handler=lambda: invoked.append(True))], system_prompt=None, - prompt="hello", + prompt=to_content("hello"), ) ] assert not invoked @@ -338,7 +379,7 @@ async def test_truncated_stream_never_executes_tools(tmp_path: Path, provider_se @pytest.mark.asyncio @pytest.mark.parametrize("next_model", ["google:model-one", "google:model-two", "openai:model-one"]) -async def test_provider_state_is_replayed_only_to_its_source_after_reload( +async def test_provider_state_is_replayed_only_to_its_api_format_after_reload( tmp_path: Path, provider_service: ProviderService, next_model: str ) -> None: opaque = {"executableCode": {"language": "PYTHON", "code": "1 + 1"}} @@ -377,7 +418,7 @@ async def test_provider_state_is_replayed_only_to_its_source_after_reload( _ = [ event async for event in runner.run( - tape=tape, model="google:model-one", tools=tools, system_prompt=None, prompt="lookup" + tape=tape, model="google:model-one", tools=tools, system_prompt=None, prompt=to_content("lookup") ) ] reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( @@ -388,13 +429,13 @@ async def test_provider_state_is_replayed_only_to_its_source_after_reload( events = [ event async for event in runner.run( - tape=reopened, model=next_model, tools=tools, system_prompt=None, prompt="Continue." + tape=reopened, model=next_model, tools=tools, system_prompt=None, prompt=to_content("Continue.") ) ] request = json.dumps(provider_service.body()) assert "lookup result" in request - assert ("signature" in request) is (next_model == "google:model-one") - assert ("executableCode" in request) is (next_model == "google:model-one") + assert ("signature" in request) is next_model.startswith("google:") + assert ("executableCode" in request) is next_model.startswith("google:") assert events[-1].data == {"ok": True, "text": "done"} @@ -418,7 +459,7 @@ async def test_named_tool_choice_is_sent_to_anthropic(tmp_path: Path, provider_s model="anthropic:test", tools=[Tool(name="echo", handler=lambda: "done")], system_prompt=None, - prompt="hello", + prompt=to_content("hello"), ) ] assert provider_service.body()["tool_choice"] == {"type": "tool", "name": "echo"} diff --git a/tests/test_framework.py b/tests/test_framework.py index dc35f96b..51243f2e 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest +import republic import typer from typer.testing import CliRunner @@ -145,6 +146,25 @@ def provide_tape_sidecar(self) -> Sidecar: assert cast(Any, sidecars["shared"]).source == "high" +@pytest.mark.asyncio +async def test_build_prompt_converts_legacy_content_blocks_by_value() -> None: + framework = BubFramework() + + class LegacyPlugin: + @hookimpl + def build_prompt(self, message, session_id, state): + return [ + {"type": "text", "text": "describe"}, + {"type": "image", "media_type": "image/png", "url": "https://example.test/a.png"}, + ] + + framework.plugin_manager.register(LegacyPlugin(), name="legacy") + + prompt = await framework.build_prompt({"content": "ignored"}, session_id="s", state={}) + + assert prompt == ["describe", republic.Image("image/png", url="https://example.test/a.png")] + + @pytest.mark.asyncio async def test_continue_prompt_awaits_high_priority_async_hook() -> None: framework = BubFramework() @@ -162,6 +182,7 @@ class AsyncPlugin: @hookimpl async def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> str: called.append("async") + # Hooks receive the legacy prompt form. assert prompt == "current prompt" assert state.usage == {"total_tokens": 42} return "async prompt" @@ -169,7 +190,7 @@ async def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> s framework.plugin_manager.register(SyncPlugin(), name="sync") framework.plugin_manager.register(AsyncPlugin(), name="async") - prompt = await framework.continue_prompt(prompt="current prompt", tape=tape, state=state) + prompt = await framework.continue_prompt(prompt=["current prompt"], tape=tape, state=state) assert prompt == "async prompt" assert called == ["async"] @@ -191,7 +212,7 @@ def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> None: framework.plugin_manager.register(NoPromptPlugin()) result = await framework.continue_prompt( - prompt="initial prompt", tape=cast(Any, SimpleNamespace()), state=StreamState() + prompt=["initial prompt"], tape=cast(Any, SimpleNamespace()), state=StreamState() ) assert result is None diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py index a336d679..3605d401 100644 --- a/tests/test_hook_runtime.py +++ b/tests/test_hook_runtime.py @@ -1,5 +1,6 @@ import pluggy import pytest +import republic from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.runtime import HookRuntime @@ -139,3 +140,27 @@ async def run_model(self, prompt, session_id, state): assert stream is not None events = [event async for event in stream] assert [(event.kind, event.data) for event in events] == [("text", {"delta": "plain"})] + + +@pytest.mark.asyncio +async def test_model_hooks_receive_the_legacy_prompt_form() -> None: + received: list[object] = [] + + class PlainPlugin: + @hookimpl + async def run_model(self, prompt, session_id, state): + received.append(prompt) + return "plain" + + runtime = _runtime_with_plugins(("plain", PlainPlugin())) + + await runtime.run_model(prompt=["hello", "world"], session_id="s", state={}) + await runtime.run_model(prompt=["look", republic.Image("image/png", data=b"png")], session_id="s", state={}) + + assert received == [ + "hello\nworld", + [ + {"type": "text", "text": "look"}, + {"type": "image", "media_type": "image/png", "url": "data:image/png;base64,cG5n"}, + ], + ] diff --git a/tests/test_image_message.py b/tests/test_image_message.py index 0444c5e2..486db122 100644 --- a/tests/test_image_message.py +++ b/tests/test_image_message.py @@ -2,16 +2,17 @@ from __future__ import annotations -import base64 from pathlib import Path from types import SimpleNamespace import pytest +import republic from bub.builtin.hook_impl import BuiltinImpl from bub.channels.message import ChannelMessage, MediaItem from bub.channels.telegram import TelegramChannel, TelegramMessageParser, _extract_media_items from bub.framework import BubFramework +from bub.prompt import prompt_text, to_content, to_legacy_prompt # --------------------------------------------------------------------------- # MediaItem & ChannelMessage @@ -298,18 +299,19 @@ def _build_impl(tmp_path: Path) -> tuple[BubFramework, BuiltinImpl]: @pytest.mark.asyncio -async def test_build_prompt_returns_string_without_media(tmp_path: Path) -> None: +async def test_build_prompt_returns_text_without_media(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage(session_id="s", channel="tg", content="hello") result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, str) - assert "hello" in result + assert len(result) == 1 + assert isinstance(result[0], str) + assert "hello" in result[0] @pytest.mark.asyncio -async def test_build_prompt_returns_multimodal_parts_with_image_media(tmp_path: Path) -> None: +async def test_build_prompt_returns_text_and_inline_image(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -320,17 +322,10 @@ async def test_build_prompt_returns_multimodal_parts_with_image_media(tmp_path: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) assert len(result) == 2 - - text_part = result[0] - assert text_part["type"] == "text" - assert "describe this" in text_part["text"] - - image_part = result[1] - assert image_part["type"] == "image" - expected = base64.b64encode(b"\xff\xd8").decode("utf-8") - assert image_part["url"] == f"data:image/jpeg;base64,{expected}" + assert isinstance(result[0], str) + assert "describe this" in result[0] + assert result[1] == republic.Image("image/jpeg", data=b"\xff\xd8") @pytest.mark.asyncio @@ -348,14 +343,11 @@ async def test_build_prompt_with_multiple_images(tmp_path: Path) -> None: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) - assert len(result) == 3 - assert result[1]["type"] == "image" - assert result[2]["type"] == "image" + assert result[1:] == [republic.Image("image/jpeg", data=b"A"), republic.Image("image/jpeg", data=b"B")] @pytest.mark.asyncio -async def test_build_prompt_returns_video_part_with_media(tmp_path: Path) -> None: +async def test_build_prompt_returns_inline_video(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -366,16 +358,9 @@ async def test_build_prompt_returns_video_part_with_media(tmp_path: Path) -> Non result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) assert len(result) == 2 - assert result[0]["type"] == "text" - assert "describe this video" in result[0]["text"] - expected = base64.b64encode(b"video").decode("utf-8") - assert result[1] == { - "type": "video", - "media_type": "video/mp4", - "url": f"data:video/mp4;base64,{expected}", - } + assert "describe this video" in result[0] + assert result[1] == republic.Video("video/mp4", data=b"video") @pytest.mark.asyncio @@ -390,8 +375,8 @@ async def test_build_prompt_skips_video_when_download_is_too_large(tmp_path: Pat result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, str) - assert "describe this video" in result + assert len(result) == 1 + assert "describe this video" in result[0] @pytest.mark.asyncio @@ -399,7 +384,7 @@ async def test_build_prompt_skips_video_when_download_is_too_large(tmp_path: Pat "mime_type", ["audio/mpeg", "audio/ogg", "audio/x-wav"], ) -async def test_build_prompt_returns_audio_part(tmp_path: Path, mime_type: str) -> None: +async def test_build_prompt_returns_inline_audio(tmp_path: Path, mime_type: str) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -410,14 +395,8 @@ async def test_build_prompt_returns_audio_part(tmp_path: Path, mime_type: str) - result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) - assert result[0]["type"] == "text" - assert "listen to this" in result[0]["text"] - assert result[1] == { - "type": "audio", - "media_type": mime_type, - "url": f"data:{mime_type};base64,{base64.b64encode(b'audio').decode('utf-8')}", - } + assert "listen to this" in result[0] + assert result[1] == republic.Audio(mime_type, data=b"audio") @pytest.mark.asyncio @@ -432,8 +411,7 @@ async def test_build_prompt_preserves_remote_audio_url(tmp_path: Path) -> None: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) - assert result[1] == {"type": "audio", "media_type": "audio/ogg", "url": "https://example.com/audio.ogg"} + assert result[1] == republic.Audio("audio/ogg", url="https://example.com/audio.ogg") @pytest.mark.asyncio @@ -448,35 +426,32 @@ async def test_build_prompt_command_ignores_media(tmp_path: Path) -> None: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, str) - assert result == ",help" + assert result == [",help"] assert message.kind == "command" # --------------------------------------------------------------------------- -# _extract_text_from_parts +# Prompt content compatibility # --------------------------------------------------------------------------- -def test_extract_text_from_parts() -> None: - from bub.builtin.agent import _extract_text_from_parts - - parts = [ +def test_legacy_prompt_blocks_convert_to_user_content() -> None: + content = to_content([ {"type": "text", "text": "hello"}, - {"type": "image", "media_type": "image/jpeg", "url": "data:image/jpeg;base64,X"}, + {"type": "image", "media_type": "image/jpeg", "url": "data:image/jpeg;base64,WA=="}, {"type": "text", "text": "world"}, - ] - assert _extract_text_from_parts(parts) == "hello\nworld" - + ]) -def test_extract_text_from_parts_empty() -> None: - from bub.builtin.agent import _extract_text_from_parts + assert content == ["hello", republic.Image("image/jpeg", data=b"X"), "world"] + assert prompt_text(content) == "hello\nworld" + assert to_content("hello") == ["hello"] + assert to_content(content) == content - assert _extract_text_from_parts([]) == "" - -def test_extract_text_from_parts_no_text_parts() -> None: - from bub.builtin.agent import _extract_text_from_parts - - parts = [{"type": "image", "media_type": "image/jpeg", "url": "data:image/jpeg;base64,X"}] - assert _extract_text_from_parts(parts) == "" +def test_user_content_converts_back_to_legacy_prompt() -> None: + assert to_legacy_prompt(["hello", "world"]) == "hello\nworld" + assert to_legacy_prompt([]) == "" + assert to_legacy_prompt(["look", republic.Image("image/png", url="https://example.test/a.png")]) == [ + {"type": "text", "text": "look"}, + {"type": "image", "media_type": "image/png", "url": "https://example.test/a.png"}, + ] diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 22bfe09e..712bbd89 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -9,7 +9,7 @@ import pytest import republic import typer -from republic.errors import AuthenticationError, UnsupportedFeatureError +from republic.errors import APITimeoutError, AuthenticationError, UnsupportedFeatureError from typer.testing import CliRunner from bub import configure, inquirer @@ -71,7 +71,7 @@ def test_all_providers_check_connection_before_selecting_models(prompts, provide names = [name for name, _ in calls] assert names[0] == "LLM provider" assert names.index("API key (optional)") < names.index("discover") < names.index("LLM model (type to search)") - if provider in {"openai-compatible", "azure-openai", "ollama"}: + if provider in {"openai-compatible", "azure-openai", "ollama", "magpie"}: assert names.index("API base URL") < names.index("API key (optional)") else: assert "API base URL" not in names @@ -547,27 +547,28 @@ def create_client(**kwargs): assert clients[0].is_closed -def test_discovery_timeout_cancels_request_and_closes_client(monkeypatch): +def test_discovery_timeout_is_passed_to_the_client_and_closes_it(monkeypatch): clients = [] - cancelled = [] + timeouts = [] constructor = httpx.AsyncClient - async def respond(request): - try: - await asyncio.Event().wait() - finally: - cancelled.append(True) + def respond(request): + timeouts.append(request.extensions["timeout"]) + raise httpx.ReadTimeout("timed out", request=request) def create_client(**kwargs): client = constructor(**kwargs, transport=httpx.MockTransport(respond)) clients.append(client) return client - monkeypatch.setattr(onboarding, "CONNECTION_TIMEOUT", 0.01) monkeypatch.setattr(httpx, "AsyncClient", create_client) - with pytest.raises(TimeoutError): - onboarding.discover_models("openai", api_base="https://example.test/v1", api_key="test-key") - assert cancelled == [True] + with pytest.raises(APITimeoutError) as exc: + # A configured client timeout does not apply to the connection check. + onboarding.discover_models( + "openai", api_base="https://example.test/v1", api_key="test-key", timeout=600, max_retries=0 + ) + assert timeouts[0]["read"] == onboarding.CONNECTION_TIMEOUT + assert "timed out" in onboarding._connection_error(exc.value) assert clients[0].is_closed diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index 9c006a0b..966ccc12 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -15,6 +15,7 @@ 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 @@ -57,7 +58,9 @@ async def video(): await tape.ensure_bootstrap_anchor() events = [ event - async for event in runner.run(tape=tape, model="google:test", tools=[], system_prompt=None, prompt=prompt) + async for event in runner.run( + tape=tape, model="google:test", tools=[], system_prompt=None, prompt=to_content(prompt) + ) ] reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( "media" @@ -65,7 +68,7 @@ async def video(): follow_up = [ event async for event in runner.run( - tape=reopened, model="google:test", tools=[], system_prompt=None, prompt="Continue." + tape=reopened, model="google:test", tools=[], system_prompt=None, prompt=to_content("Continue.") ) ] expected = [ @@ -103,7 +106,7 @@ async def aclose(self): client_args["http_client"] = client runner = ModelRunner(AgentSettings(client_args=client_args)) tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cancel") - events = runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + events = runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello")) assert (await anext(events)).data == {"delta": "first"} await events.aclose() assert closed.is_set() @@ -125,7 +128,9 @@ async def test_primary_success_does_not_require_an_available_fallback( tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") events = [ event - async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) ] assert len(provider_service.requests) == 1 assert events[-1].data == {"ok": True, "text": "done"} @@ -167,7 +172,7 @@ async def test_fresh_process_replays_tool_signatures_to_the_provider( model="google:test", tools=[Tool(name="lookup", handler=lambda: "lookup result")], system_prompt=None, - prompt="Look it up.", + prompt=to_content("Look it up."), ) ] code = """ @@ -189,7 +194,7 @@ def respond(request): directory = Path(sys.argv[1]) tape = Tape(directory, AsyncTapeStoreAdapter(FileTapeStore(directory)), default_tape_context()).scoped("persisted") runner = ModelRunner(AgentSettings(client_args={"http_client": client})) - events = [event async for event in runner.run(tape=tape, model="google:test", tools=[], system_prompt=None, prompt="Continue.")] + events = [event async for event in runner.run(tape=tape, model="google:test", tools=[], system_prompt=None, prompt=["Continue."])] print(json.dumps({"request": bodies[0], "result": events[-1].data})) asyncio.run(main()) """ @@ -216,14 +221,14 @@ async def test_media_urls_preserve_explicit_mime_types(tmp_path: Path, provider_ model="google:test", tools=[], system_prompt=None, - prompt=[ + prompt=to_content([ { "type": "image", "media_type": "image/png", "url": "https://example.test/photo.png?signature=opaque", }, {"type": "image", "media_type": "image/webp", "url": "https://example.test/attachment"}, - ], + ]), ) ] assert provider_service.body()["contents"][0]["parts"] == [ @@ -252,7 +257,7 @@ async def test_invalid_content_fails_without_a_provider_request( _ = [ event async for event in runner.run( - tape=tape, model="google:test", tools=[], system_prompt=None, prompt=[block] + tape=tape, model="google:test", tools=[], system_prompt=None, prompt=to_content([block]) ) ] assert exc.value.kind == ErrorKind.INVALID_INPUT diff --git a/tests/test_settings.py b/tests/test_settings.py index e7561f09..27bce758 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -12,6 +12,7 @@ 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, chat_events, sse @@ -190,7 +191,7 @@ async def _run(settings: AgentSettings, tmp_path: Path) -> str: events = [ event async for event in ModelRunner(settings).run( - tape=tape, model=settings.model, tools=[], system_prompt=None, prompt="Hello" + tape=tape, model=settings.model, tools=[], system_prompt=None, prompt=to_content("Hello") ) ] assert events[-1].data["ok"] diff --git a/tests/test_spill.py b/tests/test_spill.py index 7faaef3e..6cf9696a 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -6,6 +6,7 @@ import pluggy import pytest +import republic from bub.builtin.context import default_tape_context from bub.builtin.hook_impl import BuiltinImpl @@ -125,7 +126,7 @@ async def test_oversized_result_is_bounded_and_readable_across_merge(tmp_path: P tool_results=execution.tool_results, ) request_messages = await tape.read_messages() - request_body = json.dumps(request_messages, ensure_ascii=False) + request_body = json.dumps([message.to_dict() for message in request_messages], ensure_ascii=False) assert handle in request_body assert output not in request_body @@ -331,7 +332,7 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp await tape.record_chat( run_id="run-1", system_prompt=None, - new_messages=[{"role": "user", "content": "archive this"}], + new_messages=[republic.user("archive this")], response_text=None, tool_calls=[{"id": "call-1", "name": "large", "arguments": "{}"}], tool_results=execution.tool_results, diff --git a/tests/test_tape.py b/tests/test_tape.py index 8d0dfa15..14174cf2 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -3,10 +3,11 @@ from pathlib import Path import pytest +import republic from bub.builtin.context import default_tape_context from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry +from bub.tape import Tape, TapeContext, TapeEntry, to_messages def test_tape_reexports_legacy_store_objects() -> None: @@ -37,9 +38,10 @@ async def test_legacy_tool_call_without_content_replays_with_its_result(tmp_path store.append("test-tape", TapeEntry(id=0, kind="tool_call", payload={"calls": calls})) store.append("test-tape", TapeEntry.tool_result(["files found"])) + call = republic.ToolCall("call-1", "inspect", "{}") assert await tape.read_messages() == [ - {"role": "assistant", "content": "", "tool_calls": calls}, - {"role": "tool", "content": "files found", "tool_call_id": "call-1", "name": "inspect"}, + republic.assistant("", tool_calls=[call]), + republic.tool(call, "files found"), ] @@ -50,7 +52,7 @@ async def test_text_only_response_remains_a_standalone_assistant_message(tmp_pat await tape.ensure_bootstrap_anchor() await tape.record_chat(run_id="run-1", system_prompt=None, new_messages=[], response_text=content) - assert await tape.read_messages() == [{"role": "assistant", "content": content}] + assert await tape.read_messages() == [republic.assistant(content)] @pytest.mark.asyncio @@ -134,3 +136,45 @@ def select_events(entries, _context): await tape.append_event("hidden", {}, context=False) assert await tape.read_messages() == [{"role": "assistant", "content": "visible"}] + + +@pytest.mark.asyncio +async def test_new_messages_round_trip_through_the_tape(tmp_path: Path) -> None: + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape") + await tape.ensure_bootstrap_anchor() + prompt = republic.user("Describe this.", republic.image(b"png-bytes", media_type="image/png")) + await tape.record_chat(run_id="run-1", system_prompt=None, new_messages=[prompt], response_text="A picture.") + + assert await tape.read_messages() == [prompt, republic.assistant("A picture.")] + + +def test_legacy_payloads_convert_to_republic_messages() -> None: + call = republic.ToolCall("call-1", "inspect", "{}") + payloads = [ + { + "role": "user", + "content": [{"type": "image", "url": "data:image/png;base64,cG5n", "media_type": "image/png"}], + }, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-1", "name": "inspect", "arguments": "{}"}], + "reasoning": "Look first.", + "provider_data": [{"api_format": "responses", "payload": {"type": "reasoning"}}], + }, + {"role": "tool", "tool_call_id": "call-1", "content": {"files": 2}}, + ] + + assert to_messages(payloads) == [ + republic.user(republic.Image("image/png", data=b"png")), + republic.Message( + "assistant", + ( + republic.Text(""), + republic.Reasoning("Look first."), + republic.ProviderData("responses", {"type": "reasoning"}), + ), + tool_calls=(call,), + ), + republic.tool(call, '{"files": 2}'), + ] diff --git a/website/src/content/docs/docs/concepts/tape-and-context.mdx b/website/src/content/docs/docs/concepts/tape-and-context.mdx index 897a580f..5e1638d3 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -66,14 +66,16 @@ Before the first turn on a tape, `TapeService.ensure_bootstrap_anchor` checks fo ### default_tape_context: entries → model messages -`default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and emits Bub message dictionaries for conversion to Republic messages. The mapping is: +`default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and returns a list of `republic.Message` objects. The mapping is: - `anchor` → an `assistant` message of the form `[Anchor created: ]: `. -- `message` → the entry payload, used as a chat message dict. +- `message` → the entry payload, converted with `bub.tape.to_message`. New user messages are stored as `republic.Message.to_dict()`; older Bub payloads (media URLs, `tool_call_id`, `reasoning`, `provider_data`) are still read. - `tool_call` → one `assistant` message with the saved text (or empty content for calls without text, including older entries) and a `tool_calls` array. -- `tool_result` → one `tool` role message per result; when a preceding `tool_call` is pending, Bub copies the call's `id` into `tool_call_id` by result position. +- `tool_result` → one `republic.tool(...)` message per result, paired with the preceding `tool_call` entry's calls by position. Results without a recorded call are skipped. -The context selector is a hook (`build_tape_context`), so plugins can replace it with a different strategy — compaction, summarization, retrieval — without touching the rest of the pipeline. +Saved reasoning and provider data are always included; Republic sends them back only to API formats that accept them. + +The context selector is a hook (`build_tape_context`), so plugins can replace it with a different strategy — compaction, summarization, retrieval — without touching the rest of the pipeline. A custom `select` must return Republic messages (or an awaitable of them); `bub.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/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx index a44ab197..abd744f0 100644 --- a/website/src/content/docs/docs/reference/hooks.mdx +++ b/website/src/content/docs/docs/reference/hooks.mdx @@ -17,7 +17,7 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi | --- | --- | --- | --- | --- | --- | | `resolve_session` | firstresult | `(message: Envelope) -> str` | session id | `BubFramework.process_inbound` | Falls back to `"{channel}:{chat_id}"` when no impl returns a value. | | `load_state` | broadcast | `(message: Envelope, session_id: str) -> TurnState` | dict to merge | `BubFramework.process_inbound` | Framework reverses results, then merges in order so high-priority keys overwrite low-priority. | -| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> str \| list[dict]` | prompt or content parts | `BubFramework.process_inbound` | Falls back to `content_of(message)` when no impl returns a non-`None` value, or when the selected firstresult is falsy. A falsy selected value does not cause lower-priority impls to run. | +| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> list[UserContent]` | prompt content | `BubFramework.process_inbound` | Falls back to `content_of(message)` when no impl returns a non-`None` value, or when the selected firstresult is falsy. A falsy selected value does not cause lower-priority impls to run. | | `run_model` | firstresult | `(prompt, session_id, state) -> str` | model text | `HookRuntime.run_model` | Legacy text path. Implement either `run_model` or `run_model_stream`, not both. | | `run_model_stream` | firstresult | `(prompt, session_id, state) -> AsyncStreamEvents` | async stream | `HookRuntime.run_model_stream` | Preferred. Falls back to wrapping a `run_model` result in a one-chunk stream when no streaming impl exists. | | `continue_prompt` | firstresult | `(prompt: str \| list[dict] \| None, tape: Tape, state: StreamState) -> str \| None` | optional next-step user message | `Agent._stream_events_with_auto_handoff` | Runs before each continued agent-loop step. `prompt` is the prompt used by the completed step, or `None` if none was added. Implementations may be sync or async. If no hook returns a string, the next model call uses the existing conversation without adding a user message. | @@ -41,6 +41,12 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi | `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` via `AgentHooks` | Per tool invocation. `proceed(arguments=…)` folds argument changes; `replace(result)` / `deny(message)` short-circuit. Veto only via the decision object — exceptions are logged and skipped. | | `after_tool_call` | terminal handler | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | none | `ToolExecutor` via `AgentHooks` | Fires for success, failure and deny/replace. Cancellation is not observed. Assigning `result.result` replaces the model-facing value; `result.error` remains the original `BubError` for failures. | +### Prompt content + +`build_prompt` returns `list[UserContent]` from `bub.prompt`, where `UserContent = str | republic.Image | republic.Audio | republic.Video`. Build media with `republic.image(...)`, `republic.audio(...)` or `republic.video(...)`. For compatibility, it may still return the legacy forms: a string, or a list of content-block dicts such as `{"type": "image", "media_type": "image/png", "url": "..."}`. The framework converts them by inspecting the returned value. + +Hooks that receive a prompt (`run_model`, `run_model_stream`, `continue_prompt`, `system_prompt`) still get the legacy form: a string when the prompt has no media, otherwise content blocks whose media use URLs (inline media become `data:` URLs). `bub.prompt.to_content(prompt)` converts it to user content. + ## How hooks are invoked `HookRuntime` (in `src/bub/hooks/runtime.py`) wraps the `pluggy.PluginManager` with the following semantics. diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index 6fb886d5..11fca1b4 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -31,7 +31,7 @@ type TurnState = dict[str, Any] @dataclass(frozen=True) class TurnResult: session_id: str - prompt: str | list[dict[str, Any]] + prompt: list[UserContent] model_output: str outbounds: list[Envelope] = field(default_factory=list) state: TurnState = field(default_factory=dict) 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 c61e8048..be6c9b64 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 @@ -66,14 +66,16 @@ spill 写入以 `spill.write` event 记录。框架管理的生命周期结果 ### default_tape_context:entry → 模型消息 -`default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并产出 Bub 消息字典,再转换为 Republic 消息。映射规则如下: +`default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并返回 `republic.Message` 列表。映射规则如下: - `anchor` → 形如 `[Anchor created: ]: ` 的 `assistant` 消息。 -- `message` → 直接以 entry payload 作为 chat 消息 dict。 +- `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 一条 `tool` role 消息;若前面有 pending `tool_call`,Bub 会按 result 位置复制对应 call 的 `id` 到 `tool_call_id`。 +- `tool_result` → 每个 result 一条 `republic.tool(...)` 消息,按位置与前一个 `tool_call` entry 中的 call 配对;没有对应 call 的 result 会被跳过。 -context selector 本身是个 hook(`build_tape_context`),插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 +已保存的 reasoning 与 provider data 总是会带上;Republic 只会把它们发回接受这些数据的 API format。 + +context selector 本身是个 hook(`build_tape_context`)。自定义 `select` 必须返回 Republic 消息(或返回它们的 awaitable);`bub.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/hooks.mdx b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx index 5406fe2a..8f64f12f 100644 --- a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx @@ -17,7 +17,7 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用 | --- | --- | --- | --- | --- | --- | | `resolve_session` | firstresult | `(message: Envelope) -> str` | session id | `BubFramework.process_inbound` | 没有任何实现返回值时,框架回退为 `"{channel}:{chat_id}"`。 | | `load_state` | broadcast | `(message: Envelope, session_id: str) -> TurnState` | dict to merge | `BubFramework.process_inbound` | 框架先反转结果列表再依次合并,使高优先级的键覆盖低优先级。 | -| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> str \| list[dict]` | prompt or content parts | `BubFramework.process_inbound` | 没有实现返回非 `None` 值,或选中的 firstresult 为 falsy 时,回退到 `content_of(message)`。已选中的 falsy 值不会触发继续尝试低优先级实现。 | +| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> list[UserContent]` | prompt content | `BubFramework.process_inbound` | 没有实现返回非 `None` 值,或选中的 firstresult 为 falsy 时,回退到 `content_of(message)`。已选中的 falsy 值不会触发继续尝试低优先级实现。 | | `run_model` | firstresult | `(prompt, session_id, state) -> str` | model text | `HookRuntime.run_model` | 旧版纯文本路径。`run_model` 与 `run_model_stream` 选其一实现,不要同时实现。 | | `run_model_stream` | firstresult | `(prompt, session_id, state) -> AsyncStreamEvents` | async stream | `HookRuntime.run_model_stream` | 推荐路径。无流式实现时框架将 `run_model` 结果包装为单个 chunk 的流。 | | `continue_prompt` | firstresult | `(prompt: str \| list[dict] \| None, tape: Tape, state: StreamState) -> str \| None` | 可选的下一步 user 消息 | `Agent._stream_events_with_auto_handoff` | 每次 agent loop 继续前调用。`prompt` 是刚完成 step 使用的 prompt;如果上一步未添加消息则为 `None`。实现可同步或异步;若没有 hook 返回字符串,下一次模型调用只使用现有对话,不额外添加 user 消息。 | @@ -41,6 +41,12 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用 | `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` 经 `AgentHooks` | 逐次工具调用。`proceed(arguments=…)` 折叠参数修改;`replace(result)` / `deny(message)` 短路。否决只能通过 decision 对象——异常仅记日志并跳过。 | | `after_tool_call` | 终态处理 | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | 无 | `ToolExecutor` 经 `AgentHooks` | 成功、失败、deny/replace 会触发。取消不观察。赋值 `result.result` 会替换模型可见结果;失败时 `result.error` 仍是原始 `BubError`。 | +### Prompt 内容 + +`build_prompt` 返回 `bub.prompt` 中的 `list[UserContent]`,其中 `UserContent = str | republic.Image | republic.Audio | republic.Video`。媒体用 `republic.image(...)`、`republic.audio(...)` 或 `republic.video(...)` 构造。为保持兼容,它仍可返回旧格式:字符串,或 content block 字典列表(例如 `{"type": "image", "media_type": "image/png", "url": "..."}`)。框架会根据实际返回值进行转换。 + +接收 prompt 的 hook(`run_model`、`run_model_stream`、`continue_prompt`、`system_prompt`)仍收到旧格式:没有媒体时为字符串,否则为 content block 列表,媒体使用 URL(内联媒体转为 `data:` URL)。可用 `bub.prompt.to_content(prompt)` 转为 user content。 + ## 钩子如何被调用 `HookRuntime`(位于 `src/bub/hooks/runtime.py`)在 `pluggy.PluginManager` 之上提供以下语义。 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 edc47890..e02ed259 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -31,7 +31,7 @@ type TurnState = dict[str, Any] @dataclass(frozen=True) class TurnResult: session_id: str - prompt: str | list[dict[str, Any]] + prompt: list[UserContent] model_output: str outbounds: list[Envelope] = field(default_factory=list) state: TurnState = field(default_factory=dict) From da9e6cab049fb4fb14754c8b4ffc6dfea1a7bb78 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 10 Oct 2026 17:12:42 +0800 Subject: [PATCH 07/16] fix: replay Chat Completions tool calls from older tapes Older Bub versions recorded tool calls as {"id", "type": "function", "function": {"name", "arguments"}}, which republic.Message.from_dict rejects with KeyError: 'name', so sessions with such tapes could not build their context. Convert them to Republic's form in to_message, next to the other legacy conversions. Co-Authored-By: Claude Opus 5.5 --- src/bub/tape.py | 17 ++++++++++++++++- tests/test_tape.py | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/bub/tape.py b/src/bub/tape.py index cbae608c..b3e26b15 100644 --- a/src/bub/tape.py +++ b/src/bub/tape.py @@ -184,7 +184,8 @@ def to_message( """Convert one stored message payload; assistant tool calls are added to ``calls``. Payloads are ``republic.Message.to_dict()`` output. Older Bub payloads, with media URLs, - ``tool_call_id``, ``reasoning`` and ``provider_data`` fields, are converted to that form first. + ``tool_call_id``, ``reasoning`` and ``provider_data`` fields, and tool calls in the Chat + Completions ``{"function": {"name", "arguments"}}`` form, are converted to that form first. """ calls = {} if calls is None else calls role = payload.get("role") @@ -197,6 +198,8 @@ def to_message( raw_content = render_result(raw_content) content = _content_parts(raw_content) + if isinstance(tool_calls := data.get("tool_calls"), list): + data["tool_calls"] = [_tool_call_dict(call) for call in tool_calls] if role == "tool" and "tool_call" not in data: call_id = data.pop("tool_call_id", None) if not isinstance(call_id, str) or not call_id: @@ -218,6 +221,18 @@ def to_message( return message +def _tool_call_dict(call: object) -> object: + """Convert a Chat Completions tool call, as recorded by older Bub versions, to Republic's form.""" + if not isinstance(call, dict) or not isinstance(function := call.get("function"), dict): + return call + arguments = function.get("arguments") + return { + "id": call.get("id") or "", + "name": function.get("name") or "", + "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments or {}), + } + + def _content_parts(content: object) -> list[dict[str, Any]]: if content is None: return [] diff --git a/tests/test_tape.py b/tests/test_tape.py index 14174cf2..ef4f5db8 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -45,6 +45,27 @@ async def test_legacy_tool_call_without_content_replays_with_its_result(tmp_path ] +@pytest.mark.asyncio +async def test_chat_completions_tool_calls_from_older_tapes_replay_with_their_results(tmp_path: Path) -> None: + store = InMemoryTapeStore() + tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape") + await tape.ensure_bootstrap_anchor() + calls = [ + {"id": "call-1", "type": "function", "function": {"name": "fs_read", "arguments": '{"path": "a.txt"}'}}, + {"id": "call-2", "type": "function", "function": {"name": "tape_info", "arguments": {}}}, + ] + store.append("test-tape", TapeEntry.tool_call(calls, content="Let me look.")) + store.append("test-tape", TapeEntry.tool_result(["hello", {"entries": 3}])) + + read = republic.ToolCall("call-1", "fs_read", '{"path": "a.txt"}') + info = republic.ToolCall("call-2", "tape_info", "{}") + assert await tape.read_messages() == [ + republic.assistant("Let me look.", tool_calls=[read, info]), + republic.tool(read, "hello"), + republic.tool(info, '{"entries": 3}'), + ] + + @pytest.mark.asyncio @pytest.mark.parametrize("content", ["", "done"]) async def test_text_only_response_remains_a_standalone_assistant_message(tmp_path: Path, content: str) -> None: From 7ec3cf76a5e43aa4ec63d3b6838ee6be80d83817 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 10 Oct 2026 17:12:43 +0800 Subject: [PATCH 08/16] feat: identify Republic model clients as bub/{version} Send User-Agent: bub/{version} instead of Republic's default through AgentSettings.model_client_kwargs, used by the model runner, code mode and onboarding. A User-Agent in client_args.headers still wins. Co-Authored-By: Claude Opus 5.5 --- src/bub/builtin/settings.py | 8 +++++++- tests/test_onboarding.py | 21 +++++++++++---------- tests/test_settings.py | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 886cd796..b1c2b657 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -12,7 +12,7 @@ from pydantic.fields import FieldInfo from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict -from bub import Settings, config, ensure_config +from bub import Settings, __version__, config, ensure_config from bub.builtin.commands import validate_command_prefix if TYPE_CHECKING: @@ -27,6 +27,10 @@ def _provider_env_name(provider: str) -> str: return provider.upper().replace("-", "_") +USER_AGENT = f"bub/{__version__}" +"""Sent to model providers instead of Republic's default.""" + + @dataclass(frozen=True) class ModelCandidate: provider: str @@ -140,6 +144,8 @@ def model_client_kwargs(self, provider: str) -> dict[str, Any]: custom = self.providers.get(provider) provider = provider if custom else provider.lower() options = dict(self.client_args) + # Headers from client_args, including a User-Agent, take precedence. + options["headers"] = {"User-Agent": USER_AGENT, **(options.get("headers") or {})} prefix = _provider_env_name(provider) options.setdefault("env_prefix", f"BUB_{prefix}") return { diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 712bbd89..1c68bd1a 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -15,6 +15,7 @@ from bub import configure, inquirer from bub.builtin import onboarding from bub.builtin.onboarding import discover_models +from bub.builtin.settings import USER_AGENT from bub.framework import BubFramework from bub.hooks import hookimpl @@ -165,16 +166,16 @@ def discover(provider, **kwargs): monkeypatch.setattr(onboarding, "discover_models", discover) config = onboarding.collect_model_config({}) - assert probes == [ - {"api_base": "https://first.test/v1", "api_key": "first-key", "env_prefix": "BUB_OPENAI", "api_format": "chat"}, - {"api_base": "https://first.test/v1", "api_key": "first-key", "env_prefix": "BUB_OPENAI", "api_format": "chat"}, - { - "api_base": "https://second.test/v1", - "api_key": "second-key", - "env_prefix": "BUB_OPENAI", - "api_format": "chat", - }, - ] + headers = {"User-Agent": USER_AGENT} + first = { + "api_base": "https://first.test/v1", + "api_key": "first-key", + "env_prefix": "BUB_OPENAI", + "api_format": "chat", + "headers": headers, + } + second = {**first, "api_base": "https://second.test/v1", "api_key": "second-key"} + assert probes == [first, first, second] assert config["client_args"] == {"api_format": "chat"} assert probes[-1] == onboarding.AgentSettings.model_validate(config).model_client_kwargs("openai") assert [name for name, _ in calls].count("LLM provider") == 1 diff --git a/tests/test_settings.py b/tests/test_settings.py index 27bce758..8f960027 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -351,3 +351,17 @@ def test_hyphenated_provider_names_resolve_environment_credentials(provider: str prefix = provider.upper().replace("-", "_") settings = _settings_with_env({f"BUB_{prefix}_API_KEY": "provider-key"}) assert settings.api_key == {provider: "provider-key"} + + +def test_model_clients_identify_as_bub() -> None: + import republic + + import bub + + settings = AgentSettings(api_key="sk-test") + kwargs = settings.model_client_kwargs("openai") + provider = republic.get_provider("openai", **kwargs) + + assert provider.headers["User-Agent"] == f"bub/{bub.__version__}" + custom = AgentSettings(api_key="sk-test", client_args={"headers": {"User-Agent": "mine", "X-Team": "a"}}) + assert custom.model_client_kwargs("openai")["headers"] == {"User-Agent": "mine", "X-Team": "a"} From d89fb03f3fe768663bca8141353229b33eb39510 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Tue, 13 Oct 2026 08:53:35 +0800 Subject: [PATCH 09/16] feat: expose republic models and media attachments in code mode (#351) - Add a stdlib `republic` module to run_code: media classes, messages, get_model() (chat and stream), get_embedding_model() and get_decision_model(). Model calls are proxied to Bub, which uses its provider settings and credentials; they never reach the code. - Add code-only tools attach_image/attach_audio/attach_video that accept republic media or a path/URL/data URL and attach it to the run_code result, sent to the model as a multimodal tool message. - Add content results (bub.tools.content_result) for tool results with media; spilling bounds only their text. - Add Environment.read_bytes with a default that runs the environment's python. - Document the republic module in the code mode stub and prompt. Co-authored-by: Claude Opus 5.5 --- src/bub/builtin/codemode/__init__.py | 206 ++++++++- src/bub/builtin/codemode/code_runner_child.py | 412 +++++++++++++++++- src/bub/builtin/codemode/republic_bridge.py | 275 ++++++++++++ src/bub/builtin/context.py | 4 +- src/bub/builtin/environment.py | 3 + src/bub/builtin/hook_impl.py | 12 +- src/bub/environment.py | 17 +- src/bub/tools.py | 40 +- tests/test_codemode.py | 290 +++++++++++- tests/test_environment.py | 15 + tests/test_spill.py | 20 + website/src/content/docs/docs/build/tools.mdx | 6 +- .../content/docs/zh-cn/docs/build/tools.mdx | 6 +- 13 files changed, 1276 insertions(+), 30 deletions(-) create mode 100644 src/bub/builtin/codemode/republic_bridge.py diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py index d9038796..ae028b38 100644 --- a/src/bub/builtin/codemode/__init__.py +++ b/src/bub/builtin/codemode/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +import contextvars import hashlib import keyword +import mimetypes import re import uuid from collections.abc import Iterable @@ -12,27 +14,88 @@ from pathlib import Path from typing import Any +import republic + +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 CodeFailed +from bub.environment import CallTool, CodeFailed from bub.errors import BubError, ErrorKind from bub.hooks.interception import AgentHooks -from bub.tools import Tool, ToolContext, ToolExecutor, model_tools, tool +from bub.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" CODE_TOOLS_STATE_KEY = "_runtime_code_tools" DEFAULT_RUN_CODE_TIMEOUT_SECONDS = 120 +_RUN_CODE_MEDIA: contextvars.ContextVar[list[UserContent] | None] = contextvars.ContextVar( + "bub_run_code_media", default=None +) + _STUB_HEADER = '''"""Bub tools available inside `run_code` as `tools.(...)`. Every tool is an async function: `await` it (top-level `await` is allowed) and pass keyword arguments only. Each call returns the structured value described by its return type and raises an exception when the tool fails. Use `asyncio.gather` to run independent calls concurrently. + +The `republic` module is also available (preset as a global and importable); it is documented +at the end of this file. """ -from typing import Any, Literal, NotRequired, TypedDict''' +from typing import Any, Literal, NotRequired, TypedDict + +import republic''' + +_REPUBLIC_STUB = """# --------------------------------------------------------------------------- +# `republic`: media and models inside `run_code`. Models are served by Bub with its +# credentials and settings; every model method is async. Only the members below exist. +# +# class republic.Image / republic.Audio / republic.Video: +# __init__(media_type: str, data: bytes | None = None, url: str | None = None) +# media_type: str; data: bytes | None; url: str | None +# base64_data: str; data_url: str +# `tools.attach_image/audio/video` also take a file path in the environment, an http(s)/gs URL +# or a data URL instead, and load it for you. +# +# republic.system(text: str) -> Message +# republic.user(*content: str | Image | Audio | Video) -> Message +# republic.assistant(*content: str | Image | Audio | Video) -> Message +# Message has .role, .content (list of text and media) and .text. +# +# republic.get_model(spec: str | None = None) -> ChatModel +# A "provider:model" spec; None is the model running this session. +# await model.chat(prompt: str | Message | list[str | Message], **options) -> response +# options: max_tokens, temperature, top_p, top_k, stop, seed, reasoning_effort, extra_body... +# (no tools). A plain string is a user message. +# response.text, .reasoning, .refusal, .finish_reason, .model, +# .token_usage.input_tokens / .output_tokens, .message (append it to continue the conversation) +# model.stream(prompt, **options) -> Stream # same arguments as chat(); not awaited +# async with model.stream(...) as stream: +# async for event in stream: ... +# Events from republic.events: TextDelta(.chunk), ReasoningDelta(.chunk), RefusalDelta(.chunk), +# UsageDelta(.usage), ImageReady(.image), CitationAdded(.citation), Completed(.response, last). +# After iterating: stream.response (as returned by chat()), stream.text, .reasoning, .token_usage. +# republic.get_embedding_model(spec: str) -> EmbeddingModel +# await model.embed(text: str, *, dimensions: int | None = None) -> response # response.vector +# await model.embed_many(texts: list[str], *, dimensions: int | None = None) -> response # response.vectors +# republic.get_decision_model(spec: str) -> DecisionModel +# await model.decide(state, *, questions: dict[str, Question]) -> response +# Questions from republic.decisions: Noul(instructions, criteria=None) (yes/no), +# Choice(instructions, criteria) (pick one), Score(instructions, criteria) (ordered levels). +# response.answers[id] or response.: .noul | .choice/.probabilities/.confidence | .score/... +# +# Example: +# model = republic.get_model() +# chart = republic.Image("image/png", data=open("chart.png", "rb").read()) +# reply = await model.chat([republic.user("Describe this chart.", chart)]) +# print(reply.text) +# async with model.stream("Write a haiku.") as stream: +# async for event in stream: +# if isinstance(event, republic.events.TextDelta): +# print(event.chunk, end="")""" _JSON_TYPES = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None"} @@ -51,6 +114,18 @@ def _pascal(name: str) -> str: return "".join(part[:1].upper() + part[1:] for part in re.split(r"[^0-9A-Za-z]+", name)) +_MEDIA_FIELDS = {"media_type", "data", "url"} + + +def _media_class(schema: dict[str, Any]) -> str | None: + """Recognize the JSON schema of ``republic.Image``, ``Audio`` and ``Video`` parameters.""" + title = schema.get("title") + properties = schema.get("properties") + if title in ("Image", "Audio", "Video") and isinstance(properties, dict) and set(properties) == _MEDIA_FIELDS: + return str(title) + return None + + def _docstring(text: str, indent: str = " ") -> list[str]: text = text.strip().replace("\\", "\\\\").replace('"""', '\\"\\"\\"') if not text: @@ -107,6 +182,11 @@ def type_hint( # noqa: C901 ) -> str: if not isinstance(schema, dict) or not schema: return "Any" + if (media := _media_class(schema)) is not None: + return f"republic.{media}" + if local_defs := self._defs(schema): + # Schemas of single parameters carry their own definitions. + defs = {**defs, **local_defs} if isinstance(ref := schema.get("$ref"), str): return self._ref(ref, defs) for key in ("anyOf", "oneOf"): @@ -219,7 +299,7 @@ def render_tool_stub(tools: Iterable[Tool]) -> str: """Render a Python stub declaring one function per model-facing tool.""" builder = _StubBuilder() functions = [builder.function(tool_item) for tool_item in model_tools(tools)] - return "\n\n\n".join([_STUB_HEADER, *builder.classes.values(), *functions]) + "\n" + return "\n\n\n".join([_STUB_HEADER, *builder.classes.values(), *functions, _REPUBLIC_STUB]) + "\n" def write_tool_stub(tools: Iterable[Tool], *, session_id: str, workspace: Path) -> Path: @@ -246,23 +326,21 @@ def render_code_mode_prompt(stub_path: Path) -> str: "Read the stub before calling a tool you have not used yet. Always `await` tool calls (top-level `await` " "is allowed) and pass keyword arguments; they return structured values and raise on failure. " f"`{RUN_CODE_TOOL_NAME}` returns only what the code prints, so print the results you need, and combine " - "several tool calls in one run when possible.\n" + "several tool calls in one run when possible. To look at an image, audio or video, pass its path, URL " + "or `republic` media object to `tools.attach_image(image=...)`, `tools.attach_audio(audio=...)` or " + f"`tools.attach_video(video=...)`: it is attached to the `{RUN_CODE_TOOL_NAME}` result.\n" + "The code can also use the `republic` module (see the end of the stub) to call models with " + "`republic.get_model()`, " + "`republic.get_embedding_model(spec)` and `republic.get_decision_model(spec)`; `get_model()` without a " + "spec is the model running this session.\n" "" ) -@tool(name=RUN_CODE_TOOL_NAME, context=True, exposure="direct") -async def run_code(code: str, timeout_seconds: int = DEFAULT_RUN_CODE_TIMEOUT_SECONDS, *, context: ToolContext) -> str: - """Run Python code in the environment and return everything it prints. - - Tools are async functions available as `tools.(...)`: await them with keyword arguments - (top-level `await` is allowed). See the tool stub file referenced in the system prompt for their - signatures and result types. The code is stopped after timeout_seconds. - """ - code_tools = context.state.get(CODE_TOOLS_STATE_KEY) - if code_tools is None: - raise BubError(ErrorKind.INVALID_INPUT, "Code mode is not enabled for this run.") - tools_by_name = {_identifier(item.name): item for item in code_tools} +def _code_tool_caller( + tools_by_name: dict[str, Tool], context: ToolContext, republic_session: RepublicSession +) -> CallTool: + """Serve the code's ``tools.`` and ``republic`` calls.""" agent = context.state.get("_runtime_agent") hooks: AgentHooks | None = getattr(getattr(agent, "model_runner", None), "hooks", None) # Code consumes structured results, so this executor does not render them to text. @@ -270,15 +348,41 @@ async def run_code(code: str, timeout_seconds: int = DEFAULT_RUN_CODE_TIMEOUT_SE code_context = replace(context, code_mode=True) async def call_tool(name: str, arguments: dict[str, Any]) -> Any: + arguments = decode_value(arguments) + if name.startswith(REPUBLIC_CALL_PREFIX): + return encode_value(await republic_session.call(name, arguments)) tool_item = tools_by_name.get(name) if tool_item is None: raise BubError(ErrorKind.INVALID_INPUT, f"unknown tool: tools.{name}") execution = await executor.execute_async([(tool_item, arguments)], context=code_context) if execution.error is not None: raise execution.error - return execution.tool_results[0] + return encode_value(execution.tool_results[0]) + + return call_tool + + +@tool(name=RUN_CODE_TOOL_NAME, context=True, exposure="direct") +async def run_code( + code: str, timeout_seconds: int = DEFAULT_RUN_CODE_TIMEOUT_SECONDS, *, context: ToolContext +) -> str | dict[str, Any]: + """Run Python code in the environment and return everything it prints. + + Tools are async functions available as `tools.(...)`: await them with keyword arguments + (top-level `await` is allowed). See the tool stub file referenced in the system prompt for their + signatures and result types. The `republic` module provides media loaders and models. Media passed + to `tools.attach_image/audio/video`, as an object, path or URL, is attached to the result. The code is stopped after timeout_seconds. + """ + code_tools = context.state.get(CODE_TOOLS_STATE_KEY) + if code_tools is None: + raise BubError(ErrorKind.INVALID_INPUT, "Code mode is not enabled for this run.") + tools_by_name = {_identifier(item.name): item for item in code_tools} + republic_session = RepublicSession(replace(context, code_mode=True)) + call_tool = _code_tool_caller(tools_by_name, context, republic_session) output: list[str] = [] + media: list[UserContent] = [] + token = _RUN_CODE_MEDIA.set(media) try: async with asyncio.timeout(timeout_seconds) as deadline: await environment_from_state(context.state).run_code( @@ -296,9 +400,75 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: ) from exc except BubError as exc: raise BubError(exc.kind, exc.message, details={"output": "".join(output), **(exc.details or {})}) from exc + finally: + _RUN_CODE_MEDIA.reset(token) + await republic_session.aclose() + if media: + return content_result(["".join(output), *media] if output else media) return "".join(output) +_REMOTE_MEDIA_PREFIXES = ("http://", "https://", "gs://", "data:") +_MEDIA_CLASSES: dict[str, type[republic.Image | republic.Audio | republic.Video]] = { + "image": republic.Image, + "audio": republic.Audio, + "video": republic.Video, +} + + +async def _load_media(kind: str, source: str, context: ToolContext) -> republic.Image | republic.Audio | republic.Video: + """Load media from a URL, a data URL, or a file path in the session environment.""" + if source.startswith(_REMOTE_MEDIA_PREFIXES): + media: republic.Image | republic.Audio | republic.Video = getattr(republic, kind)(source) + return media + media_type, _ = mimetypes.guess_type(source) + if media_type is None: + raise ValueError(f"cannot guess the media type of {source!r}") + environment = environment_from_state(context.state) + data = await environment.read_bytes(environment.resolve_path(source)) + return _MEDIA_CLASSES[kind](media_type, data=data) + + +async def _attach(kind: str, item: republic.Image | republic.Audio | republic.Video | str, context: ToolContext) -> str: + media = _RUN_CODE_MEDIA.get() + if media is None: + raise BubError(ErrorKind.INVALID_INPUT, f"tools.attach_{kind} can only be called from {RUN_CODE_TOOL_NAME}.") + if isinstance(item, str): + try: + item = await _load_media(kind, item, context) + except (OSError, ValueError) as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"Cannot load the {kind}: {exc}") from exc + media.append(item) + return f"The {kind} is attached to the {RUN_CODE_TOOL_NAME} result." + + +@tool(name="attach_image", context=True, exposure="code") +async def attach_image(image: republic.Image | str, *, context: ToolContext) -> str: + """Attach an image to the result of this `run_code` call, so you can see it. + + `image` is a `republic.Image`, a file path in the environment, an http(s) URL, or a data URL. + """ + return await _attach("image", image, context) + + +@tool(name="attach_audio", context=True, exposure="code") +async def attach_audio(audio: republic.Audio | str, *, context: ToolContext) -> str: + """Attach audio to the result of this `run_code` call, so you can hear it. + + `audio` is a `republic.Audio`, a file path in the environment, an http(s) URL, or a data URL. + """ + return await _attach("audio", audio, context) + + +@tool(name="attach_video", context=True, exposure="code") +async def attach_video(video: republic.Video | str, *, context: ToolContext) -> str: + """Attach a video to the result of this `run_code` call, so you can watch it. + + `video` is a `republic.Video`, a file path in the environment, an http(s) URL, or a data URL. + """ + return await _attach("video", video, context) + + @tool(name="code_mode", context=True, exposure="command") async def set_code_mode(enable: bool, *, context: ToolContext) -> str: """Enable or disable code mode for THIS session. Invoke as the `,code_mode enable=true` command. diff --git a/src/bub/builtin/codemode/code_runner_child.py b/src/bub/builtin/codemode/code_runner_child.py index cd8080dd..a6d05cfb 100644 --- a/src/bub/builtin/codemode/code_runner_child.py +++ b/src/bub/builtin/codemode/code_runner_child.py @@ -10,12 +10,18 @@ each tool call, ``{"type": "output", "data"}`` for what the code writes to ``sys.stdout``, and ends with ``{"type": "done"}`` or ``{"type": "failed", "error", "traceback"}``. + +The code also gets a ``republic`` module (importable and preset as a global). Its models +call Bub through tool calls named ``republic.``, so credentials stay in Bub. +Republic values travel as JSON objects with a ``"$republic"`` key, such as +``{"$republic": "image", "media_type", "data", "url"}`` with base64 ``data``. """ from __future__ import annotations import ast import asyncio +import base64 import builtins import inspect import io @@ -37,6 +43,398 @@ class BubError(Exception): """A tool call failed or was denied on the Bub side.""" +_REPUBLIC_KEY = "$republic" + + +class _Media: + kind = "media" + + def __init__(self, media_type: str, data: bytes | None = None, url: str | None = None) -> None: + if data is None and url is None: + raise ValueError(f"{type(self).__name__} needs data or a url") + self.media_type = media_type + self.data = data + self.url = url + + @property + def base64_data(self) -> str: + if self.data is None: + raise ValueError(f"This {self.kind} has no inline data") + return base64.b64encode(self.data).decode("ascii") + + @property + def data_url(self) -> str: + if self.url is not None: + return self.url + return f"data:{self.media_type};base64,{self.base64_data}" + + def __eq__(self, other: object) -> bool: + return type(other) is type(self) and (self.media_type, self.data, self.url) == ( + other.media_type, + other.data, + other.url, + ) + + def __hash__(self) -> int: + return hash((type(self), self.media_type, self.data, self.url)) + + def __repr__(self) -> str: + source = f"url={self.url!r}" if self.url is not None else f"data=<{len(self.data or b'')} bytes>" + return f"{type(self).__name__}(media_type={self.media_type!r}, {source})" + + def _to_json(self) -> dict[str, Any]: + data = None if self.data is None else self.base64_data + return {_REPUBLIC_KEY: self.kind, "media_type": self.media_type, "data": data, "url": self.url} + + +class Image(_Media): + kind = "image" + + +class Audio(_Media): + kind = "audio" + + +class Video(_Media): + kind = "video" + + +_MEDIA_CLASSES: dict[str, type[_Media]] = {"image": Image, "audio": Audio, "video": Video} + + +class Message: + """A chat message whose content mixes text and media.""" + + def __init__(self, role: str, content: list[Any]) -> None: + self.role = role + self.content = content + + @property + def text(self) -> str: + return "".join(item for item in self.content if isinstance(item, str)) + + def __repr__(self) -> str: + return f"Message(role={self.role!r}, content={self.content!r})" + + def _to_json(self) -> dict[str, Any]: + return {_REPUBLIC_KEY: "message", "role": self.role, "content": self.content} + + +def system(text: str) -> Message: + return Message("system", [text]) + + +def user(*content: Any) -> Message: + return Message("user", list(content)) + + +def assistant(*content: Any) -> Message: + return Message("assistant", list(content)) + + +class _Question: + kind = "question" + + def __init__(self, instructions: Any, criteria: Any = None) -> None: + self.instructions = instructions + self.criteria = criteria + + def __repr__(self) -> str: + return f"{type(self).__name__}(instructions={self.instructions!r}, criteria={self.criteria!r})" + + def _to_json(self) -> dict[str, Any]: + return {_REPUBLIC_KEY: self.kind, "instructions": self.instructions, "criteria": self.criteria} + + +class Noul(_Question): + """A yes/no question answered with the probability of yes.""" + + kind = "noul" + + +class Choice(_Question): + """Pick one option; ``criteria`` maps options to descriptions or lists option names.""" + + kind = "choice" + + def __init__(self, instructions: Any, criteria: Any) -> None: + super().__init__(instructions, criteria) + + +class Score(_Question): + """Rate along ordered levels, from lowest to highest.""" + + kind = "score" + + def __init__(self, instructions: Any, criteria: Any) -> None: + super().__init__(instructions, criteria) + + +class _Record(types.SimpleNamespace): + """A result whose fields are attributes.""" + + +class DecisionResponse(_Record): + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + raise AttributeError(name) + try: + return self.__dict__["answers"][name] + except KeyError: + raise AttributeError(name) from None + + +class EmbeddingResponse(_Record): + @property + def vector(self) -> list[float]: + vectors: list[list[float]] = self.vectors + return vectors[0] + + +def _record(value: Any, record_class: type[_Record] = _Record) -> Any: + if isinstance(value, dict) and _REPUBLIC_KEY not in value: + return record_class(**{key: _record(item) for key, item in value.items()}) + return value + + +def _json_default(value: Any) -> Any: + if isinstance(value, _Media | Message | _Question): + return value._to_json() + if isinstance(value, set | frozenset): + return list(value) + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + +def _json_object(value: dict[str, Any]) -> Any: + kind = value.get(_REPUBLIC_KEY) + if kind in _MEDIA_CLASSES: + data = value.get("data") + return _MEDIA_CLASSES[kind]( + value["media_type"], data=None if data is None else base64.b64decode(data), url=value.get("url") + ) + if kind == "message": + return Message(value["role"], list(value.get("content") or [])) + return value + + +class _Model: + def __init__(self, call: Any, spec: str | None) -> None: + self._call = call + self.spec = spec + + async def __aenter__(self) -> Any: + return self + + async def __aexit__(self, *exc_info: Any) -> None: + return None + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.spec!r})" + + +class TextDelta(_Record): + """``chunk`` of answer text.""" + + +class ReasoningDelta(_Record): + """``chunk`` of reasoning text, or its summary.""" + + +class RefusalDelta(_Record): + """``chunk`` of text explaining why the model declines to answer.""" + + +class UsageDelta(_Record): + """``usage`` added since the previous usage event.""" + + +class ImageReady(_Record): + """An ``image`` generated by the model.""" + + +class CitationAdded(_Record): + """A ``citation`` of a source.""" + + +class Completed(_Record): + """The last event, carrying the full ``response``.""" + + +class OtherEvent(_Record): + """An ``event`` (its type name) that is not forwarded to code.""" + + +_EVENT_CLASSES: dict[str, type[_Record]] = { + "text": TextDelta, + "reasoning": ReasoningDelta, + "refusal": RefusalDelta, + "usage": UsageDelta, + "image": ImageReady, + "citation": CitationAdded, + "completed": Completed, +} + + +def _event(data: dict[str, Any]) -> _Record: + kind = data.pop("type") + event_class = _EVENT_CLASSES.get(kind, OtherEvent) + return event_class(**{key: _record(value) for key, value in data.items()}) + + +def _chat_arguments(spec: str | None, prompt: Any, options: dict[str, Any]) -> dict[str, Any]: + if isinstance(prompt, str | Message): + prompt = [prompt] + messages = [user(item) if isinstance(item, str) else item for item in prompt] + return {"spec": spec, "messages": messages, "options": options} + + +class Stream: + """An in-progress response: ``async with`` it, then ``async for`` its events. + + After the iteration ends, the full response is ``stream.response``, with shortcuts + ``text``, ``reasoning`` and ``token_usage``. + """ + + def __init__(self, call: Any, arguments: dict[str, Any]) -> None: + self._call = call + self._arguments = arguments + self._id: str | None = None + self._iterated = False + self._response: Any = None + + async def __aenter__(self) -> Stream: + self._id = (await self._call("republic.stream_open", self._arguments))["stream"] + return self + + async def __aexit__(self, *exc_info: Any) -> None: + if self._id is not None: + stream_id, self._id = self._id, None + await self._call("republic.stream_close", {"stream": stream_id}) + + async def __aiter__(self) -> Any: + if self._id is None: + raise RuntimeError("Enter the stream with 'async with' before iterating it") + if self._iterated: + raise RuntimeError("A stream can only be iterated once; read stream.response instead") + self._iterated = True + while True: + batch = await self._call("republic.stream_next", {"stream": self._id}) + for data in batch["events"]: + event = _event(data) + if isinstance(event, Completed): + self._response = event.response + yield event + if batch["done"]: + self._id = None + return + + @property + def response(self) -> Any: + if self._response is None: + raise RuntimeError("The stream has not been fully consumed") + return self._response + + @property + def text(self) -> str: + text: str = self.response.text + return text + + @property + def reasoning(self) -> str: + reasoning: str = self.response.reasoning + return reasoning + + @property + def token_usage(self) -> Any: + return self.response.token_usage + + +class ChatModel(_Model): + """A chat model served by Bub.""" + + async def chat(self, prompt: Any, **options: Any) -> Any: + """Send the conversation and wait for the complete response.""" + return _record(await self._call("republic.chat", _chat_arguments(self.spec, prompt, options))) + + def stream(self, prompt: Any, **options: Any) -> Stream: + """Stream the response. Use as ``async with model.stream(...) as stream``.""" + return Stream(self._call, _chat_arguments(self.spec, prompt, options)) + + +class EmbeddingModel(_Model): + """An embedding model served by Bub.""" + + async def embed(self, text: str, *, dimensions: int | None = None) -> Any: + """Embed one text; the vector is ``response.vector``.""" + return await self.embed_many([text], dimensions=dimensions) + + async def embed_many(self, texts: list[str], *, dimensions: int | None = None) -> Any: + """Embed several texts in one request; ``response.vectors`` follows input order.""" + arguments = {"spec": self.spec, "texts": list(texts), "dimensions": dimensions} + return _record(await self._call("republic.embed", arguments), EmbeddingResponse) + + +class DecisionModel(_Model): + """A decision model served by Bub.""" + + async def decide(self, state: Any, *, questions: dict[str, _Question]) -> Any: + """Answer every question about ``state``. Answers come back under the same ids.""" + result = await self._call("republic.decide", {"spec": self.spec, "state": state, "questions": questions}) + response = _record(result, DecisionResponse) + response.answers = {key: _record(answer) for key, answer in result["answers"].items()} + return response + + +def _republic_module(call: Any) -> types.ModuleType: + module = types.ModuleType("republic", "Republic models and media, served by Bub.") + events = types.ModuleType("republic.events") + events.__dict__.update( + TextDelta=TextDelta, + ReasoningDelta=ReasoningDelta, + RefusalDelta=RefusalDelta, + UsageDelta=UsageDelta, + ImageReady=ImageReady, + CitationAdded=CitationAdded, + Completed=Completed, + OtherEvent=OtherEvent, + ) + decisions = types.ModuleType("republic.decisions") + decisions.__dict__.update(Noul=Noul, Choice=Choice, Score=Score, DecisionResponse=DecisionResponse) + + def get_model(spec: str | None = None) -> ChatModel: + """A chat model for a ``"provider:model"`` spec; defaults to the model running this session.""" + return ChatModel(call, spec) + + def get_embedding_model(spec: str) -> EmbeddingModel: + """An embedding model for a ``"provider:model"`` spec.""" + return EmbeddingModel(call, spec) + + def get_decision_model(spec: str) -> DecisionModel: + """A decision model for a ``"provider:model"`` spec.""" + return DecisionModel(call, spec) + + module.__dict__.update( + Image=Image, + Audio=Audio, + Video=Video, + Message=Message, + ChatModel=ChatModel, + EmbeddingModel=EmbeddingModel, + DecisionModel=DecisionModel, + EmbeddingResponse=EmbeddingResponse, + system=system, + user=user, + assistant=assistant, + decisions=decisions, + events=events, + Stream=Stream, + get_model=get_model, + get_embedding_model=get_embedding_model, + get_decision_model=get_decision_model, + ) + return module + + class _Channel: def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop @@ -47,7 +445,7 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._first: asyncio.Future[dict[str, Any]] = loop.create_future() def send(self, message: dict[str, Any]) -> None: - line = json.dumps(message) + "\n" + line = json.dumps(message, default=_json_default) + "\n" with self._send_lock: self._out.write(line) self._out.flush() @@ -59,7 +457,7 @@ def start(self) -> asyncio.Future[dict[str, Any]]: def _read(self) -> None: for line in sys.stdin: - self._loop.call_soon_threadsafe(self._dispatch, json.loads(line)) + self._loop.call_soon_threadsafe(self._dispatch, json.loads(line, object_hook=_json_object)) self._loop.call_soon_threadsafe(self._close) def _dispatch(self, message: dict[str, Any]) -> None: @@ -149,10 +547,20 @@ async def _main() -> None: code, filename = run["code"], run["filename"] output = _Output(channel) sys.stdout = output + + async def call(name: str, arguments: dict[str, Any]) -> Any: + output.flush() + return await channel.call(name, arguments) + + # Shadow any installed Republic: models must go through Bub, which holds the credentials. + republic = sys.modules["republic"] = _republic_module(call) + sys.modules["republic.decisions"] = republic.decisions + sys.modules["republic.events"] = republic.events namespace = { "__name__": "__run_code__", "__builtins__": builtins, "tools": types.SimpleNamespace(**{name: _tool_function(channel, output, name) for name in run["tools"]}), + "republic": republic, } linecache.cache[filename] = (len(code), None, code.splitlines(keepends=True), filename) try: diff --git a/src/bub/builtin/codemode/republic_bridge.py b/src/bub/builtin/codemode/republic_bridge.py new file mode 100644 index 00000000..b7302abf --- /dev/null +++ b/src/bub/builtin/codemode/republic_bridge.py @@ -0,0 +1,275 @@ +"""Serve the ``republic`` module of ``run_code``: Republic values over JSON and model calls made in Bub. + +Model-written code never sees provider credentials: its models are proxies whose calls arrive +here as tool calls named ``republic.`` (see ``code_runner_child``). +""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import uuid +from collections.abc import Awaitable, Callable, Mapping +from contextlib import AsyncExitStack +from dataclasses import asdict +from typing import Any + +import republic +from pydantic_core import to_jsonable_python +from republic.decisions import Choice, Noul, Question, Score +from republic.events import ( + CitationAdded, + Completed, + Event, + ImageReady, + ReasoningDelta, + RefusalDelta, + TextDelta, + UsageDelta, +) + +from bub.errors import BubError, ErrorKind +from bub.tools import ToolContext + +REPUBLIC_CALL_PREFIX = "republic." +REPUBLIC_KEY = "$republic" + +_MEDIA_CLASSES: dict[str, type[republic.Image | republic.Audio | republic.Video]] = { + "image": republic.Image, + "audio": republic.Audio, + "video": republic.Video, +} +_QUESTION_CLASSES: dict[str, type[Noul | Choice | Score]] = {"noul": Noul, "choice": Choice, "score": Score} +_MESSAGE_BUILDERS: dict[str, Callable[..., republic.Message]] = { + "system": republic.system, + "user": republic.user, + "assistant": republic.assistant, +} + + +def decode_value(value: Any) -> Any: + """Turn ``{"$republic": ...}`` objects sent by the code into Republic values.""" + if isinstance(value, list): + return [decode_value(item) for item in value] + if not isinstance(value, dict): + return value + kind = value.get(REPUBLIC_KEY) + if kind is None: + return {key: decode_value(item) for key, item in value.items()} + try: + if kind in _MEDIA_CLASSES: + data = value.get("data") + return _MEDIA_CLASSES[kind]( + value["media_type"], + data=None if data is None else base64.b64decode(data, validate=True), + url=value.get("url"), + ) + if kind in _QUESTION_CLASSES: + arguments = {"instructions": value["instructions"]} + if value.get("criteria") is not None or kind != "noul": + arguments["criteria"] = value.get("criteria") + return _QUESTION_CLASSES[kind](**arguments) + if kind == "message": + return _MESSAGE_BUILDERS[value["role"]](*decode_value(value.get("content") or [])) + except (KeyError, TypeError, ValueError, binascii.Error) as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"Invalid republic {kind} value: {exc}") from exc + raise BubError(ErrorKind.INVALID_INPUT, f"Unknown republic value: {kind!r}") + + +def encode_value(value: Any) -> Any: + """Turn Republic media in a result into ``{"$republic": ...}`` objects the code decodes.""" + if isinstance(value, republic.Image | republic.Audio | republic.Video): + data = None if value.data is None else value.base64_data + return {REPUBLIC_KEY: value.kind, "media_type": value.media_type, "data": data, "url": value.url} + if isinstance(value, list | tuple): + return [encode_value(item) for item in value] + if isinstance(value, Mapping): + return {key: encode_value(item) for key, item in value.items()} + return value + + +def _encode_message(message: republic.Message) -> dict[str, Any]: + content = [ + part.text if isinstance(part, republic.Text) else encode_value(part) + for part in message.parts + if isinstance(part, republic.Text | republic.Image | republic.Audio | republic.Video) + ] + return {REPUBLIC_KEY: "message", "role": message.role, "content": content} + + +def _model_spec(context: ToolContext, spec: str | None) -> tuple[str, dict[str, Any]]: + """Resolve a spec, or the session's model, to a Republic spec and client options from Bub's settings.""" + agent = context.state.get("_runtime_agent") + settings = getattr(agent, "settings", None) + if settings is None: + raise BubError(ErrorKind.INVALID_INPUT, "Republic models need a running Bub agent.") + spec = spec or context.state.get("model") or settings.model + try: + candidate = settings.model_candidates(spec)[0] + except ValueError as exc: + raise BubError(ErrorKind.INVALID_INPUT, str(exc)) from exc + client_kwargs = settings.model_client_kwargs(candidate.provider_name or candidate.provider) + return f"{candidate.provider}:{candidate.model_id}", client_kwargs + + +def _chat_messages(messages: list[Any], options: dict[str, Any]) -> list[republic.Message]: + if "tools" in options or "tool_choice" in options: + raise BubError(ErrorKind.INVALID_INPUT, "republic models in run_code do not support tools.") + if not all(isinstance(message, republic.Message) for message in messages): + raise BubError(ErrorKind.INVALID_INPUT, "chat() takes text or republic messages.") + return messages + + +def _encode_response(response: republic.Response[Any]) -> dict[str, Any]: + return { + "text": response.text, + "reasoning": response.reasoning, + "refusal": response.refusal, + "finish_reason": response.finish_reason, + "model": response.model, + "token_usage": asdict(response.token_usage), + "message": _encode_message(response.message), + } + + +def _encode_event(event: Event) -> dict[str, Any]: + match event: + case TextDelta(chunk): + return {"type": "text", "chunk": chunk} + case ReasoningDelta(chunk): + return {"type": "reasoning", "chunk": chunk} + case RefusalDelta(chunk): + return {"type": "refusal", "chunk": chunk} + case UsageDelta(usage): + return {"type": "usage", "usage": asdict(usage)} + case ImageReady(image): + return {"type": "image", "image": encode_value(image)} + case CitationAdded(citation): + return {"type": "citation", "citation": to_jsonable_python(citation)} + case Completed(response): + return {"type": "completed", "response": _encode_response(response)} + case _: + return {"type": "other", "event": type(event).__name__} + + +_END = object() + + +class _StreamPump: + """Read a Republic stream in the background so each ``stream_next`` returns every event that has arrived.""" + + def __init__(self, stack: AsyncExitStack, stream: republic.Stream[Any]) -> None: + self._stack = stack + self._queue: asyncio.Queue[Any] = asyncio.Queue() + self._task = asyncio.create_task(self._pump(stream)) + + async def _pump(self, stream: republic.Stream[Any]) -> None: + try: + async for event in stream: + self._queue.put_nowait(_encode_event(event)) + except Exception as exc: + self._queue.put_nowait(exc) + finally: + self._queue.put_nowait(_END) + + async def next(self) -> dict[str, Any]: + items = [await self._queue.get()] + while not self._queue.empty(): + items.append(self._queue.get_nowait()) + events = [item for item in items if isinstance(item, dict)] + if error := next((item for item in items if isinstance(item, Exception)), None): + if events: + # Deliver the events received before the failure first. + self._queue.put_nowait(error) + self._queue.put_nowait(_END) + return {"events": events, "done": False} + raise error + return {"events": events, "done": _END in items} + + async def aclose(self) -> None: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + await self._stack.aclose() + + +class RepublicSession: + """Serve the ``republic.`` calls of one ``run_code``; ``aclose`` closes streams left open.""" + + def __init__(self, context: ToolContext) -> None: + self.context = context + self._streams: dict[str, _StreamPump] = {} + self._operations: dict[str, Callable[..., Awaitable[Any]]] = { + "chat": self._chat, + "stream_open": self._stream_open, + "stream_next": self._stream_next, + "stream_close": self._stream_close, + "embed": self._embed, + "decide": self._decide, + } + + async def call(self, name: str, arguments: dict[str, Any]) -> Any: + """Run the ``republic.`` call ``name`` made by the code's ``republic`` module.""" + operation = self._operations.get(name.removeprefix(REPUBLIC_CALL_PREFIX)) + if operation is None: + raise BubError(ErrorKind.INVALID_INPUT, f"unknown republic operation: {name}") + try: + return await operation(**arguments) + except BubError: + raise + except TypeError as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"{name} failed: {exc}") from exc + except Exception as exc: + raise BubError(ErrorKind.TOOL, f"{name} failed: {type(exc).__name__}: {exc}") from exc + + async def aclose(self) -> None: + streams, self._streams = list(self._streams.values()), {} + await asyncio.gather(*(stream.aclose() for stream in streams), return_exceptions=True) + + async def _chat(self, spec: str | None, messages: list[Any], options: dict[str, Any]) -> Any: + messages = _chat_messages(messages, options) + model_spec, client_kwargs = _model_spec(self.context, spec) + async with republic.get_model(model_spec, **client_kwargs) as model: + response = await model.chat(messages, **options) + return _encode_response(response) + + async def _stream_open(self, spec: str | None, messages: list[Any], options: dict[str, Any]) -> Any: + messages = _chat_messages(messages, options) + model_spec, client_kwargs = _model_spec(self.context, spec) + async with AsyncExitStack() as stack: + model = await stack.enter_async_context(republic.get_model(model_spec, **client_kwargs)) + stream = await stack.enter_async_context(model.stream(messages, **options)) + stream_id = uuid.uuid4().hex + self._streams[stream_id] = _StreamPump(stack.pop_all(), stream) + return {"stream": stream_id} + + async def _stream_next(self, stream: str) -> Any: + pump = self._streams.get(stream) + if pump is None: + raise BubError(ErrorKind.INVALID_INPUT, "The stream is closed.") + result = await pump.next() + if result["done"]: + await self._stream_close(stream) + return result + + async def _stream_close(self, stream: str) -> None: + if (pump := self._streams.pop(stream, None)) is not None: + await pump.aclose() + + async def _embed(self, spec: str, texts: list[str], dimensions: int | None = None) -> Any: + model_spec, client_kwargs = _model_spec(self.context, spec) + async with republic.get_embedding_model(model_spec, **client_kwargs) as model: + response = await model.embed_many(texts, dimensions=dimensions) + return {"vectors": response.vectors, "model": response.model, "token_usage": asdict(response.token_usage)} + + async def _decide(self, spec: str, state: Any, questions: dict[str, Question]) -> Any: + if not all(isinstance(question, Noul | Choice | Score) for question in questions.values()): + raise BubError(ErrorKind.INVALID_INPUT, "decide() takes republic.decisions questions.") + model_spec, client_kwargs = _model_spec(self.context, spec) + async with republic.get_decision_model(model_spec, **client_kwargs) as model: + response = await model.decide(state, questions=questions) + return { + "answers": {key: asdict(answer) for key, answer in response.answers.items()}, + "model": response.model, + "token_usage": asdict(response.token_usage), + } diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py index 54fc7ffb..9a7d4f1e 100644 --- a/src/bub/builtin/context.py +++ b/src/bub/builtin/context.py @@ -9,7 +9,7 @@ import republic from bub.tape import TapeContext, TapeEntry, to_message -from bub.tools import render_result +from bub.tools import result_content def default_tape_context() -> TapeContext: @@ -67,4 +67,4 @@ def _append_tool_result_entry( return # Results without a recorded call cannot be expressed as tool messages. for call, result in zip(pending_calls, results, strict=False): - messages.append(republic.tool(call, render_result(result))) + messages.append(republic.tool(call, *result_content(result))) diff --git a/src/bub/builtin/environment.py b/src/bub/builtin/environment.py index b59469ee..91a4d3f1 100644 --- a/src/bub/builtin/environment.py +++ b/src/bub/builtin/environment.py @@ -107,6 +107,9 @@ async def spawn( async def read_text(self, path: str) -> str: return await asyncio.to_thread(Path(path).read_text, encoding="utf-8") + async def read_bytes(self, path: str) -> bytes: + return await asyncio.to_thread(Path(path).read_bytes) + async def write_text(self, path: str, content: str) -> None: def write() -> None: target = Path(path) diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 70e2746f..5f825542 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -32,7 +32,7 @@ from bub.store import TapeStore from bub.streaming import AsyncStreamEvents from bub.tape import TapeContext -from bub.tools import render_result +from bub.tools import is_content_result, render_result from bub.turn import TurnState AGENTS_FILE_NAME = "AGENTS.md" @@ -397,6 +397,16 @@ async def after_tool_call( else: return + if is_content_result(tool_result): + # Bound the text; media stays inline. + blocks = tool_result["content"] + text = "".join(block["text"] for block in blocks if block.get("type") == "text") + bounded_text = await spill.spill_tool_result(tape, text, tool=call.tool, run_id=call.run_id) + if bounded_text != text: + media = [block for block in blocks if block.get("type") != "text"] + result.result = {**tool_result, "content": [{"type": "text", "text": bounded_text}, *media]} + return + rendered_result = render_result(tool_result) bounded_result = await spill.spill_tool_result( tape, diff --git a/src/bub/environment.py b/src/bub/environment.py index b2e67298..6dab1024 100644 --- a/src/bub/environment.py +++ b/src/bub/environment.py @@ -104,6 +104,19 @@ async def spawn( async def read_text(self, path: str) -> str: """Read a UTF-8 text file.""" + async def read_bytes(self, path: str) -> bytes: + """Read a file as bytes. + + The default runs ``python`` in the environment to print the file; override it with direct access. + """ + script = "import sys; sys.stdout.buffer.write(open(sys.argv[1], 'rb').read())" + process = await self.spawn([self.python, "-c", script, path]) + process.close_stdin() + data, error = await asyncio.gather(process.stdout.read(), process.stderr.read()) + if await process.wait() != 0: + raise OSError(error.decode("utf-8", errors="replace").strip() or f"cannot read {path}") + return data + @abc.abstractmethod async def write_text(self, path: str, content: str) -> None: """Write a UTF-8 text file, creating missing parent directories.""" @@ -126,7 +139,9 @@ async def run_code( The code may use top-level ``await``. For each name in ``tools``, ``tools.(**kwargs)`` must be an async function that returns ``await call_tool(name, kwargs)``; errors from - ``call_tool`` surface in the code as exceptions. Pass everything the code writes to stdout + ``call_tool`` surface in the code as exceptions. The code may also call ``call_tool`` with names + prefixed ``republic.``; ``bub.builtin.codemode.code_runner_child`` shows the ``republic`` module + that makes those calls. Pass everything the code writes to stdout to ``write`` as it happens, so output survives a timeout. Raise :class:`CodeFailed` when the code raises; any other exception reports that the runtime itself failed. When cancelled (for example on timeout), stop the code before returning. diff --git a/src/bub/tools.py b/src/bub/tools.py index e972e200..840196e0 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -10,11 +10,13 @@ from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any, Literal, Protocol, get_args, get_type_hints, overload +import republic from loguru import logger from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call from bub.errors import BubError, ErrorKind from bub.hooks.interception import ToolCall, ToolCallResult +from bub.prompt import UserContent from bub.tape import Tape from bub.tracing import Span @@ -49,6 +51,41 @@ def render_result(result: Any) -> str: return str(result) +CONTENT_RESULT_TYPE = "tool_content" + + +def content_result(content: Sequence[UserContent]) -> dict[str, Any]: + """Build a model-facing tool result that carries media along with text. + + The result is JSON so it can be recorded on the tape; ``result_content`` reads it back. + """ + blocks = [ + {"type": "text", "text": item} if isinstance(item, str) else republic.user(item).to_dict()["content"][0] + for item in content + ] + return {"type": CONTENT_RESULT_TYPE, "content": blocks} + + +def is_content_result(result: Any) -> bool: + return ( + isinstance(result, dict) + and result.get("type") == CONTENT_RESULT_TYPE + and isinstance(result.get("content"), list) + ) + + +def result_content(result: Any) -> list[UserContent]: + """Return a tool result as content for the model: media of ``content_result`` results, otherwise text.""" + if not is_content_result(result): + return [render_result(result)] + parts = republic.Message.from_dict({"role": "user", "content": result["content"]}).parts + return [ + part.text if isinstance(part, republic.Text) else part + for part in parts + if isinstance(part, republic.Text | republic.Image | republic.Audio | republic.Video) + ] + + def _to_snake_case(name: str) -> str: return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") @@ -408,7 +445,8 @@ async def _invoke_normalized(self, tool_obj: Tool, call: ToolCall, context: Tool ) if inspect.isawaitable(value): value = await value - if self._render: + # Content results already carry model-facing text and media. + if self._render and not is_content_result(value): value = tool_obj.render(value) except BubError: raise diff --git a/tests/test_codemode.py b/tests/test_codemode.py index 44ee8e24..a87df637 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -9,7 +9,7 @@ from collections.abc import Callable, Mapping, Sequence from pathlib import Path from types import SimpleNamespace -from typing import Any, TypedDict +from typing import Any, ClassVar, TypedDict import pytest from pydantic import BaseModel, Field @@ -450,3 +450,291 @@ async def run_code(self, code: str, **kwargs: Any) -> None: await run_code.run(code="print(1)", context=context) assert runtime_error.value.message == "runtime crashed" assert runtime_error.value.details == {"output": "partial\n", "stderr": "boom"} + + +PNG_BYTES = b"\x89PNG\r\n\x1a\nfake" + + +def test_stub_declares_media_tools_and_documents_republic() -> None: + from bub.builtin.codemode import attach_image + + stub = render_tool_stub([attach_image]) + + ast.parse(stub) + assert "import republic" in stub + assert "async def attach_image(*, image: republic.Image | str) -> str:" in stub + assert "republic.get_model(spec: str | None = None) -> ChatModel" in stub + assert "republic.get_embedding_model(spec: str) -> EmbeddingModel" in stub + assert "republic.get_decision_model(spec: str) -> DecisionModel" in stub + assert "model.stream(prompt, **options) -> Stream" in stub + + +@pytest.mark.asyncio +async def test_run_code_attaches_media_to_its_result(tmp_path: Path) -> None: + import republic + + from bub.builtin.codemode import attach_audio, attach_image, attach_video + from bub.tools import result_content + + (tmp_path / "charts").mkdir() + (tmp_path / "charts" / "chart.png").write_bytes(PNG_BYTES) + code = ( + "print(await tools.attach_image(image='charts/chart.png'))\n" + "await tools.attach_audio(audio='https://example.com/a.mp3')\n" + "await tools.attach_video(video=republic.Video('video/mp4', data=b'mp4'))\n" + ) + tools = [attach_image, attach_audio, attach_video] + + result = await run_code.run( + code=code, context=_context(tmp_path, tools, **{ENVIRONMENT_STATE_KEY: LocalEnvironment(tmp_path)}) + ) + + assert result_content(result) == [ + "The image is attached to the run_code result.\n", + republic.Image("image/png", data=PNG_BYTES), + republic.Audio("audio/mpeg", url="https://example.com/a.mp3"), + republic.Video("video/mp4", data=b"mp4"), + ] + + +@pytest.mark.asyncio +async def test_run_code_reports_media_that_cannot_be_loaded(tmp_path: Path) -> None: + from bub.builtin.codemode import attach_image + + code = ( + "for source in ('missing.png', 'notes'):\n" + " try:\n" + " await tools.attach_image(image=source)\n" + " except Exception as exc:\n" + " print(exc)\n" + ) + + output = await run_code.run( + code=code, context=_context(tmp_path, [attach_image], **{ENVIRONMENT_STATE_KEY: LocalEnvironment(tmp_path)}) + ) + + lines = output.splitlines() + assert lines[0].startswith("[invalid_input] Cannot load the image: [Errno 2]") + assert lines[1] == "[invalid_input] Cannot load the image: cannot guess the media type of 'notes'" + + +@pytest.mark.asyncio +async def test_media_tools_only_work_inside_run_code(tmp_path: Path) -> None: + import republic + + from bub.builtin.codemode import attach_image + + with pytest.raises(BubError, match="can only be called from run_code"): + await attach_image.run(image=republic.Image("image/png", data=PNG_BYTES), context=_context(tmp_path)) + + +def test_media_tool_results_become_multimodal_tool_messages(tmp_path: Path) -> None: + import republic + + from bub.builtin.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 = [ + TapeEntry.tool_call([{"id": "call-1", "name": "run_code", "arguments": "{}"}]), + TapeEntry.tool_result([content_result(["printed", image])]), + ] + context = default_tape_context() + + messages = context.select(entries, context) # type: ignore[misc] + + assert messages[-1].parts == (republic.Text("printed"), image) + + +class _FakeChatModel: + calls: ClassVar[list[tuple[str, dict[str, Any], Any, Any]]] = [] + + def __init__(self, spec: str, kwargs: dict[str, Any]) -> None: + self.spec, self.kwargs = spec, kwargs + + async def __aenter__(self) -> _FakeChatModel: + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + async def chat(self, messages: Any, **options: Any) -> Any: + import republic + + self.calls.append((self.spec, self.kwargs, messages, options)) + message = republic.assistant("a chart") + return SimpleNamespace( + text=message.text, + reasoning="", + refusal=None, + finish_reason="stop", + model="gpt-x-1", + token_usage=republic.TokenUsage(input_tokens=3, output_tokens=2), + message=message, + ) + + async def embed_many(self, texts: list[str], *, dimensions: int | None = None) -> Any: + import republic + + return republic.EmbeddingResponse([[float(len(text))] for text in texts], model="embed-1") + + async def decide(self, state: Any, *, questions: dict[str, Any]) -> Any: + from republic.decisions import DecisionResponse, NoulAnswer + + self.calls.append((self.spec, self.kwargs, state, questions)) + return DecisionResponse({key: NoulAnswer(0.9) for key in questions}) + + +@pytest.mark.asyncio +async def test_run_code_republic_models_are_served_by_bub_settings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import republic + from republic.decisions import Noul + + from bub.builtin.codemode import republic_bridge + from bub.builtin.settings import AgentSettings + + _FakeChatModel.calls = [] + for getter in ("get_model", "get_embedding_model", "get_decision_model"): + monkeypatch.setattr(republic_bridge.republic, getter, lambda spec, **kwargs: _FakeChatModel(spec, kwargs)) + agent = SimpleNamespace(settings=AgentSettings(model="openai:gpt-x", api_key="sk-test")) + (tmp_path / "chart.png").write_bytes(PNG_BYTES) + code = ( + "import republic\n" + "from republic.decisions import Noul\n" + "model = republic.get_model()\n" + f"chart = republic.Image('image/png', data=open({str(tmp_path / 'chart.png')!r}, 'rb').read())\n" + "prompt = republic.user('Describe', chart)\n" + "reply = await model.chat([prompt], max_tokens=10)\n" + "print(reply.text, reply.finish_reason, reply.token_usage.output_tokens, reply.message.role)\n" + "emb = await republic.get_embedding_model('openai:embed').embed_many(['ab', 'abc'])\n" + "print(emb.vectors, emb.vector)\n" + "decision = await republic.get_decision_model('openai:judge').decide('x', questions={'ok': Noul('fine?')})\n" + "print(decision.ok.noul, decision.answers['ok'].type)\n" + ) + + output = await run_code.run(code=code, context=_context(tmp_path, [], _runtime_agent=agent, model="openai:gpt-y")) + + assert output == "a chart stop 2 assistant\n[[2.0], [3.0]] [2.0]\n0.9 noul\n" + (chat_spec, chat_kwargs, messages, options), (decide_spec, _, state, questions) = _FakeChatModel.calls + assert chat_spec == "openai:gpt-y" + assert chat_kwargs["api_key"] == "sk-test" + assert messages == [republic.user("Describe", republic.Image("image/png", data=PNG_BYTES))] + assert options == {"max_tokens": 10} + assert (decide_spec, state, questions) == ("openai:judge", "x", {"ok": Noul("fine?")}) + + +@pytest.mark.asyncio +async def test_run_code_republic_errors_raise_in_code(tmp_path: Path) -> None: + code = ( + "try:\n" + " await republic.get_model().chat('hi')\n" + "except Exception as exc:\n" + " print(type(exc).__name__, exc)\n" + ) + + output = await run_code.run(code=code, context=_context(tmp_path, [])) + + assert output == "BubError [invalid_input] Republic models need a running Bub agent.\n" + + +class _FakeStream: + closed: ClassVar[list[bool]] = [] + + def __init__(self, chunks: list[str]) -> None: + self.chunks = chunks + + async def __aenter__(self) -> _FakeStream: + return self + + async def __aexit__(self, *exc_info: object) -> None: + self.closed.append(True) + + async def __aiter__(self) -> Any: + import republic + from republic.events import Completed, TextDelta, UsageDelta + + for chunk in self.chunks: + if chunk == "boom": + raise RuntimeError("connection lost") + yield TextDelta(chunk) + await asyncio.sleep(0) + yield UsageDelta(republic.TokenUsage(output_tokens=len(self.chunks))) + message = republic.assistant("".join(self.chunks)) + yield Completed(republic.Response(message, token_usage=republic.TokenUsage(output_tokens=len(self.chunks)))) + + +class _FakeStreamingModel: + async def __aenter__(self) -> _FakeStreamingModel: + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + def stream(self, messages: Any, **options: Any) -> _FakeStream: + return _FakeStream(messages[-1].text.split()) + + +def _patch_streaming_model(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + from bub.builtin.codemode import republic_bridge + from bub.builtin.settings import AgentSettings + + _FakeStream.closed = [] + monkeypatch.setattr(republic_bridge.republic, "get_model", lambda spec, **kwargs: _FakeStreamingModel()) + return SimpleNamespace(settings=AgentSettings(model="openai:gpt-x", api_key="sk-test")) + + +@pytest.mark.asyncio +async def test_run_code_streams_republic_chat_events(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + agent = _patch_streaming_model(monkeypatch) + code = ( + "from republic.events import Completed, TextDelta, UsageDelta\n" + "async with republic.get_model().stream('a b c', max_tokens=5) as stream:\n" + " async for event in stream:\n" + " if isinstance(event, TextDelta):\n" + " print(event.chunk, end='|')\n" + " elif isinstance(event, UsageDelta):\n" + " print('usage', event.usage.output_tokens, end='|')\n" + " elif isinstance(event, Completed):\n" + " print('done', end='|')\n" + "print()\n" + "print(stream.text, stream.token_usage.output_tokens, stream.response.message.role)\n" + ) + + output = await run_code.run(code=code, context=_context(tmp_path, [], _runtime_agent=agent)) + + assert output == "a|b|c|usage 3|done|\nabc 3 assistant\n" + assert _FakeStream.closed == [True] + + +@pytest.mark.asyncio +async def test_run_code_stream_errors_raise_after_received_events( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + agent = _patch_streaming_model(monkeypatch) + code = ( + "try:\n" + " async with republic.get_model().stream('a boom') as stream:\n" + " async for event in stream:\n" + " print(event.chunk)\n" + "except Exception as exc:\n" + " print(type(exc).__name__, exc)\n" + ) + + output = await run_code.run(code=code, context=_context(tmp_path, [], _runtime_agent=agent)) + + assert output == "a\nBubError [tool] republic.stream_next failed: RuntimeError: connection lost\n" + assert _FakeStream.closed == [True] + + +@pytest.mark.asyncio +async def test_run_code_closes_streams_the_code_left_open(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + agent = _patch_streaming_model(monkeypatch) + code = "stream = await republic.get_model().stream('a b').__aenter__()\nprint('opened')\n" + + output = await run_code.run(code=code, context=_context(tmp_path, [], _runtime_agent=agent)) + + assert output == "opened\n" + assert _FakeStream.closed == [True] diff --git a/tests/test_environment.py b/tests/test_environment.py index 127226aa..451c6ea9 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -167,3 +167,18 @@ async def test_framework_has_no_environment_without_a_provider(tmp_path: Path) - assert await framework.get_environment("session") is None assert ENVIRONMENT_STATE_KEY not in state + + +@pytest.mark.asyncio +async def test_default_read_bytes_reads_files_through_the_environment_python(tmp_path: Path) -> None: + class SpawnOnlyEnvironment(RecordingEnvironment): + async def read_bytes(self, path: str) -> bytes: + return await Environment.read_bytes(self, path) + + environment = SpawnOnlyEnvironment(tmp_path) + (tmp_path / "blob.bin").write_bytes(b"\x00\xffbinary") + + assert await environment.read_bytes(str(tmp_path / "blob.bin")) == b"\x00\xffbinary" + assert len(environment.spawned) == 1 + with pytest.raises(OSError, match="FileNotFoundError"): + await environment.read_bytes(str(tmp_path / "missing.bin")) diff --git a/tests/test_spill.py b/tests/test_spill.py index 6cf9696a..7669c3b9 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -424,3 +424,23 @@ async def test_result_spill_without_tool_availability_keeps_full_output(tmp_path assert execution.tool_results == [output] assert list(await root.store.fetch_all(root.scoped(root.sidecar_tape_name(SPILL_SIDECAR_NAME)).query())) == [] + + +@pytest.mark.asyncio +async def test_spill_bounds_text_of_content_results_and_keeps_media(tmp_path: Path) -> None: + import republic + + from bub.tools import content_result, result_content + + image = republic.Image("image/png", data=b"png") + root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) + + async with root.fork_tape() as tape: + execution = await _spill_executor().execute_async( + [(Tool(name="run_code", handler=lambda: content_result(["x" * 20_000, image])), {})], + context=_spill_context(tape=tape, run_id="run-1"), + ) + + text, media = result_content(execution.tool_results[0]) + assert "tool output spilled" in text + assert media == image diff --git a/website/src/content/docs/docs/build/tools.mdx b/website/src/content/docs/docs/build/tools.mdx index 3d0c5cbb..891c7de4 100644 --- a/website/src/content/docs/docs/build/tools.mdx +++ b/website/src/content/docs/docs/build/tools.mdx @@ -59,6 +59,8 @@ Code mode lets the model call tools from Python instead of one tool call at a ti - Every other allowed tool is an async function inside `run_code`, named after its model-facing name (`tape.info` becomes `await tools.tape_info()`). Top-level `await` is allowed and `asyncio.gather` runs calls concurrently. Calls take keyword arguments, go through the usual `before_tool_call`/`after_tool_call` hooks, return structured results, and raise on failure. - Bub writes a Python stub with one function per allowed `auto` or `code` tool — parameter and return types plus docstring — under `~/.bub/codemode/` and puts its path in the system prompt. The path stays the same for a session as long as its tool set does not change. - `run_code(code, timeout_seconds=120)` returns what the code writes to stdout. An uncaught exception becomes a tool error whose details carry the printed output and the traceback. +- The code also gets a `republic` module (preset as a global and importable), documented at the end of the stub. It has the media classes `republic.Image`, `Audio` and `Video`. `republic.get_model(spec=None)` returns a chat model — without a spec, the model running the session — with `chat()` and `stream()` (`async with model.stream(...) as stream: async for event in stream`, events from `republic.events`), and `republic.get_embedding_model(spec)` and `republic.get_decision_model(spec)` return embedding and decision models. These are proxies: requests are made by Bub with its provider settings and credentials, which never reach the code. +- The code-only tools `tools.attach_image(image=...)`, `tools.attach_audio(audio=...)` and `tools.attach_video(video=...)` attach media to the `run_code` result, so the model can look at a chart or screenshot the code produced. Besides a `republic` media object, they accept a file path in the environment (relative to its workspace), an http(s)/gs URL or a data URL, and load it themselves, reading files with the environment's `read_bytes`. A result with media is recorded as `{"type": "tool_content", "content": [...]}` (build one with `bub.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. @@ -90,9 +92,9 @@ Mark a tool `deferred=True` (on `@tool`, `Tool.from_callable`, or with `dataclas `bash`, `bash.output`, `bash.kill` and `fs.*` do not touch the host directly: they run on the session's `Environment` (from [`bub.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/environment.py)), which spawns processes, reads and writes text files, and runs code for `run_code`. Bub keeps the tool behavior — background shells, timeouts, rendering, hooks — on the host, so an environment only needs to implement a few operations: - `spawn(command, *, cwd=None, env=None)` returns a `Process`: a string runs through the shell, a sequence runs as an argument vector. The process exposes `stdout`/`stderr` streams, `write_stdin`, `wait`, and `signal(kill=...)`, which must reach the whole process tree. -- `read_text(path)` and `write_text(path, content)` access files inside the environment. +- `read_text(path)` and `write_text(path, content)` access files inside the environment. `read_bytes(path)` reads binary files; its default runs the environment's `python` to print the file, so override it when the environment has direct file access. - `workspace` is the working directory inside the environment; `resolve_path` resolves relative paths against it. -- `run_code(code, *, tools, call_tool, write)` runs Python code in which `await tools.(**kwargs)` calls `call_tool(name, kwargs)`. It passes the code's stdout to `write` as it arrives, raises `CodeFailed` when the code raises, and stops the code when cancelled. How it runs is up to the environment. An environment that has a Python interpreter can reuse `bub.builtin.codemode.code_runner.run_code_in_subprocess`, which runs the code in a process started with `spawn`. +- `run_code(code, *, tools, call_tool, write)` runs Python code in which `await tools.(**kwargs)` calls `call_tool(name, kwargs)`. The `republic` module calls `call_tool` with names prefixed `republic.`, and Republic values travel as JSON objects with a `"$republic"` key; the subprocess runner provides it. It passes the code's stdout to `write` as it arrives, raises `CodeFailed` when the code raises, and stops the code when cancelled. How it runs is up to the environment. An environment that has a Python interpreter can reuse `bub.builtin.codemode.code_runner.run_code_in_subprocess`, which runs the code in a process started with `spawn`. - `close()` releases the environment. Provide one per session with the `provide_environment(session_id, workspace)` hook. Bub calls it on the session's first turn, puts the result in `state["_runtime_environment"]`, and closes it when `framework.running()` exits. Bub's builtin hooks provide `LocalEnvironment` (in [`bub.builtin.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/environment.py)), which runs everything on the host; a plugin's implementation takes precedence over it. Your own tools can use the same environment through `bub.builtin.environment.environment_from_state(context.state)`. diff --git a/website/src/content/docs/zh-cn/docs/build/tools.mdx b/website/src/content/docs/zh-cn/docs/build/tools.mdx index d13cb53e..23c61ebf 100644 --- a/website/src/content/docs/zh-cn/docs/build/tools.mdx +++ b/website/src/content/docs/zh-cn/docs/build/tools.mdx @@ -59,6 +59,8 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 - 其他允许的工具在 `run_code` 中都是异步函数,名称使用面向模型的形式(`tape.info` 对应 `await tools.tape_info()`)。支持顶层 `await`,可以用 `asyncio.gather` 并发调用。调用只接受关键字参数,照常经过 `before_tool_call`/`after_tool_call` hook,返回结构化结果,失败时抛出异常。 - Bub 会在 `~/.bub/codemode/` 下为允许的 `auto` 和 `code` 工具生成一个 Python stub 文件,每个工具对应一个函数,包含参数类型、返回类型和 docstring,并把文件路径写入 system prompt。只要工具集合不变,同一个 session 中这个路径保持不变。 - `run_code(code, timeout_seconds=120)` 返回代码写到 stdout 的内容。未捕获的异常会变成工具错误,错误详情中包含已打印的输出和 traceback。 +- 代码里还有一个 `republic` 模块(已作为全局变量预置,也可以 `import`),其用法写在 stub 文件末尾。其中有媒体类 `republic.Image`、`Audio` 和 `Video`。`republic.get_model(spec=None)` 返回对话模型——不传 spec 时就是当前会话使用的模型——支持 `chat()` 和 `stream()`(`async with model.stream(...) as stream: async for event in stream`,事件类型在 `republic.events` 中);`republic.get_embedding_model(spec)` 和 `republic.get_decision_model(spec)` 分别返回 embedding 模型和决策模型。它们都是代理:请求由 Bub 使用自己的 provider 配置和凭证发出,凭证不会进入代码。 +- 仅代码可用的工具 `tools.attach_image(image=...)`、`tools.attach_audio(audio=...)` 和 `tools.attach_video(video=...)` 会把媒体附加到这次 `run_code` 的结果里,模型因此可以看到代码生成的图表或截图。除了 `republic` 媒体对象,它们也接受执行环境内的文件路径(相对路径基于其 workspace)、http(s)/gs URL 或 data URL,并自行加载,文件通过执行环境的 `read_bytes` 读取。带媒体的结果以 `{"type": "tool_content", "content": [...]}` 记录(可用 `bub.tools.content_result` 构造),并作为多模态工具消息发给模型;spill 只截断其中的文本。 如果 `run_code` 不在允许范围内(例如被 `allowed_tools` 限制的 subagent),该 turn 会退回直接调用工具的方式。 @@ -91,9 +93,9 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 `bash`、`bash.output`、`bash.kill` 和 `fs.*` 不直接操作宿主机,而是在当前 session 的 `Environment`(见 [`bub.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/environment.py))里启动进程、读写文本文件,`run_code` 的代码也在这里执行。工具本身的行为(后台 shell、超时、渲染、hook)都留在宿主上,所以一个执行环境只需要实现几个操作: - `spawn(command, *, cwd=None, env=None)` 返回一个 `Process`:传字符串时经 shell 执行,传序列时作为参数列表直接执行。进程提供 `stdout`/`stderr` 流、`write_stdin`、`wait` 和 `signal(kill=...)`,其中 `signal` 必须作用到整个进程树。 -- `read_text(path)` 和 `write_text(path, content)` 读写执行环境内的文件。 +- `read_text(path)` 和 `write_text(path, content)` 读写执行环境内的文件。`read_bytes(path)` 读取二进制文件;默认实现会用执行环境的 `python` 输出文件内容,能直接访问文件的执行环境应覆盖它。 - `workspace` 是执行环境内的工作目录,`resolve_path` 基于它解析相对路径。 -- `run_code(code, *, tools, call_tool, write)` 执行 Python 代码,代码里的 `await tools.(**kwargs)` 会调用 `call_tool(name, kwargs)`。代码写到 stdout 的内容要实时交给 `write`;代码抛异常时抛出 `CodeFailed`;被取消时要停止代码。具体怎么执行由执行环境决定。有 Python 解释器的执行环境可以直接复用 `bub.builtin.codemode.code_runner.run_code_in_subprocess`,它会用 `spawn` 启动进程来执行代码。 +- `run_code(code, *, tools, call_tool, write)` 执行 Python 代码,代码里的 `await tools.(**kwargs)` 会调用 `call_tool(name, kwargs)`。`republic` 模块会以 `republic.` 前缀的名字调用 `call_tool`,Republic 值以带 `"$republic"` 键的 JSON 对象传输;子进程 runner 已经提供了这个模块。代码写到 stdout 的内容要实时交给 `write`;代码抛异常时抛出 `CodeFailed`;被取消时要停止代码。具体怎么执行由执行环境决定。有 Python 解释器的执行环境可以直接复用 `bub.builtin.codemode.code_runner.run_code_in_subprocess`,它会用 `spawn` 启动进程来执行代码。 - `close()` 释放执行环境。 通过 `provide_environment(session_id, workspace)` hook 为每个 session 提供执行环境。Bub 在 session 的第一个 turn 调用它,把结果放进 `state["_runtime_environment"]`,并在 `framework.running()` 退出时关闭。Bub 的 builtin hook 提供 `LocalEnvironment`(见 [`bub.builtin.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/environment.py)),直接在宿主上运行;插件提供的实现会优先于它。自定义工具可以用 `bub.builtin.environment.environment_from_state(context.state)` 使用同一个环境。 From f2416ecd8ed51574c4fe0f567c565ea2cd8246fd Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 14 Oct 2026 08:57:32 +0800 Subject: [PATCH 10/16] refactor!: extract an event-driven core agent into bub.core (#356) --- AGENTS.md | 3 +- src/bub/__init__.py | 2 +- src/bub/builtin/__init__.py | 7 +- src/bub/builtin/agent.py | 664 ++++------------ src/bub/builtin/archive.py | 85 +++ src/bub/builtin/codemode/__init__.py | 12 +- src/bub/builtin/codemode/republic_bridge.py | 2 +- src/bub/builtin/commands.py | 20 - src/bub/builtin/hook_impl.py | 45 +- src/bub/builtin/model.py | 150 ++++ src/bub/builtin/model_runner.py | 396 ---------- src/bub/builtin/settings.py | 51 +- src/bub/builtin/spill.py | 4 +- src/bub/builtin/tools.py | 14 +- src/bub/channels/cli/__init__.py | 67 +- src/bub/channels/cli/renderer.py | 19 +- src/bub/channels/manager.py | 2 +- src/bub/channels/telegram.py | 2 +- src/bub/core/__init__.py | 72 ++ src/bub/core/agent.py | 717 ++++++++++++++++++ src/bub/core/commands.py | 64 ++ src/bub/{builtin => core}/context.py | 6 +- src/bub/core/events.py | 306 ++++++++ src/bub/core/model.py | 83 ++ src/bub/core/recorder.py | 76 ++ src/bub/core/sidecars.py | 20 + src/bub/core/store.py | 560 ++++++++++++++ src/bub/core/system_prompt.py | 91 +++ src/bub/core/tape.py | 521 +++++++++++++ src/bub/core/tools.py | 633 ++++++++++++++++ src/bub/framework.py | 12 +- src/bub/hooks/interception.py | 157 ++-- src/bub/hooks/specs.py | 12 +- src/bub/prompt.py | 2 +- src/bub/sidecars.py | 22 +- src/bub/store.py | 562 +------------- src/bub/tape.py | 617 +-------------- src/bub/tools.py | 680 +---------------- src/bub/types.py | 16 - src/bub/utils.py | 2 +- tests/model_fakes.py | 37 + tests/test_agent_hooks.py | 45 +- tests/test_builtin_agent.py | 221 ++---- tests/test_builtin_cli.py | 2 +- tests/test_builtin_codex.py | 21 +- tests/test_builtin_hook_impl.py | 23 +- tests/test_builtin_tools.py | 4 +- tests/test_channels.py | 49 +- tests/test_cli_renderer.py | 7 +- tests/test_codemode.py | 51 +- tests/test_core_agent.py | 338 +++++++++ ...odel_runner.py => test_core_agent_step.py} | 61 +- tests/test_environment.py | 2 +- tests/test_provider_messages.py | 43 +- tests/test_sdk_isolation.py | 112 ++- tests/test_session_settings.py | 2 +- tests/test_settings.py | 22 +- tests/test_spill.py | 70 +- tests/test_tape.py | 17 +- tests/test_tool_loading.py | 18 +- tests/test_tools.py | 137 ++-- tests/test_tracing.py | 77 +- tests/test_types.py | 20 - website/src/content/docs/docs/build/sdk.md | 433 +++++++---- website/src/content/docs/docs/build/tools.mdx | 2 +- .../docs/docs/concepts/tape-and-context.mdx | 2 +- .../docs/docs/concepts/turn-pipeline.mdx | 6 +- .../src/content/docs/docs/reference/hooks.mdx | 14 +- .../src/content/docs/docs/reference/types.mdx | 4 +- .../content/docs/docs/tutorials/fastapi.mdx | 126 +++ .../src/content/docs/docs/tutorials/index.mdx | 1 + .../src/content/docs/zh-cn/docs/build/sdk.md | 428 +++++++---- .../content/docs/zh-cn/docs/build/tools.mdx | 2 +- .../zh-cn/docs/concepts/tape-and-context.mdx | 2 +- .../zh-cn/docs/concepts/turn-pipeline.mdx | 6 +- .../docs/zh-cn/docs/reference/hooks.mdx | 14 +- .../docs/zh-cn/docs/reference/types.mdx | 4 +- .../docs/zh-cn/docs/tutorials/fastapi.mdx | 124 +++ .../docs/zh-cn/docs/tutorials/index.mdx | 1 + 79 files changed, 5410 insertions(+), 3914 deletions(-) create mode 100644 src/bub/builtin/archive.py delete mode 100644 src/bub/builtin/commands.py create mode 100644 src/bub/builtin/model.py delete mode 100644 src/bub/builtin/model_runner.py create mode 100644 src/bub/core/__init__.py create mode 100644 src/bub/core/agent.py create mode 100644 src/bub/core/commands.py rename src/bub/{builtin => core}/context.py (91%) create mode 100644 src/bub/core/events.py create mode 100644 src/bub/core/model.py create mode 100644 src/bub/core/recorder.py create mode 100644 src/bub/core/sidecars.py create mode 100644 src/bub/core/store.py create mode 100644 src/bub/core/system_prompt.py create mode 100644 src/bub/core/tape.py create mode 100644 src/bub/core/tools.py delete mode 100644 src/bub/types.py create mode 100644 tests/test_core_agent.py rename tests/{test_builtin_model_runner.py => test_core_agent_step.py} (85%) delete mode 100644 tests/test_types.py create mode 100644 website/src/content/docs/docs/tutorials/fastapi.mdx create mode 100644 website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx diff --git a/AGENTS.md b/AGENTS.md index eeb9f18c..32bb991f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +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/framework.py`: turn orchestration and outbound routing. - `src/bub/hooks/`: hook specifications, execution helpers, and interception contracts. - `src/bub/channels/`: channel bases, routing contracts, admission policy, and lifecycle manager. - `src/bub/{envelope,turn,streaming,errors,model_selection}.py`: small kernel vocabulary owned by each concern. -- `src/bub/builtin/`: builtin runtime, CLI wiring, settings, tools, and tape services. +- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.core.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, and tape services. - `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 0edf7945..28666a72 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -10,9 +10,9 @@ from typing import TYPE_CHECKING 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 -from bub.tools import tool __all__ = [ "BubFramework", diff --git a/src/bub/builtin/__init__.py b/src/bub/builtin/__init__.py index a6df24ad..6ca4c1ce 100644 --- a/src/bub/builtin/__init__.py +++ b/src/bub/builtin/__init__.py @@ -1,3 +1,6 @@ -from .agent import Agent +from .agent import BubAgent -__all__ = ["Agent"] +Agent = BubAgent +"""Deprecated alias of :class:`BubAgent`.""" + +__all__ = ["Agent", "BubAgent"] diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 14a904cb..8f6f6cd7 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -1,47 +1,43 @@ -"""Runtime engine to process prompts with Republic.""" +"""Bub's agent: the core agent configured by settings and the framework's hooks.""" from __future__ import annotations import asyncio -import inspect import re -import shlex -import time -from collections.abc import AsyncGenerator, AsyncIterator, Collection, Iterable -from contextlib import AsyncExitStack, aclosing -from dataclasses import dataclass, replace -from datetime import UTC, datetime -from functools import cached_property +from collections.abc import Collection from pathlib import Path -from typing import Any -import republic -from loguru import logger - -from bub.builtin.commands import strip_command_prefix, validate_command_prefix -from bub.builtin.model_runner import ( - ModelRunner, - is_context_length_error, -) +import bub.core as core from bub.builtin.settings import load_settings +from bub.core.store import AsyncTapeStore, TapeStore +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import REGISTRY, Tool, model_tools from bub.envelope import field_of from bub.framework import BubFramework from bub.prompt import LegacyPrompt, UserContent, prompt_text, to_content from bub.skills import discover_skills, render_skills_prompt -from bub.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.tape import Tape -from bub.tools import REGISTRY, Tool, ToolContext, model_tools -from bub.tracing import Span, current_span +from bub.streaming import AsyncStreamEvents, StreamState from bub.turn import TurnState from bub.utils import workspace_from_state HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") -MAX_AUTO_HANDOFF_RETRIES = 1 +ALLOWED_SKILLS_STATE_KEY = "allowed_skills" + + +class BubAgent(core.Agent): + """Bub's agent: the core loop configured by settings and the framework's hooks. + + Adds commands, skills, code mode, deferred tools, steering and the + ``continue_prompt`` hook to :class:`bub.core.Agent`. System prompt sections, in + order: the framework's ``system_prompt`` hooks, code mode, deferred tools, skills, + then sections added with ``system_prompt.add``. The framework's interception + hooks handle events before handlers added with ``events.on``. + One instance serves any number of sessions: per-turn choices (model, allowed tools + and skills) live in the turn state. + """ -class Agent: - """Agent that processes prompts using hooks, tools, tape, and Republic.""" + command_prefix: str def __init__( self, @@ -52,15 +48,16 @@ def __init__( skill_dirs: Collection[Path] | None = None, command_prefix: str | None = None, ) -> None: - """Create a builtin agent with instance-specific tools, skills, and storage. + """Create a Bub agent with instance-specific tools, skills, and storage. Args: framework: Configured hook runtime supplying prompts, tape context, interception hooks, and optional shared resources. tools: Tools available to this instance. None snapshots the global registry; an empty collection disables tools. - tape_store: Explicit store, preferred over the framework's active - store. Without either, the agent uses an in-memory store. + tape_store: Explicit store, preferred over the framework's active store. + Create the agent inside ``framework.running()`` to use the latter. + Without either, the agent uses an in-memory store. skill_dirs: Skill roots in precedence order. None uses project, user, and builtin discovery; an empty collection disables discovery. command_prefix: Non-empty prefix without whitespace. None uses the @@ -70,48 +67,19 @@ def __init__( lifecycle of an explicitly supplied store. """ self.settings = load_settings() - self.command_prefix = validate_command_prefix( - self.settings.command_prefix if command_prefix is None else command_prefix - ) self.framework = framework - self.tools = {tool.name: tool for tool in tools} if tools is not None else REGISTRY.copy() - self.tape_store = tape_store self.skill_dirs = skill_dirs - self.model_runner = ModelRunner(self.settings, hooks=framework.get_agent_hooks()) - - @cached_property - def tape(self) -> Tape: - """Return the lazily constructed, cached tape factory for this agent. - - Select the explicit store, active framework store, or an in-memory fallback, - in that order. Adapt synchronous stores and use hook-provided context and - sidecars. Archive files use ``bub.home / 'tapes'`` independently of the store. - """ - import bub - - tape_store: TapeStore | AsyncTapeStore | None - if self.tape_store is not None: - tape_store = self.tape_store - else: - tape_store = self.framework.get_tape_store() - if tape_store is None: - tape_store = InMemoryTapeStore() - if not is_async_tape_store(tape_store): - tape_store = AsyncTapeStoreAdapter(tape_store) - return Tape( - bub.home / "tapes", - tape_store, - self.framework.build_tape_context(), - sidecars=self.framework.get_tape_sidecars(), + super().__init__( + store=framework.get_tape_store() if tape_store is None else tape_store, + tools=REGISTRY.values() if tools is None else tools, + system_prompt=[self._hook_prompt, self._code_mode_prompt, self._deferred_tools_prompt, self._skills_prompt], + context=framework.build_tape_context(), + sidecars=framework.get_tape_sidecars(), + max_steps=self.settings.max_steps, + name="bub", + command_prefix=self.settings.command_prefix if command_prefix is None else command_prefix, ) - - @staticmethod - def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents: - async def generator() -> AsyncIterator: - for item in iterable: - yield item - - return AsyncStreamEvents(generator()) + framework.get_agent_hooks().subscribe(self.events) async def run_stream( self, @@ -143,481 +111,155 @@ async def run_stream( Command execution uses the instance's tools directly. reasoning_effort: Per-turn override of the value in state. - Consume the stream to completion to finish execution and tape merging. - A ``final`` event ends a model step, not necessarily the whole turn. - The returned object exposes ``error`` and ``usage``; execution can also - raise exceptions. This method does not render or dispatch outbound messages, - call save-state hooks, or serialize concurrent turns in the same session. + Loads the turn state and starts the turn with :meth:`start` on the session's tape. + Consume the stream to completion to finish execution and tape merging. A + ``final`` event ends a model step, not necessarily the whole turn. The returned + object exposes ``error`` and ``usage``; execution can also raise exceptions. This + method does not render or dispatch outbound messages, call save-state hooks, or + serialize concurrent turns in the same session. """ - span = Span( - "invoke_agent bub", - { - "gen_ai.operation.name": "invoke_agent", - "gen_ai.agent.name": "bub", - "gen_ai.conversation.id": session_id, - }, - ) - stack = AsyncExitStack() - try: - with span.activate(): - content = to_content(prompt) - span.messages("gen_ai.input.messages", [republic.user(*content)]) - if not any(content): - events = self._events_from_iterable([ - StreamEvent("text", {"delta": "error: empty prompt"}), - StreamEvent("final", {"text": "error: empty prompt", "ok": False}), - ]) - else: - if state is None: - state = await self.framework.build_state({"_runtime_agent": self}, session_id) - state["_runtime_agent"] = self - if model is None: - model = state.get("model") - if reasoning_effort is not None: - state["reasoning_effort"] = reasoning_effort - state.setdefault("session_id", session_id) - tape = self.tape.session_tape( - session_id, workspace_from_state(state), context=replace(self.tape.context, state=state) - ) - # Keep the tape fork open until the stream closes, even if it is never consumed. - tape = await stack.enter_async_context( - tape.fork_tape(merge_back=not session_id.startswith("temp/")) - ) - await tape.ensure_bootstrap_anchor() - command = ( - strip_command_prefix(content[0], self.command_prefix) - if len(content) == 1 and isinstance(content[0], str) - else None - ) - if command is not None: - result = await self._run_command(tape=tape, line=command) - events = self._events_from_iterable([ - StreamEvent("text", {"delta": result}), - StreamEvent("final", {"text": result, "ok": True}), - ]) - else: - events = await self._agent_loop( - tape=tape, - prompt=content, - model=model, - allowed_skills=allowed_skills, - allowed_tools=allowed_tools, - ) - except BaseException as exc: - span.fail(exc) - try: - with span.activate(): - await stack.aclose() - finally: - span.end() - raise - return AsyncStreamEvents( - self._trace_events(events, span), - state=events._state, - on_close=stack.aclose, - span=span, - ) - - @staticmethod - async def _trace_events(events: AsyncStreamEvents, span: Span) -> AsyncGenerator[StreamEvent, None]: - messages: list[dict[str, Any]] = [] - text: list[str] = [] - calls: list[dict[str, Any]] = [] - try: - async with aclosing(events): - async for event in events: - if span.recording: - if event.kind == "text": - text.append(str(event.data.get("delta", ""))) - elif event.kind == "tool_call": - calls = event.data.get("tool_calls", []) - messages.append({"role": "assistant", "content": "".join(text), "tool_calls": calls}) - text.clear() - elif event.kind == "tool_result": - for call, result in zip(calls, event.data.get("tool_results", []), strict=False): - messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) - elif event.kind == "error": - span.fail(RuntimeError(str(event.data.get("message", "agent failed")))) - yield event - finally: - if text: - messages.append({"role": "assistant", "content": "".join(text)}) - span.messages("gen_ai.output.messages", messages) - - async def _run_command(self, tape: Tape, *, line: str) -> str: - if not line: - raise ValueError("empty command") - - name, arg_tokens = _parse_internal_command(line) - start = time.monotonic() - context = ToolContext(tape=tape, run_id="run_command", state=tape.context.state) - output = "" - status = "ok" - try: - if name not in self.tools: - if "bash" not in self.tools: - raise ValueError("bash tool is not available") # noqa: TRY301 - bash_tool = self.tools["bash"] - output = bash_tool.render(await bash_tool.run(context=context, command=line)) - else: - command_tool = self.tools[name] - args = _parse_args(arg_tokens) - if command_tool.context: - args.kwargs["context"] = context - result = command_tool.run(*args.positional, **args.kwargs) - if inspect.isawaitable(result): - result = await result - output = command_tool.render(result) - except Exception as exc: - status = "error" - output = f"{exc!s}" - raise - else: - return output - finally: - elapsed_ms = int((time.monotonic() - start) * 1000) - - event_payload = { - "raw": line, - "name": name, - "status": status, - "elapsed_ms": elapsed_ms, - "output": output, - "date": datetime.now(UTC).isoformat(), - } - await tape.append_event("command", event_payload) - - async def _agent_loop( - self, - *, - tape: Tape, - prompt: list[UserContent], - model: str | None = None, - allowed_skills: Collection[str] | None = None, - allowed_tools: Collection[str] | None = None, - ) -> AsyncStreamEvents: - next_prompt = prompt - display_model = model or self.settings.model - await tape.append_event( - "loop.start", - { - "model": display_model, - "prompt": _prompt_payload(prompt), + content = to_content(prompt) + if state is None: + state = await self.framework.build_state({"_runtime_agent": self}, session_id) + state["_runtime_agent"] = self + if reasoning_effort is not None: + state["reasoning_effort"] = reasoning_effort + state.setdefault("session_id", session_id) + metadata = None + if self.command_line(content) is None: + self._scope_turn(state, allowed_skills=allowed_skills, allowed_tools=allowed_tools) + metadata = { "allowed_skills": list(allowed_skills) if allowed_skills else None, "allowed_tools": list(allowed_tools) if allowed_tools else None, - }, - ) - state = StreamState() - iterator = self._stream_events_with_auto_handoff( - tape=tape, - prompt=next_prompt, + } + return await self.start( + content, + # The turn's model and reasoning effort apply until the next turn. + model=self.settings.chat_model( + state.get("model") if model is None else model, reasoning_effort=state.get("reasoning_effort") + ), + tape=session_tape_name(session_id, workspace_from_state(state)), state=state, - model=model, - allowed_skills=allowed_skills, - allowed_tools=allowed_tools, - ) - return AsyncStreamEvents(iterator, state=state) - - async def _stream_events_with_auto_handoff( - self, - tape: Tape, - prompt: list[UserContent], - state: StreamState, - model: str | None = None, - allowed_skills: Collection[str] | None = None, - allowed_tools: Collection[str] | None = None, - ) -> AsyncGenerator[StreamEvent, None]: - auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES - display_model = model or self.settings.model - next_prompt: list[UserContent] | None = prompt - for step in range(1, self.settings.max_steps + 1): - start = time.monotonic() - should_continue = False - logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model) - await tape.append_event( - "loop.step.start", - {"step": step, "prompt": None if next_prompt is None else _prompt_payload(next_prompt)}, - ) - try: - output = await self._run_once( - tape=tape, - prompt=next_prompt, - model=model, - allowed_skills=allowed_skills, - allowed_tools=allowed_tools, - ) - async with aclosing(output): - async for event in output: - yield event - if event.kind == "error": - elapsed_ms = int((time.monotonic() - start) * 1000) - await tape.append_event( - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "error", - "error": event.data.get("message", ""), - "date": datetime.now(UTC).isoformat(), - }, - ) - elif event.kind == "final": - should_continue = bool(event.data.get("tool_calls") or event.data.get("tool_results")) - except Exception as exc: - error_message = f"{exc!s}" - elapsed_ms = int((time.monotonic() - start) * 1000) - if auto_handoff_remaining > 0 and is_context_length_error(error_message): - auto_handoff_remaining -= 1 - logger.warning( - "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}", - tape.name, - step, - ) - await tape.handoff( - name="auto_handoff/context_overflow", - state={"reason": "context_length_exceeded", "error": error_message}, - ) - await tape.append_event( - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "auto_handoff", - "error": error_message, - "date": datetime.now(UTC).isoformat(), - }, - ) - next_prompt = prompt - continue - - await tape.append_event( - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "error", - "error": error_message, - "date": datetime.now(UTC).isoformat(), - }, - ) - raise - - state.error = output.error - state.usage = output.usage - elapsed_ms = int((time.monotonic() - start) * 1000) - should_continue = should_continue or self._has_steering_messages(tape.context.state) - if not should_continue: - await tape.append_event( - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "ok", - "date": datetime.now(UTC).isoformat(), - }, - ) - return - - continuation = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state) - next_prompt = None if continuation is None else [continuation] - await tape.append_event( - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "continue", - "date": datetime.now(UTC).isoformat(), - }, - ) - - raise RuntimeError(f"max_steps_reached={self.settings.max_steps}") - - def _load_skills_prompt(self, prompt: str, workspace: Path, allowed_skills: set[str] | None = None) -> str: - skill_index = { - skill.name.casefold(): skill - for skill in discover_skills(workspace, skill_dirs=self.skill_dirs) - if allowed_skills is None or skill.name.casefold() in allowed_skills - } - expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys()) - return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills) - - async def _run_once( - self, - *, - tape: Tape, - prompt: list[UserContent] | None, - model: str | None = None, - allowed_tools: Collection[str] | None = None, - allowed_skills: Collection[str] | None = None, - ) -> AsyncStreamEvents: - if allowed_skills is not None: - allowed_skills = {name.casefold() for name in allowed_skills} - tape.context.state["allowed_skills"] = list(allowed_skills) - return await self._run_once_stream( - tape=tape, - prompt=prompt, - model=model, - allowed_skills=allowed_skills, - tools=self._allowed_tools(tape, allowed_tools), + merge_back=not session_id.startswith("temp/"), + metadata=metadata, ) - def _allowed_tools(self, tape: Tape, allowed_tools: Collection[str] | None) -> list[Tool]: - """Filter direct and deferred tools alike by ``allowed_tools`` and record the scope in state.""" + def _scope_turn( + self, state: TurnState, *, allowed_skills: Collection[str] | None, allowed_tools: Collection[str] | None + ) -> None: + """Record the turn's allowed skills and tools in state; unknown tool names raise ``ValueError``.""" from bub.builtin.tools import ALLOWED_TOOLS_STATE_KEY, resolve_tool_names - state = tape.context.state if allowed_tools is None: state.pop(ALLOWED_TOOLS_STATE_KEY, None) - return list(self.tools.values()) - allowed = resolve_tool_names(allowed_tools, all_names=self.tools) - state[ALLOWED_TOOLS_STATE_KEY] = sorted(allowed) - return [tool for name, tool in self.tools.items() if name in allowed] + else: + state[ALLOWED_TOOLS_STATE_KEY] = sorted(resolve_tool_names(allowed_tools, all_names=self.tools)) + if allowed_skills is None: + state.pop(ALLOWED_SKILLS_STATE_KEY, None) + else: + state[ALLOWED_SKILLS_STATE_KEY] = list({name.casefold() for name in allowed_skills}) - async def _prepare_deferred_tools(self, tools: list[Tool], tape: Tape) -> tuple[list[Tool], str]: - """Return direct tools followed by deferred tools loaded on the tape, plus the deferred tools prompt. + async def prepare_tools(self, tape: Tape, prompt: list[UserContent] | None) -> list[Tool]: + from bub.builtin.codemode import CODE_TOOLS_STATE_KEY + from bub.builtin.tools import TOOL_DESCRIBE_TOOL_NAME, loaded_tool_names, tool_describe - ``tool.describe`` is exposed exactly when a deferred tool remains, since it is the only way to load one. - """ - from bub.builtin.tools import ( - TOOL_DESCRIBE_TOOL_NAME, - loaded_tool_names, - render_deferred_tools_prompt, - tool_describe, - ) - - direct = [tool for tool in tools if not tool.deferred and tool.name != TOOL_DESCRIBE_TOOL_NAME] - deferred = {tool.name: tool for tool in tools if tool.deferred and tool.exposure in ("auto", "direct")} - if not deferred: - return direct, "" - direct.append(self.tools.get(TOOL_DESCRIBE_TOOL_NAME, tool_describe)) - loaded = [deferred[name] for name in await loaded_tool_names(tape) if name in deferred] - return [*direct, *loaded], render_deferred_tools_prompt(deferred.values()) - - async def _run_once_stream( - self, - *, - tape: Tape, - prompt: list[UserContent] | None, - model: str | None, - allowed_skills: set[str] | None, - tools: list[Tool], - ) -> AsyncStreamEvents: + state = tape.context.state # Code mode sees every allowed tool, deferred ones included; deferral applies to model-facing tools. - tools, code_mode_prompt = await self._prepare_code_mode(tools, tape) - tools, deferred_prompt = await self._prepare_deferred_tools(tools, tape) - system_prompt = self._system_prompt( - prompt or [], - state=tape.context.state, - allowed_skills=allowed_skills, - tools_prompt="\n\n".join(block for block in (code_mode_prompt, deferred_prompt) if block), - ) - resolved_model = model or self.settings.model + tools, code_tools = self._split_code_mode(state) + if code_tools is None: + state.pop(CODE_TOOLS_STATE_KEY, None) + else: + state[CODE_TOOLS_STATE_KEY] = model_tools(code_tools) + # ``tool.describe`` is exposed exactly when a deferred tool remains, since it is the only way to load one. + direct = [tool for tool in tools if not tool.deferred and tool.name != TOOL_DESCRIBE_TOOL_NAME] + deferred = {tool.name: tool for tool in _deferred_tools(tools)} + if deferred: + direct.append(self.tools.get(TOOL_DESCRIBE_TOOL_NAME, tool_describe)) + direct.extend(deferred[name] for name in await loaded_tool_names(tape) if name in deferred) + return model_tools(direct) - model_tools_for_call = model_tools(tools) - if (span := current_span()) and span.recording: - span.set(**{ - "gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in model_tools_for_call] - }) + async def pending_messages(self, tape: Tape) -> list[list[UserContent]]: + state = tape.context.state steering_inbox = self.framework.get_steering_inbox() - steering_envelopes = await steering_inbox.drain_messages(tape.context.state) if steering_inbox else [] - steering_messages: list[list[UserContent]] = list( + envelopes = await steering_inbox.drain_messages(state) if steering_inbox else [] + return list( await asyncio.gather(*[ - self.framework.build_prompt( - message, session_id=field_of(message, "session_id"), state=tape.context.state - ) - for message in steering_envelopes + self.framework.build_prompt(message, session_id=field_of(message, "session_id"), state=state) + for message in envelopes ]) ) - return self.model_runner.run( - tape=tape, - model=resolved_model, - tools=model_tools_for_call, - system_prompt=system_prompt, - prompt=prompt, - steering_messages=steering_messages, - ) - async def _prepare_code_mode(self, tools: list[Tool], tape: Tape) -> tuple[list[Tool], str]: - """Split tools for code mode and return model-facing tools and the stub prompt. + def has_pending_messages(self, tape: Tape) -> bool: + steering_inbox = self.framework.get_steering_inbox() + return bool(steering_inbox and steering_inbox.message_count(tape.context.state) > 0) + + async def continue_prompt( + self, tape: Tape, prompt: list[UserContent] | None, state: StreamState + ) -> list[UserContent] | None: + continuation = await self.framework.continue_prompt(prompt=prompt, tape=tape, state=state) + return None if continuation is None else [continuation] + + def _turn_tools(self, state: TurnState) -> list[Tool]: + """Return the tools allowed in this turn, direct and deferred alike.""" + from bub.builtin.tools import ALLOWED_TOOLS_STATE_KEY + + if (allowed := state.get(ALLOWED_TOOLS_STATE_KEY)) is None: + return list(self.tools.values()) + return [tool for name, tool in self.tools.items() if name in allowed] + + def _split_code_mode(self, state: TurnState) -> tuple[list[Tool], list[Tool] | None]: + """Return the tools left to the model and, in code mode, the tools callable only from code. Code mode is a session setting (``state["code_mode"]``, switched by the ``code_mode`` command) and applies only when ``run_code`` is among the allowed tools: the model then sees ``direct`` tools, and ``auto`` and ``code`` tools are callable only from code. Outside code mode, ``code`` tools are dropped. """ - from bub.builtin.codemode import ( - CODE_MODE_STATE_KEY, - CODE_TOOLS_STATE_KEY, - RUN_CODE_TOOL_NAME, - render_code_mode_prompt, - write_tool_stub, - ) + from bub.builtin.codemode import CODE_MODE_STATE_KEY, RUN_CODE_TOOL_NAME - state = tape.context.state + tools = self._turn_tools(state) direct_tools = [tool for tool in tools if tool.name != RUN_CODE_TOOL_NAME] if not state.get(CODE_MODE_STATE_KEY) or len(direct_tools) == len(tools): - state.pop(CODE_TOOLS_STATE_KEY, None) - return [tool for tool in direct_tools if tool.exposure != "code"], "" - + return [tool for tool in direct_tools if tool.exposure != "code"], None code_tools = [tool for tool in direct_tools if tool.exposure in ("auto", "code")] - state[CODE_TOOLS_STATE_KEY] = model_tools(code_tools) + return [tool for tool in tools if tool.exposure == "direct"], code_tools + + def _hook_prompt(self, context: core.PromptContext) -> str: + return self.framework.get_system_prompt(prompt=context.prompt, state=context.state) + + @staticmethod + def _code_mode_prompt(context: core.PromptContext) -> str | None: + from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, render_code_mode_prompt, write_tool_stub + + state = context.state + if (code_tools := state.get(CODE_TOOLS_STATE_KEY)) is None: + return None stub_path = write_tool_stub( code_tools, session_id=str(state.get("session_id", "")), workspace=workspace_from_state(state) ) - return [tool for tool in tools if tool.exposure == "direct"], render_code_mode_prompt(stub_path) + return render_code_mode_prompt(stub_path) - def _system_prompt( - self, - prompt: list[UserContent], - state: TurnState, - allowed_skills: set[str] | None = None, - tools_prompt: str = "", - ) -> str: - blocks: list[str] = [] - if result := self.framework.get_system_prompt(prompt=prompt, state=state): - blocks.append(result) - if tools_prompt: - blocks.append(tools_prompt) - workspace = workspace_from_state(state) - if skills_prompt := self._load_skills_prompt(prompt_text(prompt), workspace, allowed_skills): - blocks.append(skills_prompt) - return "\n\n".join(blocks) - - def _has_steering_messages(self, state: TurnState) -> bool: - steering_inbox = self.framework.get_steering_inbox() - return bool(steering_inbox and steering_inbox.message_count(state) > 0) - - -@dataclass(frozen=True) -class Args: - positional: list[str] - kwargs: dict[str, Any] - - -def _parse_internal_command(line: str) -> tuple[str, list[str]]: - body = line.strip() - words = shlex.split(body) - if not words: - return "", [] - return words[0], words[1:] - - -def _parse_args(args_tokens: list[str]) -> Args: - positional: list[str] = [] - kwargs: dict[str, str] = {} - first_kwarg = False - for token in args_tokens: - if "=" in token: - key, value = token.split("=", 1) - kwargs[key] = value - first_kwarg = True - elif first_kwarg: - raise ValueError(f"positional argument '{token}' cannot appear after keyword arguments") - else: - positional.append(token) - return Args(positional=positional, kwargs=kwargs) + def _deferred_tools_prompt(self, context: core.PromptContext) -> str: + from bub.builtin.tools import render_deferred_tools_prompt + + tools, _ = self._split_code_mode(context.state) + return render_deferred_tools_prompt(_deferred_tools(tools)) + + def _skills_prompt(self, context: core.PromptContext) -> str: + allowed = context.state.get(ALLOWED_SKILLS_STATE_KEY) + return self._load_skills_prompt( + prompt_text(context.prompt), workspace_from_state(context.state), None if allowed is None else set(allowed) + ) + + def _load_skills_prompt(self, prompt: str, workspace: Path, allowed_skills: set[str] | None = None) -> str: + skill_index = { + skill.name.casefold(): skill + for skill in discover_skills(workspace, skill_dirs=self.skill_dirs) + if allowed_skills is None or skill.name.casefold() in allowed_skills + } + expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys()) + return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills) -def _prompt_payload(content: list[UserContent]) -> object: - """Serialize prompt content for tape events.""" - return republic.user(*content).to_dict().get("content") +def _deferred_tools(tools: list[Tool]) -> list[Tool]: + return [tool for tool in tools if tool.deferred and tool.exposure in ("auto", "direct")] diff --git a/src/bub/builtin/archive.py b/src/bub/builtin/archive.py new file mode 100644 index 00000000..9e190514 --- /dev/null +++ b/src/bub/builtin/archive.py @@ -0,0 +1,85 @@ +"""Archive tapes to JSONL files before they are reset.""" + +from __future__ import annotations + +import json +from dataclasses import asdict +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from bub.core.sidecars import sidecar_tape_name +from bub.core.store import TapeQuery +from bub.core.tape import Tape + + +def archive_dir() -> Path: + """Bub's archive directory, ``bub.home / "tapes"``.""" + import bub + + return bub.home / "tapes" + + +def _stamp() -> str: + return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + + +async def _write_archive(tape: Tape, tape_name: str, directory: Path, stamp: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{tape_name}.jsonl.{stamp}.bak" + with path.open("w", encoding="utf-8") as f: + for entry in await tape.store.fetch_all(TapeQuery(tape=tape_name, store=tape.store)): + f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n") + return path + + +async def archive_tape(tape: Tape, directory: Path, *, stamp: str | None = None) -> Path: + """Write the tape's entries to ``{directory}/{tape name}.jsonl.{stamp}.bak`` and return the path.""" + return await _write_archive(tape, tape.name, directory, stamp or _stamp()) + + +async def archive_sidecar( + tape: Tape, name: str, directory: Path, *, reason: str = "manual", stamp: str | None = None +) -> tuple[Path | None, dict[str, Any]]: + """Archive one mounted sidecar of ``tape``; return the archive path and ``sidecar.archive`` event data. + + A failed archive returns no path and error data instead of raising. + """ + if tape.get_sidecar(name) is None: + raise KeyError(f"tape sidecar {name!r} is not mounted") + data: dict[str, Any] = {"sidecar": name, "reason": reason} + try: + path = await _write_archive(tape, sidecar_tape_name(tape.name, name), directory, stamp or _stamp()) + except Exception as exc: + return None, {**data, "status": "error", "error": str(exc)} + return path, {**data, "status": "ok", "archive": str(path)} + + +async def reset_tape(tape: Tape, *, archive_to: Path | None = None) -> str: + """Reset ``tape``, first archiving it and its sidecars to ``archive_to`` when given. + + A sidecar whose archive fails is kept rather than reset; archive outcomes are recorded + as ``sidecar.archive`` events after the reset. + """ + if archive_to is None: + await tape.reset() + return "ok" + stamp = _stamp() + path = await archive_tape(tape, archive_to, stamp=stamp) + archives = [ + await archive_sidecar(tape, sidecar.name, archive_to, reason="tape.reset", stamp=stamp) + for sidecar in tape.sidecars + ] + failed = [data["sidecar"] for archived, data in archives if archived is None] + await tape.reset(state={"archived": str(path)}, keep_sidecars=failed) + for _, data in archives: + await tape.append_event("sidecar.archive", data, context=False) + if data["status"] == "error": + skipped = { + "sidecar": data["sidecar"], + "status": "skipped", + "reason": "tape.reset", + "cause": "archive_failed", + } + await tape.append_event("sidecar.reset", skipped, context=False) + return f"Archived: {path}" diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py index ae028b38..0a366a67 100644 --- a/src/bub/builtin/codemode/__init__.py +++ b/src/bub/builtin/codemode/__init__.py @@ -19,11 +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.hooks.interception import AgentHooks 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" @@ -341,10 +340,8 @@ def _code_tool_caller( tools_by_name: dict[str, Tool], context: ToolContext, republic_session: RepublicSession ) -> CallTool: """Serve the code's ``tools.`` and ``republic`` calls.""" - agent = context.state.get("_runtime_agent") - hooks: AgentHooks | None = getattr(getattr(agent, "model_runner", None), "hooks", None) # Code consumes structured results, so this executor does not render them to text. - executor = ToolExecutor(hooks=hooks, render=False) + executor = ToolExecutor(context.events, render=False) code_context = replace(context, code_mode=True) async def call_tool(name: str, arguments: dict[str, Any]) -> Any: @@ -354,10 +351,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: tool_item = tools_by_name.get(name) if tool_item is None: raise BubError(ErrorKind.INVALID_INPUT, f"unknown tool: tools.{name}") - execution = await executor.execute_async([(tool_item, arguments)], context=code_context) - if execution.error is not None: - raise execution.error - return encode_value(execution.tool_results[0]) + return encode_value(await executor.execute(tool_item, arguments, context=code_context)) return call_tool 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/commands.py b/src/bub/builtin/commands.py deleted file mode 100644 index 1956554e..00000000 --- a/src/bub/builtin/commands.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Command prefix rules for the builtin agent and its channel adapters.""" - - -def validate_command_prefix(prefix: str) -> str: - if not prefix or any(char.isspace() for char in prefix): - raise ValueError("command_prefix must be non-empty and contain no whitespace") - return prefix - - -def strip_command_prefix(text: str, prefix: str) -> str | None: - """Return the command without its prefix, or None for ordinary text. - - Strip surrounding whitespace and exactly one prefix. A bare prefix returns - an empty string so execution can report an empty command. - Callers validate the prefix when configuring it. - """ - text = text.strip() - if text.startswith(prefix): - return text[len(prefix) :].strip() - return None diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 5f825542..77615b0d 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -10,9 +10,7 @@ from loguru import logger from bub import inquirer as bub_inquirer -from bub.builtin.agent import Agent -from bub.builtin.commands import strip_command_prefix -from bub.builtin.context import default_tape_context +from bub.builtin.agent import BubAgent from bub.builtin.onboarding import collect_model_config from bub.builtin.settings import load_session_settings, load_settings from bub.builtin.steering import InMemorySteeringInbox @@ -20,19 +18,21 @@ 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 from bub.framework import BubFramework from bub.hooks import hookimpl -from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult from bub.model_selection import ModelChoice, ModelOptions from bub.prompt import UserContent -from bub.sidecars import TapeSidecar -from bub.store import TapeStore from bub.streaming import AsyncStreamEvents -from bub.tape import TapeContext -from bub.tools import is_content_result, render_result from bub.turn import TurnState AGENTS_FILE_NAME = "AGENTS.md" @@ -66,13 +66,13 @@ def __init__(self, framework: BubFramework) -> None: from bub.builtin import codemode, spill, tools # noqa: F401 self.framework = framework - self._agent: Agent | None = None + self._agent: BubAgent | None = None - def _get_agent(self, state: TurnState | None = None) -> Agent: + def _get_agent(self, state: TurnState | None = None) -> BubAgent: if state and "_runtime_agent" in state: - return cast("Agent", state["_runtime_agent"]) + return cast("BubAgent", state["_runtime_agent"]) if self._agent is None: - self._agent = Agent(self.framework) + self._agent = BubAgent(self.framework) return self._agent @staticmethod @@ -119,7 +119,7 @@ async def load_state(self, message: ChannelMessage, session_id: str) -> TurnStat # Carry over per-session settings (model, reasoning effort, code mode) recorded # on the session tape. Only settings a prior turn actually recorded are set, so # a fresh/unknown session never inherits another session's choices. - state.update(await load_session_settings(agent.tape.session_tape(session_id, self.framework.workspace))) + state.update(await load_session_settings(agent.tape(session_tape_name(session_id, self.framework.workspace)))) if model := field_of(message, "context", {}).get("model"): state["model"] = model if thread_id := field_of(message, "context", {}).get("thread_id"): @@ -235,10 +235,10 @@ def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: from bub.channels.cli import CliChannel from bub.channels.telegram import TelegramChannel - agent = self._get_agent() + # Channels are created before framework.running(); the agent is created on first use. return [ - TelegramChannel(on_receive=message_handler, command_prefix=agent.command_prefix), - CliChannel(on_receive=message_handler, agent=agent), + TelegramChannel(on_receive=message_handler, command_prefix=load_settings().command_prefix), + CliChannel(on_receive=message_handler, get_agent=self._get_agent, workspace=self.framework.workspace), ] @hookimpl @@ -295,7 +295,7 @@ def provide_environment(self, session_id: str, workspace: Path) -> Environment: @hookimpl def provide_tape_store(self) -> TapeStore: import bub - from bub.store import FileTapeStore + from bub.core.store import FileTapeStore return FileTapeStore(directory=bub.home / "tapes") @@ -336,12 +336,14 @@ async def before_tool_call( When the model invokes a tool outside the current model-facing tool set, replace it with a guidance ``tool_result`` so the model can re-issue a - valid call on the next step. + valid call on the next step. Commands run the agent's tools directly and are left alone. """ from bub.builtin.codemode import CODE_TOOLS_STATE_KEY from bub.builtin.tools import TOOL_DESCRIBE_TOOL_NAME - from bub.tools import model_tools + from bub.core.tools import model_tools + if call.context is not None and call.context.command: + return None agent = self._get_agent(state) agent_tools = model_tools(agent.tools.values()) @@ -381,8 +383,9 @@ async def after_tool_call( from bub.builtin.spill import SPILL_READ_MODEL_NAME, SPILL_SIDECAR_NAME, SpillStore tape = state.get("_runtime_tape") - # Results of calls from run_code are structured values for the code, not model-facing text. - if tape is None or (call.context is not None and call.context.code_mode): + # Results of calls from run_code are structured values for the code, and results of + # commands go to the user; neither is model-facing text. + if tape is None or (call.context is not None and (call.context.code_mode or call.context.command)): return if SPILL_READ_MODEL_NAME not in state.get("_runtime_tool_names", ()): return diff --git a/src/bub/builtin/model.py b/src/bub/builtin/model.py new file mode 100644 index 00000000..23e837d4 --- /dev/null +++ b/src/bub/builtin/model.py @@ -0,0 +1,150 @@ +"""Bub's model client: fallbacks, custom providers and per-provider credentials from settings.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +from typing import Any + +import republic +from loguru import logger + +from bub.tracing import current_span, event + + +@dataclass(frozen=True) +class ModelCandidate: + provider: str + model_id: str + name: str + provider_name: str | None = None + + +ClientOptions = Mapping[str, Any] | Callable[[str], Mapping[str, Any]] +"""Republic client options, or a function returning them for a provider name.""" + + +class BubModel: + """A ``provider:model`` spec with Bub's client policy, usable as the agent loop's model. + + Args: + name: Default ``provider:model`` spec. Hooks may redirect a call to another spec. + fallbacks: Specs tried in order when opening the default model fails. + providers: Custom provider names mapped to the registered Republic provider type they speak. + client_options: Options for ``republic.get_model``, or a function of the provider name + (the custom name when one is used) returning them. + options: Generation options (``republic.ChatOptions``) sent with every call. + max_tokens: Default output token limit, unless a call sets one. + timeout: Seconds allowed for one complete call, streaming included. + """ + + def __init__( + self, + name: str, + *, + fallbacks: Sequence[str] = (), + providers: Mapping[str, str] | None = None, + client_options: ClientOptions | None = None, + options: Mapping[str, Any] | None = None, + max_tokens: int | None = None, + timeout: float | None = None, + ) -> None: + self.name = name + self.fallbacks = tuple(fallbacks) + self.providers = dict(providers or {}) + self.client_options = client_options + self.options = dict(options or {}) + self.max_tokens = max_tokens + self.timeout = timeout + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.name!r})" + + @property + def spec(self) -> str: + return self.name + + def candidates(self, model: str | None = None) -> list[ModelCandidate]: + """Resolve ``model`` (the default when omitted) and, for the default, its fallbacks.""" + model = model or self.name + candidate_names = [model] + if model == self.name: + candidate_names.extend(self.fallbacks) + + candidates: list[ModelCandidate] = [] + for candidate in candidate_names: + provider, separator, model_id = candidate.partition(":") + if not separator or not provider or not model_id: + raise ValueError(f"Expected a provider:model identifier, got {candidate!r}") + if custom_type := self.providers.get(provider): + candidates.append(ModelCandidate(custom_type, model_id, candidate, provider)) + continue + provider = provider.lower() + candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=f"{provider}:{model_id}")) + return candidates + + def client_kwargs(self, provider: str) -> dict[str, Any]: + """Return Republic client options for a provider name.""" + if callable(self.client_options): + return dict(self.client_options(provider)) + return dict(self.client_options or {}) + + def chat_options(self, provider: str, options: Mapping[str, Any]) -> dict[str, Any]: + """Merge this model's generation options with a call's for ``provider``.""" + merged = {**self.options, **options} + if provider == "anthropic": + merged["extra_body"] = {"cache_control": {"type": "ephemeral"}} | merged.get("extra_body", {}) + max_tokens = options.get("max_tokens", self.max_tokens) + merged.pop("max_tokens", None) + if provider != "codex" and max_tokens is not None: + merged["max_tokens"] = max_tokens + return merged + + @asynccontextmanager + async def open_stream( + self, messages: list[republic.Message], *, spec: str, options: Mapping[str, Any] + ) -> AsyncIterator[tuple[str, republic.Stream[Any]]]: + """Open a streaming completion, falling back to later candidates if opening fails. + + Fallback covers client creation and the request; errors raised while consuming the + stream are not retried. ``timeout`` covers the whole call. + """ + async with asyncio.timeout(self.timeout): + completion_error: Exception | None = None + candidates = self.candidates(spec) + for index, candidate in enumerate(candidates): + async with AsyncExitStack() as stack: + try: + chat_model = await stack.enter_async_context( + republic.get_model( + f"{candidate.provider}:{candidate.model_id}", + **self.client_kwargs(candidate.provider_name or candidate.provider), + ) + ) + if span := current_span(): + span.rename(f"chat {candidate.model_id}") + span.set(**{ + "gen_ai.provider.name": candidate.provider, + "gen_ai.request.model": candidate.model_id, + }) + stream = await stack.enter_async_context( + chat_model.stream(messages, **self.chat_options(candidate.provider, options)) + ) + except Exception as exc: + event("bub.model.attempt_failed", model=candidate.name, error=type(exc).__name__) + if completion_error is None: + completion_error = exc + if index == len(candidates) - 1: + raise completion_error from None + logger.warning( + "model candidate failed; trying fallback model={} error={}", + candidate.name, + type(exc).__name__, + ) + else: + yield candidate.provider_name or candidate.provider, stream + return + + raise RuntimeError("no model candidates available") diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py deleted file mode 100644 index 8b4f248f..00000000 --- a/src/bub/builtin/model_runner.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Republic requests and model-output helpers for the builtin agent.""" - -from __future__ import annotations - -import asyncio -import re -from collections.abc import AsyncGenerator, AsyncIterator -from contextlib import AsyncExitStack, aclosing, asynccontextmanager -from dataclasses import asdict -from datetime import UTC, datetime -from json import JSONDecodeError -from time import monotonic -from typing import Any - -import republic -from loguru import logger -from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady - -from bub.builtin.settings import AgentSettings, ModelCandidate -from bub.errors import BubError, ErrorKind -from bub.hooks.interception import ( - AgentHooks, - LlmCallDecision, - LlmCallRequest, - LlmCallResult, -) -from bub.prompt import UserContent -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.tape import Tape -from bub.tools import Tool, ToolContext, ToolExecutor -from bub.tracing import Span, current_span, event - -CONTEXT_LENGTH_PATTERNS = re.compile( - r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum", - re.IGNORECASE, -) - - -class ModelRunner: - def __init__(self, settings: AgentSettings, hooks: AgentHooks | None = None) -> None: - self.settings = settings - self.hooks = hooks - - @asynccontextmanager - async def _completion( - self, - *, - model: str, - messages: list[republic.Message], - tools: list[Tool], - max_tokens: int | None = None, - reasoning_effort: str | None = None, - ) -> AsyncIterator[tuple[ModelCandidate, republic.Stream[Any]]]: - completion_error: Exception | None = None - candidates = self.settings.model_candidates(model) - for index, candidate in enumerate(candidates): - async with AsyncExitStack() as stack: - try: - client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider) - chat_model = await stack.enter_async_context( - republic.get_model(f"{candidate.provider}:{candidate.model_id}", **client_kwargs) - ) - if span := current_span(): - span.rename(f"chat {candidate.model_id}") - span.set(**{ - "gen_ai.provider.name": candidate.provider, - "gen_ai.request.model": candidate.model_id, - }) - options = self._chat_options(candidate.provider, tools, max_tokens, reasoning_effort) - stream = await stack.enter_async_context(chat_model.stream(messages, **options)) - except Exception as exc: - event("bub.model.attempt_failed", model=candidate.name, error=type(exc).__name__) - if completion_error is None: - completion_error = exc - if index == len(candidates) - 1: - raise completion_error from None - logger.warning( - "model candidate failed; trying fallback model={} error={}", candidate.name, type(exc).__name__ - ) - else: - yield candidate, stream - return - - raise RuntimeError("no model candidates available") - - def _chat_options( - self, provider: str, tools: list[Tool], max_tokens: int | None, reasoning_effort: str | None - ) -> dict[str, Any]: - options = dict(self.settings.completion_args) - if provider == "anthropic": - options["extra_body"] = {"cache_control": {"type": "ephemeral"}} | options.get("extra_body", {}) - options["tools"] = [republic.Tool(item.name, item.description, item.parameters) for item in tools] - if provider != "codex": - options["max_tokens"] = max_tokens if max_tokens is not None else self.settings.max_tokens - if reasoning_effort is not None: - options["reasoning_effort"] = reasoning_effort - return options - - def run( - self, - *, - tape: Tape, - model: str, - tools: list[Tool], - system_prompt: str | None, - prompt: list[UserContent] | None, - steering_messages: list[list[UserContent]] | None = None, - ) -> AsyncStreamEvents: - state = StreamState() - - async def iterator() -> AsyncGenerator[StreamEvent, None]: - run_id = self.generate_run_id() - messages, new_messages = await self.build_messages( - tape=tape, - run_id=run_id, - system_prompt=system_prompt, - prompt=prompt, - model=model, - steering_messages=steering_messages, - ) - request = LlmCallRequest( - run_id=run_id, - model=model, - messages=messages, - tool_names=tuple(tool_item.name for tool_item in tools), - max_tokens=self.settings.max_tokens, - ) - decision: LlmCallDecision | None = None - if self.hooks is not None: - request, decision = await self.hooks.before_llm_call(request, state=tape.context.state) - if decision is not None: - await tape.record_chat( - run_id=run_id, - system_prompt=system_prompt, - new_messages=new_messages, - response_text=decision.text, - model=request.model, - ) - yield StreamEvent("text", {"delta": decision.text}) - yield StreamEvent("final", {"ok": True, "text": decision.text}) - return - completion_started = monotonic() - async with self._traced_completion(request, tools, tape, state) as (candidate, completion, events): - async for event in events: - yield event - response = completion.response - completion_elapsed = monotonic() - completion_started - text = response.text or response.refusal or "" - provider = candidate.provider_name or candidate.provider - - yield StreamEvent("usage", {"usage": state.usage, "elapsed_seconds": completion_elapsed}) - - tool_calls = response.tool_calls - if tool_calls: - tool_map = {tool_item.name: tool_item for tool_item in tools} - serialized_tool_calls = [asdict(tool_call) for tool_call in tool_calls] - tool_invocations = [_tool_invocation(tool_call, tool_map) for tool_call in tool_calls] - yield StreamEvent("tool_call", {"tool_calls": serialized_tool_calls}) - tape.context.state["_runtime_tool_names"] = tuple(tool_map) - context = ToolContext(tape=tape, run_id=run_id, state=tape.context.state) - execution = await ToolExecutor(hooks=self.hooks).execute_async( - tool_invocations, - context=context, - call_ids=[call.id for call in tool_calls], - ) - tool_results = execution.tool_results - else: - serialized_tool_calls, tool_results = [], None - assistant_fields = { - key: value - for key, value in { - "reasoning": response.reasoning, - "provider_data": [ - asdict(part) for part in response.message.parts if isinstance(part, republic.ProviderData) - ], - }.items() - if value - } - await tape.record_chat( - run_id=run_id, - system_prompt=system_prompt, - new_messages=new_messages, - response_text=(text or None) if tool_calls else text, - tool_calls=serialized_tool_calls, - tool_results=tool_results, - model=request.model, - usage=state.usage, - assistant_fields=assistant_fields, - provider=provider, - ) - if tool_calls: - yield StreamEvent("tool_result", {"tool_results": tool_results}) - yield StreamEvent( - "final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": tool_results} - ) - else: - yield StreamEvent("final", {"ok": True, "text": text}) - - return AsyncStreamEvents(iterator(), state=state) - - @asynccontextmanager - async def _traced_completion( - self, - request: LlmCallRequest, - tools: list[Tool], - tape: Tape, - state: StreamState, - ) -> AsyncIterator[tuple[ModelCandidate, republic.Stream[Any], AsyncIterator[StreamEvent]]]: - provider, _, model = request.model.partition(":") - span = Span( - f"chat {model or request.model}", - { - "gen_ai.operation.name": "chat", - "gen_ai.provider.name": provider, - "gen_ai.request.model": model or request.model, - "gen_ai.request.max_tokens": request.max_tokens, - "gen_ai.conversation.id": tape.context.state.get("session_id"), - "bub.run_id": request.run_id, - "bub.tape": tape.name, - }, - ) - span.messages("gen_ai.input.messages", request.messages) - if span.recording: - span.set(**{"gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in tools]}) - started = datetime.now(UTC) - # Partial output for hook errors and cancellation traces. - text: list[str] = [] - calls: list[republic.ToolCall] = [] - - try: - async with asyncio.timeout(self.settings.model_timeout_seconds), AsyncExitStack() as stack: - with span.activate(): - candidate, stream = await stack.enter_async_context( - self._completion( - model=request.model, - messages=list(request.messages), - tools=tools, - max_tokens=request.max_tokens, - reasoning_effort=tape.context.state.get("reasoning_effort"), - ) - ) - events = await stack.enter_async_context(aclosing(_stream_events(stream, span, state, text, calls))) - yield candidate, stream, events - except BaseException as exc: - span.fail(exc) - if isinstance(exc, Exception): - await self._fire_after_llm_call(request, "".join(text), calls, state, started, tape, error=exc) - raise - else: - completed = stream.response - await self._fire_after_llm_call( - request, completed.text or completed.refusal or "", completed.tool_calls, state, started, tape - ) - finally: - usage = state.usage or {} - span.set(**{ - "gen_ai.usage.input_tokens": usage.get("input_tokens"), - "gen_ai.usage.output_tokens": usage.get("output_tokens"), - }) - span.messages( - "gen_ai.output.messages", - [ - { - "role": "assistant", - "content": "".join(text), - "tool_calls": [asdict(call) for call in calls], - } - ], - ) - span.end() - - @staticmethod - def generate_run_id() -> str: - return f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}" - - async def _fire_after_llm_call( - self, - request: LlmCallRequest, - text: str, - tool_calls: list[republic.ToolCall], - state: StreamState, - started: datetime, - tape: Tape, - error: Exception | None = None, - ) -> None: - if self.hooks is None: - return - duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) - result = LlmCallResult( - run_id=request.run_id, - text=text or None, - tool_calls=[asdict(call) for call in tool_calls], - usage=state.usage, - error=error, - duration_ms=duration_ms, - ) - await self.hooks.after_llm_call(request, result, state=tape.context.state) - - async def build_messages( - self, - *, - tape: Tape, - run_id: str, - system_prompt: str | None, - prompt: list[UserContent] | None, - model: str, - steering_messages: list[list[UserContent]] | None = None, - ) -> tuple[list[republic.Message], list[republic.Message]]: - try: - messages = await tape.read_messages() - except BubError as exc: - await tape.record_chat( - run_id=run_id, - system_prompt=system_prompt, - context_error=exc, - new_messages=[], - response_text=None, - error=exc, - model=model, - ) - raise - contents = [*(steering_messages or []), *([] if prompt is None else [prompt])] - new_messages = [republic.user(*content) for content in contents] - if system_prompt: - messages = [republic.system(system_prompt), *messages] - messages.extend(new_messages) - return messages, new_messages - - -async def _stream_events( - stream: republic.Stream[Any], - span: Span, - state: StreamState, - text: list[str], - calls: list[republic.ToolCall], -) -> AsyncGenerator[StreamEvent, None]: - native_events = aiter(stream) - while True: - with span.activate(): - try: - item = await anext(native_events) - except StopAsyncIteration: - break - match item: - case TextDelta(chunk=delta) | RefusalDelta(chunk=delta): - text.append(delta) - yield StreamEvent("text", {"delta": delta}) - case ReasoningDelta(chunk=delta): - yield StreamEvent("reasoning", {"delta": delta}) - case ToolCallReady(call=call): - calls.append(call) - case Completed(response=completed): - state.usage = { - **asdict(completed.token_usage), - "total_tokens": completed.token_usage.total_tokens, - } - span.set(**{ - "gen_ai.response.model": completed.model, - "gen_ai.response.id": completed.id, - "gen_ai.response.finish_reasons": [completed.finish_reason] if completed.finish_reason else None, - }) - - -def _tool_invocation( - tool_call: republic.ToolCall, - tool_map: dict[str, Tool], -) -> tuple[Tool, dict[str, Any]]: - """Resolve a model tool call to (runtime tool, arguments). - - An unknown tool name is not treated as a fatal error: it is surfaced as a - placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and - builtin hooks (e.g. ``before_tool_call``) can recover it into a guidance - ``tool_result`` instead of interrupting the turn. If no hook replaces the - call, the placeholder raises a clear tool error rather than succeeding with - an empty result. - """ - tool_name = tool_call.name - try: - arguments = tool_call.args - except JSONDecodeError as exc: - raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc - if not isinstance(arguments, dict): - raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") - tool_obj = tool_map.get(tool_name) - if tool_obj is None: - - def raise_unknown_tool(**_: Any) -> None: - raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.") - - return Tool(name=tool_name, handler=raise_unknown_tool), arguments - return tool_obj, arguments - - -def is_context_length_error(error_msg: str) -> bool: - """Check whether an error message indicates a context-length / prompt-too-long failure.""" - return bool(CONTEXT_LENGTH_PATTERNS.search(error_msg)) diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index b1c2b657..ef11fb22 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -4,7 +4,6 @@ import pathlib import re import sys -from dataclasses import dataclass from typing import TYPE_CHECKING, Any import republic @@ -13,11 +12,12 @@ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict from bub import Settings, __version__, config, ensure_config -from bub.builtin.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 @@ -31,14 +31,6 @@ def _provider_env_name(provider: str) -> str: """Sent to model providers instead of Republic's default.""" -@dataclass(frozen=True) -class ModelCandidate: - provider: str - model_id: str - name: str - provider_name: str | None = None - - class CustomProvider(BaseModel): """A named endpoint that speaks a registered Republic provider API.""" @@ -123,22 +115,27 @@ def default_dict_args(cls, value: Any) -> Any: def valid_command_prefix(cls, value: str) -> str: return validate_command_prefix(value) + def chat_model(self, model: str | None = None, *, reasoning_effort: str | None = None) -> BubModel: + """Build the Republic model for ``model`` (the configured model by default). + + Fallback models apply only to the configured model. ``reasoning_effort`` + overrides the one in ``completion_args``. + """ + options = dict(self.completion_args) + if reasoning_effort is not None: + options["reasoning_effort"] = reasoning_effort + return BubModel( + model or self.model, + fallbacks=(self.fallback_models or []) if (model or self.model) == self.model else (), + providers={name: provider.type for name, provider in self.providers.items()}, + client_options=self.model_client_kwargs, + options=options, + max_tokens=self.max_tokens, + timeout=self.model_timeout_seconds, + ) + def model_candidates(self, model: str) -> list[ModelCandidate]: - candidate_names = [model] - if model == self.model: - candidate_names.extend(self.fallback_models or []) - - candidates: list[ModelCandidate] = [] - for candidate in candidate_names: - provider, separator, model_id = candidate.partition(":") - if not separator or not provider or not model_id: - raise ValueError(f"Expected a provider:model identifier, got {candidate!r}") - if custom := self.providers.get(provider): - candidates.append(ModelCandidate(custom.type, model_id, candidate, provider)) - continue - provider = provider.lower() - candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=f"{provider}:{model_id}")) - return candidates + return self.chat_model().candidates(model) def model_client_kwargs(self, provider: str) -> dict[str, Any]: custom = self.providers.get(provider) 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/tools.py b/src/bub/builtin/tools.py index b3391cbb..770d9d1d 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -14,12 +14,12 @@ from bub.builtin.environment import environment_from_state from bub.builtin.settings import load_settings, set_session_setting from bub.builtin.shell_manager import shell_manager +from bub.core.tools import REGISTRY, Tool, ToolContext, tool from bub.skills import discover_skills -from bub.tools import REGISTRY, Tool, ToolContext, tool if TYPE_CHECKING: - from bub.builtin.agent import Agent - from bub.tape import Tape + from bub.builtin.agent import BubAgent + from bub.core.tape import Tape DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 DEFAULT_HEADERS = {"accept": "text/markdown"} @@ -141,10 +141,10 @@ def _raise_for_failed_shell(returncode: int | None, output: str) -> None: raise RuntimeError(f"command exited with code {returncode}\noutput:\n{body}") -def _get_agent(context: ToolContext) -> Agent: +def _get_agent(context: ToolContext) -> BubAgent: if "_runtime_agent" not in context.state: raise RuntimeError("no runtime agent found in tool context") - return cast("Agent", context.state["_runtime_agent"]) + return cast("BubAgent", context.state["_runtime_agent"]) class SearchInput(BaseModel): @@ -458,7 +458,9 @@ async def tape_search(param: SearchInput, *, context: ToolContext) -> TapeSearch @tool(context=True, name="tape.reset", renderer=lambda result: result["message"]) async def tape_reset(archive: bool = False, *, context: ToolContext) -> dict[str, str]: """Reset the current tape, optionally archiving it.""" - return {"message": await context.tape.reset(archive=archive)} + from bub.builtin.archive import archive_dir, reset_tape + + return {"message": await reset_tape(context.tape, archive_to=archive_dir() if archive else None)} @tool(context=True, name="tape.handoff", renderer=lambda result: f"anchor added: {result['anchor']}") diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 71170880..96a5adac 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -3,6 +3,7 @@ import math from collections.abc import AsyncGenerator, AsyncIterable, Callable from datetime import datetime +from functools import cached_property from hashlib import md5 from pathlib import Path from time import monotonic @@ -25,8 +26,7 @@ from rich.tree import Tree import bub -from bub.builtin.agent import Agent -from bub.builtin.commands import strip_command_prefix +from bub.builtin.agent import BubAgent from bub.channels.admission import AdmitDecision, TurnSnapshot from bub.channels.base import Interface from bub.channels.cli.ansi_bridge import render_to_ansi @@ -38,10 +38,12 @@ from bub.channels.cli.writers import MarkdownWriter from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage +from bub.core.commands import strip_command_prefix +from bub.core.events import AfterToolCall, BeforeToolCall, EventBus +from bub.core.tape import TapeInfo, session_tape_name +from bub.core.tools import REGISTRY from bub.envelope import Envelope, field_of from bub.streaming import StreamEvent -from bub.tape import TapeInfo -from bub.tools import REGISTRY, tool_call_reporter _GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment] _PROMPT_REFRESH_INTERVAL: float = SPINNERS["dots"]["interval"] / 1000.0 # type: ignore[operator] @@ -183,22 +185,30 @@ async def _print(self, *args: Any, **kwargs: Any) -> None: await self._presenter.write(lambda: self._console.print(*args, **kwargs)) -class _CliToolCallReporter: +class _CliToolCallRenderer: def __init__(self, renderer: CliRenderer, presenter: TerminalPresenter) -> None: self._renderer = renderer self._presenter = presenter - async def start(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: - await self._presenter.write(lambda: self._renderer.tool_call_start(name=name, args=args, kwargs=kwargs)) + def subscribe(self, events: EventBus) -> None: + events.on(BeforeToolCall, self.on_before_tool_call) + events.on(AfterToolCall, self.on_after_tool_call) - async def success(self, name: str, result: object, elapsed_ms: float) -> None: - await self._presenter.write( - lambda: self._renderer.tool_call_success(name=name, result=result, elapsed_ms=elapsed_ms) - ) + async def on_before_tool_call(self, event: BeforeToolCall) -> None: + call = event.call + await self._presenter.write(lambda: self._renderer.tool_call_start(name=call.tool, kwargs=call.arguments)) - async def error(self, name: str, error: BaseException, elapsed_ms: float) -> None: + async def on_after_tool_call(self, event: AfterToolCall) -> None: + result = event.result + if result.error is None: + await self._presenter.write( + lambda: self._renderer.tool_call_success(name=result.tool, elapsed_ms=result.duration_ms) + ) + return + # Show the exception the tool raised rather than the executor's wrapping error. + error = result.error.__cause__ or result.error await self._presenter.write( - lambda: self._renderer.tool_call_error(name=name, error=error, elapsed_ms=elapsed_ms) + lambda: self._renderer.tool_call_error(name=result.tool, error=error, elapsed_ms=result.duration_ms) ) @@ -209,9 +219,9 @@ class CliChannel(Interface): _stop_event: asyncio.Event _initial_prompt: str | None = None - def __init__(self, on_receive: MessageHandler, agent: Agent) -> None: + def __init__(self, on_receive: MessageHandler, get_agent: Callable[[], BubAgent], workspace: Path) -> None: self._on_receive = on_receive - self._agent = agent + self._get_agent = get_agent self._message_template = { "chat_id": "cli_chat", "channel": self.name, @@ -227,15 +237,25 @@ def __init__(self, on_receive: MessageHandler, agent: Agent) -> None: self._presenter = TerminalPresenter() self._last_tape_info: TapeInfo | None = None self._last_token_speed: float | None = None - self._workspace = self._agent.framework.workspace - self._prompt = self._build_prompt(self._workspace) + self._workspace = workspace + + @cached_property + def _agent(self) -> BubAgent: + """The agent, created on first use, inside the framework's lifespan.""" + agent = self._get_agent() + _CliToolCallRenderer(self._renderer, self._presenter).subscribe(agent.events) + return agent + + @cached_property + def _prompt(self) -> PromptSession[str]: + return self._build_prompt(self._workspace) def _suppress_logs(self) -> None: with contextlib.suppress(ValueError): logger.remove() async def _refresh_tape_info(self) -> None: - tape = self._agent.tape.session_tape(self._message_template["session_id"], self._workspace) + tape = self._agent.tape(session_tape_name(self._message_template["session_id"], self._workspace)) info = await tape.info() self._last_tape_info = info @@ -388,12 +408,11 @@ async def stream_events( self._stream_printer = printer self._invalidate_prompt() try: - with tool_call_reporter(_CliToolCallReporter(self._renderer, self._presenter)): - async for event in stream: - if event.kind == "usage": - self._update_token_speed(event.data) - if await printer.render(event): - yield event + async for event in stream: + if event.kind == "usage": + self._update_token_speed(event.data) + if await printer.render(event): + yield event finally: try: await printer.finish() diff --git a/src/bub/channels/cli/renderer.py b/src/bub/channels/cli/renderer.py index f0f0248c..feb9251b 100644 --- a/src/bub/channels/cli/renderer.py +++ b/src/bub/channels/cli/renderer.py @@ -49,10 +49,10 @@ def input_echo(self, prompt: str, text: str, steering: bool = False) -> None: mid = "[grey](steering)[/] " if steering else "" self.console.print(f"[dim][bold]{prompt}[/]{mid}{text}[/]", new_line_start=True) - def tool_call_start(self, *, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: - self.console.print(Text(_format_tool_call(name, args, kwargs), style="magenta"), new_line_start=True) + def tool_call_start(self, *, name: str, kwargs: dict[str, Any]) -> None: + self.console.print(Text(_format_tool_call(name, kwargs), style="magenta"), new_line_start=True) - def tool_call_success(self, *, name: str, result: Any, elapsed_ms: float) -> None: + def tool_call_success(self, *, name: str, elapsed_ms: float) -> None: self._tool_status(f"completed in {elapsed_ms:.0f} ms", style="green") def tool_call_error(self, *, name: str, error: BaseException, elapsed_ms: float) -> None: @@ -89,8 +89,8 @@ def _tool_status(self, label: str, *, style: str) -> None: self.console.print(Text(f" ⎿ {label}", style=style), highlight=False) -def _format_tool_call(name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: - params = _format_tool_params(args, kwargs) +def _format_tool_call(name: str, kwargs: dict[str, Any]) -> str: + params = [f"{key}: {_format_tool_value(value)}" for key, value in kwargs.items()] if not params: return f"● {name}()" @@ -102,15 +102,6 @@ def _format_tool_call(name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) return _truncate(f"● {name}(\n{body}\n)", max_chars=MAX_TOOL_CALL_CHARS) -def _format_tool_params(args: tuple[Any, ...], kwargs: dict[str, Any]) -> list[str]: - params: list[str] = [] - for index, value in enumerate(args, start=1): - params.append(f"arg{index}: {_format_tool_value(value)}") - for key, value in kwargs.items(): - params.append(f"{key}: {_format_tool_value(value)}") - return params - - def _format_tool_payload(payload: Any, *, max_chars: int = MAX_TOOL_PAYLOAD_CHARS) -> str: return _format_tool_value(payload, max_chars=max_chars, indent=2) diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index d2cb5675..06059e57 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -44,7 +44,7 @@ class ChannelSettings(Settings): stream_output: bool = Field(default=False, description="Whether to stream model output to channels in real-time.") -# A tool call runs as a child task of ToolExecutor.execute_async's own asyncio.gather(), +# A tool call runs as a child task of ToolExecutor.execute_many's own asyncio.gather(), # so asyncio.current_task() inside one is not the _run_message task quit() needs to # spare. This contextvar, set once per turn, survives that gather boundary. _owning_task: contextvars.ContextVar[asyncio.Task | None] = contextvars.ContextVar( diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index c93fa126..c455de4a 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -15,11 +15,11 @@ from telegram.request import HTTPXRequest from bub import config -from bub.builtin.commands import strip_command_prefix, validate_command_prefix from bub.channels.base import Channel from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage, MediaItem, MediaType from bub.configure import Settings, ensure_config +from bub.core.commands import strip_command_prefix, validate_command_prefix from bub.utils import exclude_none diff --git a/src/bub/core/__init__.py b/src/bub/core/__init__.py new file mode 100644 index 00000000..10b135db --- /dev/null +++ b/src/bub/core/__init__.py @@ -0,0 +1,72 @@ +"""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 ( + AfterLlmCall, + AfterToolCall, + AgentEvent, + BeforeLlmCall, + BeforeToolCall, + ChatCompleted, + CommandFinished, + EventBus, + EventHandler, + LlmCallDecision, + LlmCallRequest, + LlmCallResult, + RunStarted, + StepFinished, + StepStarted, + TapeEvent, + ToolCall, + 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 + +__all__ = [ + "AfterLlmCall", + "AfterToolCall", + "Agent", + "AgentEvent", + "BeforeLlmCall", + "BeforeToolCall", + "ChatCompleted", + "ChatModelClient", + "CommandFinished", + "EventBus", + "EventHandler", + "LlmCallDecision", + "LlmCallRequest", + "LlmCallResult", + "Model", + "ModelClient", + "PromptContext", + "PromptSection", + "RunStarted", + "StepFinished", + "StepStarted", + "SystemPrompt", + "Tape", + "TapeContext", + "TapeEvent", + "TapeRecorder", + "Tool", + "ToolCall", + "ToolCallDecision", + "ToolCallResult", + "ToolContext", + "default_tape_context", + "events_from", + "session_tape_name", + "tool", +] diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py new file mode 100644 index 00000000..429495bd --- /dev/null +++ b/src/bub/core/agent.py @@ -0,0 +1,717 @@ +"""Agent loop over tools, a system prompt and a tape store, calling its model or the one given to a run.""" + +from __future__ import annotations + +import sys +import time +from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Mapping, Sequence +from contextlib import AsyncExitStack, aclosing, asynccontextmanager +from dataclasses import asdict, replace +from datetime import UTC, datetime +from json import JSONDecodeError +from typing import Any, TypeVar + +import republic +from loguru import logger +from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady + +from bub.core.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix +from bub.core.context import default_tape_context +from bub.core.events import ( + AfterLlmCall, + AgentEvent, + BeforeLlmCall, + ChatCompleted, + CommandFinished, + EventBus, + LlmCallRequest, + LlmCallResult, + RunStarted, + StepFinished, + StepStarted, + TapeEvent, +) +from bub.core.model import Model, ModelClient, is_context_length_error, model_client +from bub.core.recorder import TapeRecorder +from bub.core.sidecars import TapeSidecar +from bub.core.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store +from bub.core.system_prompt import PromptContext, PromptSection, SystemPrompt +from bub.core.tape import Tape, TapeContext +from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools +from bub.errors import BubError, ErrorKind +from bub.prompt import UserContent +from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState +from bub.tracing import Span, current_span +from bub.turn import TurnState + +MAX_AUTO_HANDOFF_RETRIES = 1 + +E = TypeVar("E", bound=AgentEvent) +T = TypeVar("T", bound=TapeEvent) + + +class Agent: + """Run the agent loop: call the model, execute its tool calls, repeat until it answers. + + The agent owns no configuration or session. A run calls the model given to it, or + else the agent's ``model``: a ``republic.ChatModel`` or a + :class:`~bub.core.model.ModelClient`. The agent is assembled from its components: + + Args: + model: Default model of runs not given one; None requires each run to give one. + store: Tape store holding conversations; each run works on one named tape in it. + Defaults to an in-memory store. + tools: Tools the model may call, kept by name in ``tools``. + system_prompt: System prompt sections: text, or functions of the step's + :class:`PromptContext` (prompt, tools, tape, state, agent) returning text. + Add more with ``agent.system_prompt.add``. + context: How a tape's entries become model context; defaults to + :func:`default_tape_context`, which replays the recorded chat. + sidecars: Sidecar capabilities mounted on every tape. + max_steps: Maximum model calls in one run. + name: Agent name, reported in ``invoke_agent`` traces. + command_prefix: When set, a text prompt starting with it runs as a command + (see :meth:`run_command`) instead of the agent loop. None disables commands. + + The loop reports its progress as events (:mod:`bub.core.events`) on ``events``; + register handlers with ``agent.events.on``. A :class:`TapeRecorder`, registered first, + records them on the run's tape. ``BeforeLlmCall`` and ``BeforeToolCall`` handlers + may change or short-circuit the call. + + Subclasses customize a run by overriding :meth:`prepare_tools`, + :meth:`pending_messages`, :meth:`has_pending_messages` and :meth:`continue_prompt`, + each of which receives the run's tape. + """ + + def __init__( + self, + *, + model: Model | None = None, + store: TapeStore | AsyncTapeStore | None = None, + tools: Iterable[Tool] = (), + system_prompt: SystemPrompt | PromptSection | Iterable[PromptSection] = (), + context: TapeContext | None = None, + sidecars: Sequence[TapeSidecar] = (), + max_steps: int = sys.maxsize, + name: str = "agent", + command_prefix: str | None = None, + ) -> None: + self.model = model + self.tools = {tool.name: tool for tool in tools} + self.system_prompt = SystemPrompt.of(system_prompt) + if store is None: + store = InMemoryTapeStore() + self._tapes = Tape( + store if is_async_tape_store(store) else AsyncTapeStoreAdapter(store), + context or default_tape_context(), + sidecars=tuple(sidecars), + ) + self.max_steps = max_steps + self.name = name + self.command_prefix = None if command_prefix is None else validate_command_prefix(command_prefix) + self.events = EventBus() + TapeRecorder().subscribe(self.events) + + def tape(self, name: str, *, state: TurnState | None = None) -> Tape: + """Return tape ``name`` in the agent's store, with ``state`` as its turn state. + + Use it outside runs, for example to read a conversation or add an anchor; it + writes to the store directly. Runs work on a fork instead (see :meth:`session`). + """ + tapes = self._tapes + return tapes.scoped(name, context=replace(tapes.context, state={} if state is None else state)) + + @asynccontextmanager + async def session( + self, name: str, *, state: TurnState | None = None, merge_back: bool = True + ) -> AsyncGenerator[Tape]: + """Open a fork of tape ``name`` for one run, with ``state`` as its turn state. + + The fork merges back on exit unless ``merge_back`` is false. + """ + async with self.tape(name, state=state).fork_tape(merge_back=merge_back) as tape: + await tape.ensure_bootstrap_anchor() + yield tape + + @asynccontextmanager + async def run( + self, + prompt: str | list[UserContent], + *, + model: Model | None = None, + options: Mapping[str, Any] | None = None, + tape: str, + state: TurnState | None = None, + merge_back: bool = True, + metadata: dict[str, Any] | None = None, + ) -> AsyncGenerator[AsyncStreamEvents]: + """Run ``model``, or the agent's model, on tape ``tape``; iterate the yielded stream to drive the loop. + + :: + + async with agent.run(prompt, tape="session") as stream: + async for event in stream: + ... + + Leaving the block closes the stream, even after an early exit: the tape fork + merges back and the trace span ends. See :meth:`start` for the parameters. + """ + stream = await self.start( + prompt, model=model, options=options, tape=tape, state=state, merge_back=merge_back, metadata=metadata + ) + async with aclosing(stream): + yield stream + + async def start( + self, + prompt: str | list[UserContent], + *, + model: Model | None = None, + options: Mapping[str, Any] | None = None, + tape: str, + state: TurnState | None = None, + merge_back: bool = True, + metadata: dict[str, Any] | None = None, + ) -> AsyncStreamEvents: + """Start a run on tape ``tape`` and return its stream; the caller must close it. + + Prefer :meth:`run`; use this to hand the stream to code that consumes and closes it. + + The run calls ``model``, or the agent's ``model`` when it is None; a run that + calls a model without either raises ``ValueError``. + + ``options`` are ``republic.ChatOptions`` (such as ``max_tokens`` or ``temperature``) + sent with every model call of the run. + + The run works on a fork of the tape (see :meth:`session`) that stays open until + the stream closes, even if it is never consumed. ``state`` is the run's mutable + turn state, carried by events and tools; ``metadata`` is attached to the + ``RunStarted`` event. Each model step ends with a ``final`` event; the run ends + when a step makes no tool calls and no messages are pending. A context-length + error triggers one automatic tape handoff and a retry of the prompt. An empty + prompt yields an error without opening the tape. The run is traced as an + ``invoke_agent`` span. + """ + span = Span( + f"invoke_agent {self.name}", + { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.agent.name": self.name, + "gen_ai.conversation.id": (state or {}).get("session_id", tape), + }, + ) + stack = AsyncExitStack() + if isinstance(prompt, str): + prompt = [prompt] + try: + with span.activate(): + span.messages("gen_ai.input.messages", [republic.user(*prompt)]) + if not any(prompt): + 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) + except BaseException as exc: + span.fail(exc) + try: + with span.activate(): + await stack.aclose() + finally: + span.end() + raise + return AsyncStreamEvents(_trace_events(events, span), state=events._state, on_close=stack.aclose, span=span) + + async def run_on( + self, + tape: Tape, + prompt: list[UserContent], + *, + model: Model | None = None, + options: Mapping[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ) -> 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. + """ + 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}), + ]) + client = self._model_client(model) + 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) + + def run_once( + self, + prompt: list[UserContent] | None, + *, + model: Model | None = None, + options: Mapping[str, Any] | None = None, + tape: Tape, + ) -> AsyncStreamEvents: + """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. + """ + 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]: + turn_state = tape.context.state + tools = await self.prepare_tools(tape, prompt) + prompt_context = PromptContext(agent=self, tape=tape, prompt=prompt or [], tools=tools, model=client.spec) + system_prompt = await self.system_prompt.render(prompt_context) or None + if (span := current_span()) and span.recording: + span.set(**{"gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in tools]}) + pending = await self.pending_messages(tape) + + run_id = generate_run_id() + messages, new_messages = await self._build_messages( + tape, model=client.spec, run_id=run_id, system_prompt=system_prompt, prompt=prompt, pending=pending + ) + request = LlmCallRequest( + run_id=run_id, + model=client.spec, + messages=messages, + tool_names=tuple(tool_item.name for tool_item in tools), + max_tokens=options.get("max_tokens", client.max_tokens), + ) + before = await self._emit(BeforeLlmCall, tape, request=request) + request, decision = before.request, before.decision + if decision is not None: + await self._emit( + ChatCompleted, + tape, + run_id=run_id, + model=request.model, + system_prompt=system_prompt, + new_messages=new_messages, + response_text=decision.text, + ) + yield StreamEvent("text", {"delta": decision.text}) + yield StreamEvent("final", {"ok": True, "text": decision.text}) + return + completion_started = time.monotonic() + async with self._traced_completion(tape, client, options, request, tools, state) as ( + provider, + completion, + events, + ): + async for event in events: + yield event + response = completion.response + completion_elapsed = time.monotonic() - completion_started + text = response.text or response.refusal or "" + + yield StreamEvent("usage", {"usage": state.usage, "elapsed_seconds": completion_elapsed}) + + tool_calls = response.tool_calls + if tool_calls: + tool_map = {tool_item.name: tool_item for tool_item in tools} + serialized_tool_calls = [asdict(tool_call) for tool_call in tool_calls] + tool_invocations = [_tool_invocation(tool_call, tool_map) for tool_call in tool_calls] + yield StreamEvent("tool_call", {"tool_calls": serialized_tool_calls}) + turn_state["_runtime_tool_names"] = tuple(tool_map) + context = ToolContext(tape=tape, run_id=run_id, state=turn_state, events=self.events) + execution = await ToolExecutor(events=self.events).execute_many( + tool_invocations, + context=context, + call_ids=[call.id for call in tool_calls], + ) + tool_results = execution.tool_results + else: + serialized_tool_calls, tool_results = [], None + assistant_fields = { + key: value + for key, value in { + "reasoning": response.reasoning, + "provider_data": [ + asdict(part) for part in response.message.parts if isinstance(part, republic.ProviderData) + ], + }.items() + if value + } + await self._emit( + ChatCompleted, + tape, + run_id=run_id, + model=request.model, + system_prompt=system_prompt, + new_messages=new_messages, + response_text=(text or None) if tool_calls else text, + tool_calls=serialized_tool_calls, + tool_results=tool_results, + usage=state.usage, + provider=provider, + assistant_fields=assistant_fields, + ) + if tool_calls: + yield StreamEvent("tool_result", {"tool_results": tool_results}) + yield StreamEvent("final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": tool_results}) + else: + yield StreamEvent("final", {"ok": True, "text": text}) + + async def _build_messages( + self, + tape: Tape, + *, + model: str, + run_id: str, + system_prompt: str | None, + prompt: list[UserContent] | None, + pending: list[list[UserContent]], + ) -> tuple[list[republic.Message], list[republic.Message]]: + try: + messages = await tape.read_messages() + except BubError as exc: + await self._emit( + ChatCompleted, + tape, + run_id=run_id, + model=model, + system_prompt=system_prompt, + new_messages=[], + response_text=None, + error=exc, + context_error=exc, + ) + raise + contents = [*pending, *([] if prompt is None else [prompt])] + new_messages = [republic.user(*content) for content in contents] + if system_prompt: + messages = [republic.system(system_prompt), *messages] + messages.extend(new_messages) + return messages, new_messages + + @asynccontextmanager + async def _traced_completion( + self, + tape: Tape, + client: ModelClient, + options: dict[str, Any], + request: LlmCallRequest, + tools: list[Tool], + state: StreamState, + ) -> AsyncGenerator[tuple[str, republic.Stream[Any], AsyncIterator[StreamEvent]]]: + provider, _, model_id = request.model.partition(":") + span = Span( + f"chat {model_id or request.model}", + { + "gen_ai.operation.name": "chat", + "gen_ai.provider.name": provider, + "gen_ai.request.model": model_id or request.model, + "gen_ai.request.max_tokens": request.max_tokens, + "gen_ai.conversation.id": tape.context.state.get("session_id"), + "bub.run_id": request.run_id, + "bub.tape": tape.name, + }, + ) + span.messages("gen_ai.input.messages", request.messages) + if span.recording: + span.set(**{"gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in tools]}) + started = datetime.now(UTC) + # Partial output for hook errors and cancellation traces. + text: list[str] = [] + calls: list[republic.ToolCall] = [] + + try: + call_options = { + **options, + "tools": [republic.Tool(item.name, item.description, item.parameters) for item in tools], + } + if request.max_tokens is not None: + call_options["max_tokens"] = request.max_tokens + async with AsyncExitStack() as stack: + with span.activate(): + provider, stream = await stack.enter_async_context( + client.open_stream(list(request.messages), spec=request.model, options=call_options) + ) + events = await stack.enter_async_context(aclosing(_stream_events(stream, span, state, text, calls))) + yield provider, stream, events + except BaseException as exc: + span.fail(exc) + if isinstance(exc, Exception): + await self._fire_after_llm_call(tape, request, "".join(text), calls, state, started, error=exc) + raise + else: + completed = stream.response + await self._fire_after_llm_call( + tape, request, completed.text or completed.refusal or "", completed.tool_calls, state, started + ) + finally: + usage = state.usage or {} + span.set(**{ + "gen_ai.usage.input_tokens": usage.get("input_tokens"), + "gen_ai.usage.output_tokens": usage.get("output_tokens"), + }) + span.messages( + "gen_ai.output.messages", + [{"role": "assistant", "content": "".join(text), "tool_calls": [asdict(call) for call in calls]}], + ) + span.end() + + async def _fire_after_llm_call( + self, + tape: Tape, + request: LlmCallRequest, + text: str, + tool_calls: list[republic.ToolCall], + state: StreamState, + started: datetime, + error: Exception | None = None, + ) -> None: + duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) + result = LlmCallResult( + run_id=request.run_id, + text=text or None, + tool_calls=[asdict(call) for call in tool_calls], + usage=state.usage, + error=error, + duration_ms=duration_ms, + ) + await self._emit(AfterLlmCall, tape, request=request, result=result) + + +def events_from(items: Iterable[StreamEvent]) -> AsyncStreamEvents: + """Stream fixed events.""" + + async def generator() -> AsyncIterator[StreamEvent]: + for item in items: + yield item + + return AsyncStreamEvents(generator()) + + +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 generate_run_id() -> str: + return f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}" + + +async def _stream_events( + stream: republic.Stream[Any], + span: Span, + state: StreamState, + text: list[str], + calls: list[republic.ToolCall], +) -> AsyncGenerator[StreamEvent, None]: + native_events = aiter(stream) + while True: + with span.activate(): + try: + item = await anext(native_events) + except StopAsyncIteration: + break + match item: + case TextDelta(chunk=delta) | RefusalDelta(chunk=delta): + text.append(delta) + yield StreamEvent("text", {"delta": delta}) + case ReasoningDelta(chunk=delta): + yield StreamEvent("reasoning", {"delta": delta}) + case ToolCallReady(call=call): + calls.append(call) + case Completed(response=completed): + state.usage = { + **asdict(completed.token_usage), + "total_tokens": completed.token_usage.total_tokens, + } + span.set(**{ + "gen_ai.response.model": completed.model, + "gen_ai.response.id": completed.id, + "gen_ai.response.finish_reasons": [completed.finish_reason] if completed.finish_reason else None, + }) + + +def _tool_invocation(tool_call: republic.ToolCall, tool_map: dict[str, Tool]) -> tuple[Tool, dict[str, Any]]: + """Resolve a model tool call to (runtime tool, arguments). + + An unknown tool name is not treated as a fatal error: it is surfaced as a + placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and + ``BeforeToolCall`` handlers can recover it into a guidance + ``tool_result`` instead of interrupting the turn. If no hook replaces the + call, the placeholder raises a clear tool error rather than succeeding with + an empty result. + """ + tool_name = tool_call.name + try: + arguments = tool_call.args + except JSONDecodeError as exc: + raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc + if not isinstance(arguments, dict): + raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") + tool_obj = tool_map.get(tool_name) + if tool_obj is None: + + def raise_unknown_tool(**_: Any) -> None: + raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.") + + return Tool(name=tool_name, handler=raise_unknown_tool), arguments + return tool_obj, arguments diff --git a/src/bub/core/commands.py b/src/bub/core/commands.py new file mode 100644 index 00000000..d93ec6a5 --- /dev/null +++ b/src/bub/core/commands.py @@ -0,0 +1,64 @@ +"""Command prompts: prefix rules and argument parsing.""" + +from __future__ import annotations + +import shlex +from collections.abc import Sequence +from dataclasses import dataclass + + +def validate_command_prefix(prefix: str) -> str: + if not prefix or any(char.isspace() for char in prefix): + raise ValueError("command_prefix must be non-empty and contain no whitespace") + return prefix + + +def strip_command_prefix(text: str, prefix: str) -> str | None: + """Return the command without its prefix, or None for ordinary text. + + Strip surrounding whitespace and exactly one prefix. A bare prefix returns + an empty string so execution can report an empty command. + Callers validate the prefix when configuring it. + """ + text = text.strip() + if text.startswith(prefix): + return text[len(prefix) :].strip() + return None + + +@dataclass(frozen=True) +class CommandArgs: + positional: list[str] + kwargs: dict[str, str] + + def bind(self, names: Sequence[str]) -> dict[str, str]: + """Return all arguments by name, naming the positional ones after ``names`` in order.""" + if len(self.positional) > len(names): + raise ValueError(f"expected at most {len(names)} positional arguments, got {len(self.positional)}") + arguments = dict(zip(names, self.positional, strict=False)) + if repeated := arguments.keys() & self.kwargs.keys(): + raise ValueError(f"argument '{sorted(repeated)[0]}' given both positionally and by keyword") + return {**arguments, **self.kwargs} + + +def parse_command(line: str) -> tuple[str, list[str]]: + """Split a command line into its name and argument tokens, shell-style.""" + words = shlex.split(line.strip()) + if not words: + return "", [] + return words[0], words[1:] + + +def parse_command_args(tokens: list[str]) -> CommandArgs: + """Parse positional arguments followed by ``key=value`` arguments.""" + positional: list[str] = [] + kwargs: dict[str, str] = {} + for token in tokens: + if "=" in token: + key, value = token.split("=", 1) + kwargs[key] = value + elif kwargs: + raise ValueError(f"positional argument '{token}' cannot appear after keyword arguments") + else: + positional.append(token) + return CommandArgs(positional=positional, kwargs=kwargs) diff --git a/src/bub/builtin/context.py b/src/bub/core/context.py similarity index 91% rename from src/bub/builtin/context.py rename to src/bub/core/context.py index 9a7d4f1e..3e649c24 100644 --- a/src/bub/builtin/context.py +++ b/src/bub/core/context.py @@ -8,12 +8,12 @@ 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: - """Return the default context selection for Bub.""" + """Return a context that replays the chat recorded by the agent loop, tool calls included, since the last anchor.""" return TapeContext(select=_select_messages) diff --git a/src/bub/core/events.py b/src/bub/core/events.py new file mode 100644 index 00000000..7eafebcb --- /dev/null +++ b/src/bub/core/events.py @@ -0,0 +1,306 @@ +"""Events emitted by the agent loop, their payloads, and the bus delivering them to handlers.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload + +import republic + +from bub.errors import BubError +from bub.prompt import UserContent +from bub.turn import TurnState + +if TYPE_CHECKING: + from bub.core.tape import Tape + from bub.core.tools import ToolContext + + +@dataclass(frozen=True) +class LlmCallRequest: + """Outgoing agent-loop LLM request exposed to interception hooks. + + Hooks may return a modified copy (``dataclasses.replace``) to change the + model or messages (``republic.Message`` objects) for this call. Tool objects are not exposed; + ``tool_names`` is observational and altering the toolset is out of scope. + """ + + run_id: str + model: str + messages: list[republic.Message] + tool_names: tuple[str, ...] = () + max_tokens: int | None = None + + +@dataclass(frozen=True) +class LlmCallResult: + """Terminal outcome of one LLM call exposed to interception hooks. + + For streaming completions this is the fully accumulated final state, not + a per-chunk view. ``error`` is the original raised exception (and other + fields are best effort) when the call failed. Cancellation and consumer + close are not observed. + """ + + run_id: str + text: str | None = None + tool_calls: list[dict[str, Any]] = field(default_factory=list) + usage: dict[str, Any] | None = None + error: Exception | None = None + duration_ms: int = 0 + + +@dataclass(frozen=True) +class LlmCallDecision: + """Short-circuit verdict returned by a ``before_llm_call`` hook.""" + + action: Literal["finish"] = "finish" + text: str = "" + + @classmethod + def finish(cls, text: str) -> LlmCallDecision: + """Skip the provider call and emit ``text`` as its final output.""" + + return cls(action="finish", text=text) + + +@dataclass(frozen=True) +class ToolCall: + """One tool invocation exposed to interception hooks. + + ``context`` is the ``ToolContext`` the call runs with, when one is available; for + example, ``context.code_mode`` marks calls made from model-written code (``run_code``). + """ + + run_id: str + tool: str + arguments: dict[str, Any] + context: ToolContext | None = field(default=None, repr=False, compare=False) + + +@dataclass(frozen=True) +class ToolCallDecision: + """Verdict returned by a ``before_tool_call`` hook. + + ``proceed`` continues with optional argument changes. ``replace`` skips + the handler and uses the supplied result. ``deny`` skips the handler and + surfaces the supplied message as a tool error. + """ + + action: Literal["proceed", "replace", "deny"] = "proceed" + arguments: dict[str, Any] | None = None + result: Any = None + message: str | None = None + + @classmethod + def proceed(cls, arguments: dict[str, Any] | None = None) -> ToolCallDecision: + return cls(action="proceed", arguments=arguments) + + @classmethod + def replace(cls, result: Any) -> ToolCallDecision: + return cls(action="replace", result=result) + + @classmethod + def deny(cls, message: str) -> ToolCallDecision: + return cls(action="deny", message=message) + + +@dataclass +class ToolCallResult: + """Terminal outcome exposed to hooks; model-facing results may be replaced in place.""" + + run_id: str + tool: str + arguments: dict[str, Any] + result: Any = None + error: Exception | None = None + duration_ms: int = 0 + + +# Events. Each carries the turn ``state``; loop events also carry the run's ``tape``. ``Before*`` +# events are mutable: handlers may replace the request or call, or set a decision, which stops delivery. + + +@dataclass(kw_only=True) +class AgentEvent: + """Base class of agent-loop events; a handler for it receives every event.""" + + state: TurnState + + @property + def stopped(self) -> bool: + """Whether delivery to the remaining handlers stops.""" + return False + + +@dataclass(kw_only=True) +class TapeEvent(AgentEvent): + """An event of a run, carrying the run's tape (whose ``context.state`` is ``state``).""" + + tape: Tape + + +@dataclass(kw_only=True) +class RunStarted(TapeEvent): + """A run began; ``metadata`` is caller-supplied context recorded with it.""" + + model: str + prompt: list[UserContent] + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class StepStarted(TapeEvent): + """A model step began; ``prompt`` is None when the step continues the conversation.""" + + step: int + prompt: list[UserContent] | None + + +@dataclass(kw_only=True) +class StepFinished(TapeEvent): + """A model step ended with ``status`` ``ok``, ``continue``, ``error`` or ``auto_handoff``.""" + + step: int + status: str + elapsed_ms: int + error: str | None = None + + +@dataclass(kw_only=True) +class CommandFinished(TapeEvent): + """A command prompt ran (see ``Agent.run_command``), with ``status`` ``ok`` or ``error``. + + ``line`` is the command without its prefix, ``name`` the command name, and ``output`` + the rendered result or the error message. + """ + + line: str + name: str + status: str + elapsed_ms: int + output: str + + +@dataclass(kw_only=True) +class BeforeLlmCall(TapeEvent): + """An LLM call is about to be sent. + + Handlers may replace ``request`` (``dataclasses.replace``) or set ``decision`` to + finish without calling the model; setting a decision stops delivery. + """ + + request: LlmCallRequest + decision: LlmCallDecision | None = None + + @property + def stopped(self) -> bool: + return self.decision is not None + + +@dataclass(kw_only=True) +class AfterLlmCall(TapeEvent): + """An LLM call completed or failed with an ``Exception``; cancellation is not observed.""" + + request: LlmCallRequest + result: LlmCallResult + + +@dataclass(kw_only=True) +class BeforeToolCall(AgentEvent): + """A tool call is about to run. + + Handlers may replace ``call`` to change its arguments, or set ``decision`` to + ``ToolCallDecision.replace(result)`` or ``deny(message)``, which stops delivery. + """ + + call: ToolCall + decision: ToolCallDecision = field(default_factory=lambda: ToolCallDecision.proceed()) + + @property + def stopped(self) -> bool: + return self.decision.action != "proceed" + + +@dataclass(kw_only=True) +class AfterToolCall(AgentEvent): + """A tool call ended (success, failure, denial or replacement); assign ``result.result`` to change it.""" + + call: ToolCall + result: ToolCallResult + + +@dataclass(kw_only=True) +class ChatCompleted(TapeEvent): + """One model step's exchange is complete and ready to be recorded. + + Also emitted when the step fails before calling the model (``context_error``) or a + ``before_llm_call`` decision finishes it. + """ + + run_id: str + model: str + system_prompt: str | None + new_messages: list[republic.Message] + response_text: str | None + tool_calls: list[dict[str, Any]] = field(default_factory=list) + tool_results: list[Any] | None = None + usage: dict[str, Any] | None = None + provider: str | None = None + assistant_fields: dict[str, Any] = field(default_factory=dict) + error: BubError | None = None + context_error: BubError | None = None + + +E = TypeVar("E", bound=AgentEvent) +EventHandler = Callable[[E], object] +"""A sync or async function of an event; its result is awaited if awaitable, then ignored.""" + + +class EventBus: + """Deliver agent events to handlers registered by event type, in registration order. + + A handler registered for a base class receives its subclasses. Handler exceptions + propagate to the agent loop. + """ + + def __init__(self) -> None: + self._handlers: list[tuple[type[AgentEvent], EventHandler[Any]]] = [] + + @overload + def on(self, event_type: type[E]) -> Callable[[EventHandler[E]], EventHandler[E]]: ... + + @overload + def on(self, event_type: type[E], handler: EventHandler[E]) -> EventHandler[E]: ... + + def on(self, event_type: type[E], handler: EventHandler[E] | None = None) -> Any: + """Register ``handler`` for ``event_type`` and return it. + + Without ``handler``, return a decorator registering the decorated function:: + + @events.on(BeforeToolCall) + def guard(event: BeforeToolCall) -> None: ... + """ + if handler is None: + return lambda handler: self.on(event_type, handler) + self._handlers.append((event_type, handler)) + return handler + + def off(self, event_type: type[E], handler: EventHandler[E]) -> None: + """Remove a handler registered for ``event_type``; do nothing if it is not registered.""" + with suppress(ValueError): + self._handlers.remove((event_type, handler)) + + async def emit(self, event: E) -> E: + """Deliver ``event`` and return it, as modified by the handlers.""" + for event_type, handler in list(self._handlers): + if event.stopped: + break + if isinstance(event, event_type): + result = handler(event) + if inspect.isawaitable(result): + await result + return event diff --git a/src/bub/core/model.py b/src/bub/core/model.py new file mode 100644 index 00000000..6034af9d --- /dev/null +++ b/src/bub/core/model.py @@ -0,0 +1,83 @@ +"""Models the agent loop calls: Republic chat models, or clients streaming like them.""" + +from __future__ import annotations + +import re +from collections.abc import AsyncIterator, Mapping +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import Any, Protocol + +import republic + +CONTEXT_LENGTH_PATTERNS = re.compile( + r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum", + re.IGNORECASE, +) + + +def is_context_length_error(error_msg: str) -> bool: + """Check whether an error message indicates a context-length / prompt-too-long failure.""" + return bool(CONTEXT_LENGTH_PATTERNS.search(error_msg)) + + +class ModelClient(Protocol): + """A model source with its own policy (fallbacks, credentials, timeouts) for the agent loop.""" + + @property + def spec(self) -> str: + """The default ``provider:model`` spec.""" + ... + + max_tokens: int | None + """Default output token limit reported to hooks, or None.""" + + def open_stream( + self, messages: list[republic.Message], *, spec: str, options: Mapping[str, Any] + ) -> AbstractAsyncContextManager[tuple[str, republic.Stream[Any]]]: + """Open a streaming completion for ``spec``; yield the provider name and the stream. + + ``spec`` is the default spec unless a ``BeforeLlmCall`` handler redirected the call; + ``options`` are the call's ``republic.ChatOptions``, tools included. + """ + ... + + +Model = republic.ChatModel | ModelClient +"""What a run calls: a ``republic.ChatModel``, or a :class:`ModelClient`.""" + + +class ChatModelClient: + """Adapt a ``republic.ChatModel`` to :class:`ModelClient`; the caller owns the model's lifecycle. + + A call redirected to another model of the same provider reuses its client; one + redirected to another provider uses a client configured from Republic's environment. + """ + + max_tokens: int | None = None + + def __init__(self, model: republic.ChatModel) -> None: + self.model = model + + @property + def spec(self) -> str: + return f"{self.model.provider.name}:{self.model.name}" + + @asynccontextmanager + async def open_stream( + self, messages: list[republic.Message], *, spec: str, options: Mapping[str, Any] + ) -> AsyncIterator[tuple[str, republic.Stream[Any]]]: + provider, _, name = spec.partition(":") + if spec == self.spec: + async with self.model.stream(messages, **options) as stream: + yield provider, stream + elif provider == self.model.provider.name: + async with self.model.provider.get_model(name).stream(messages, **options) as stream: + yield provider, stream + else: + async with republic.get_model(spec) as model, model.stream(messages, **options) as stream: + yield provider, stream + + +def model_client(model: Model) -> ModelClient: + """Return ``model`` as a :class:`ModelClient`, adapting a ``republic.ChatModel``.""" + return ChatModelClient(model) if isinstance(model, republic.ChatModel) else model diff --git a/src/bub/core/recorder.py b/src/bub/core/recorder.py new file mode 100644 index 00000000..fc3e48ca --- /dev/null +++ b/src/bub/core/recorder.py @@ -0,0 +1,76 @@ +"""Record agent events on a tape.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +import republic + +from bub.core.events import ChatCompleted, CommandFinished, EventBus, RunStarted, StepFinished, StepStarted +from bub.prompt import UserContent + + +class TapeRecorder: + """Write the conversation and loop progress of agent events to the event's tape. + + The agent loop reads its context back from the tape, so an agent always records + its own events. + """ + + def subscribe(self, events: EventBus) -> None: + events.on(RunStarted, self.on_run_started) + events.on(StepStarted, self.on_step_started) + events.on(StepFinished, self.on_step_finished) + events.on(ChatCompleted, self.on_chat_completed) + events.on(CommandFinished, self.on_command_finished) + + async def on_run_started(self, event: RunStarted) -> None: + await event.tape.append_event( + "loop.start", {"model": event.model, "prompt": prompt_payload(event.prompt), **event.metadata} + ) + + async def on_step_started(self, event: StepStarted) -> None: + prompt = None if event.prompt is None else prompt_payload(event.prompt) + await event.tape.append_event("loop.step.start", {"step": event.step, "prompt": prompt}) + + async def on_step_finished(self, event: StepFinished) -> None: + payload: dict[str, Any] = {"step": event.step, "elapsed_ms": event.elapsed_ms, "status": event.status} + if event.error is not None: + payload["error"] = event.error + payload["date"] = datetime.now(UTC).isoformat() + await event.tape.append_event("loop.step", payload) + + async def on_command_finished(self, event: CommandFinished) -> None: + await event.tape.append_event( + "command", + { + "raw": event.line, + "name": event.name, + "status": event.status, + "elapsed_ms": event.elapsed_ms, + "output": event.output, + "date": datetime.now(UTC).isoformat(), + }, + ) + + async def on_chat_completed(self, event: ChatCompleted) -> None: + await event.tape.record_chat( + run_id=event.run_id, + system_prompt=event.system_prompt, + new_messages=event.new_messages, + response_text=event.response_text, + context_error=event.context_error, + tool_calls=event.tool_calls, + tool_results=event.tool_results, + error=event.error, + assistant_fields=event.assistant_fields, + provider=event.provider, + model=event.model, + usage=event.usage, + ) + + +def prompt_payload(content: list[UserContent]) -> object: + """Serialize prompt content for tape events.""" + return republic.user(*content).to_dict().get("content") diff --git a/src/bub/core/sidecars.py b/src/bub/core/sidecars.py new file mode 100644 index 00000000..2ca767ee --- /dev/null +++ b/src/bub/core/sidecars.py @@ -0,0 +1,20 @@ +"""Contracts for tapes mounted beside a session tape.""" + +from __future__ import annotations + +from typing import Protocol + +SIDECAR_TAPE_MARKER = "__sidecar__" + + +class TapeSidecar(Protocol): + """A named capability backed by a sibling tape.""" + + @property + def name(self) -> str: ... + + +def sidecar_tape_name(owner: str, sidecar: str) -> str: + """Return the physical tape name for a mounted sidecar.""" + + return f"{owner}{SIDECAR_TAPE_MARKER}{sidecar}" diff --git a/src/bub/core/store.py b/src/bub/core/store.py new file mode 100644 index 00000000..2b824929 --- /dev/null +++ b/src/bub/core/store.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import asyncio +import inspect +import itertools +import json +import re +import threading +from collections.abc import Coroutine, Iterable, Sequence +from dataclasses import asdict, dataclass, field, replace +from datetime import UTC, datetime, time +from datetime import date as date_type +from pathlib import Path +from typing import Any, NoReturn, Protocol, Self, overload + +from loguru import logger +from typing_extensions import TypeIs + +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): + """Append-only tape storage interface.""" + + def list_tapes(self) -> list[str]: ... + + def reset(self, tape: str) -> None: ... + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: ... + + def append(self, tape: str, entry: TapeEntry) -> None: ... + + +class AsyncTapeStore(Protocol): + """Async append-only tape storage interface.""" + + async def list_tapes(self) -> list[str]: ... + + async def reset(self, tape: str) -> None: ... + + async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: ... + + async def append(self, tape: str, entry: TapeEntry) -> None: ... + + +def is_async_tape_store(store: TapeStore | AsyncTapeStore) -> TypeIs[AsyncTapeStore]: + return hasattr(store, "append") and inspect.iscoroutinefunction(store.append) + + +@dataclass(frozen=True) +class TapeQuery[T: TapeStore | AsyncTapeStore]: + tape: str + store: T + _query: str | None = None + _after_anchor: str | None = None + _after_last: bool = False + _between_anchors: tuple[str, str] | None = None + _between_dates: tuple[str, str] | None = None + _kinds: tuple[str, ...] = field(default_factory=tuple) + _limit: int | None = None + + def query(self, value: str) -> Self: + return replace(self, _query=value) + + def after_anchor(self, name: str) -> Self: + if not name: + return replace(self, _after_anchor=None, _after_last=False) + return replace(self, _after_anchor=name, _after_last=False) + + def last_anchor(self) -> Self: + return replace(self, _after_anchor=None, _after_last=True) + + def between_anchors(self, start: str, end: str) -> Self: + return replace(self, _between_anchors=(start, end)) + + def between_dates(self, start: str | date_type, end: str | date_type) -> Self: + start_value = start.isoformat() if isinstance(start, date_type) else start + end_value = end.isoformat() if isinstance(end, date_type) else end + return replace(self, _between_dates=(start_value, end_value)) + + def kinds(self, *kinds: str) -> Self: + return replace(self, _kinds=kinds) + + def limit(self, value: int) -> Self: + return replace(self, _limit=value) + + @overload + def all(self: TapeQuery[TapeStore]) -> Iterable[TapeEntry]: ... + + @overload + async def all(self: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: ... + + def all(self) -> Iterable[TapeEntry] | Coroutine[None, None, Iterable[TapeEntry]]: + return self.store.fetch_all(self) + + +def _anchor_index( + entries: Sequence[TapeEntry], + name: str | None, + *, + default: int, + forward: bool, + start: int = 0, +) -> int: + rng = range(start, len(entries)) if forward else range(len(entries) - 1, start - 1, -1) + for idx in rng: + entry = entries[idx] + if entry.kind != "anchor": + continue + if name is not None and entry.payload.get("name") != name: + continue + return idx + return default + + +def _parse_datetime_boundary(value: str, *, is_end: bool) -> datetime: + if "T" not in value and " " not in value: + try: + parsed_date = date_type.fromisoformat(value) + except ValueError: + pass + else: + boundary_time = time.max if is_end else time.min + return datetime.combine(parsed_date, boundary_time, tzinfo=UTC) + try: + parsed = datetime.fromisoformat(value) + except ValueError: + try: + parsed_date = date_type.fromisoformat(value) + except ValueError as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"Invalid ISO date or datetime: '{value}'.") from exc + boundary_time = time.max if is_end else time.min + parsed = datetime.combine(parsed_date, boundary_time, tzinfo=UTC) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _entry_in_datetime_range(entry: TapeEntry, start_dt: datetime, end_dt: datetime) -> bool: + entry_dt = _parse_datetime_boundary(entry.date, is_end=False) + return start_dt <= entry_dt <= end_dt + + +def _entry_matches_query(entry: TapeEntry, query: str) -> bool: + needle = query.casefold() + haystack = json.dumps( + { + "kind": entry.kind, + "date": entry.date, + "payload": entry.payload, + "meta": entry.meta, + }, + sort_keys=True, + default=str, + ).casefold() + return needle in haystack + + +class InMemoryQueryMixin: + """Mixin to implement in-memory query support for simple stores.""" + + def read(self, tape: str) -> list[TapeEntry] | None: + raise NotImplementedError("InMemoryQueryMixin requires a read() method to be implemented.") + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: # noqa: C901 + entries = self.read(query.tape) or [] + start_index = 0 + end_index: int | None = None + + if query._between_anchors is not None: + start_name, end_name = query._between_anchors + start_idx = _anchor_index(entries, start_name, default=-1, forward=False) + if start_idx < 0: + raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{start_name}' was not found.") + end_idx = _anchor_index(entries, end_name, default=-1, forward=True, start=start_idx + 1) + if end_idx < 0: + raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{end_name}' was not found.") + start_index = min(start_idx + 1, len(entries)) + end_index = min(max(start_index, end_idx), len(entries)) + elif query._after_last: + anchor_index = _anchor_index(entries, None, default=-1, forward=False) + if anchor_index < 0: + raise BubError(ErrorKind.NOT_FOUND, "No anchors found in tape.") + start_index = min(anchor_index + 1, len(entries)) + elif query._after_anchor is not None: + anchor_index = _anchor_index(entries, query._after_anchor, default=-1, forward=False) + if anchor_index < 0: + raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found.") + start_index = min(anchor_index + 1, len(entries)) + + sliced = entries[start_index:end_index] + if query._between_dates is not None: + start_date, end_date = query._between_dates + start_dt = _parse_datetime_boundary(start_date, is_end=False) + end_dt = _parse_datetime_boundary(end_date, is_end=True) + if start_dt > end_dt: + raise BubError(ErrorKind.INVALID_INPUT, "Start date must be earlier than or equal to end date.") + sliced = [entry for entry in sliced if _entry_in_datetime_range(entry, start_dt, end_dt)] + if query._query: + sliced = [entry for entry in sliced if _entry_matches_query(entry, query._query)] + if query._kinds: + sliced = [entry for entry in sliced if entry.kind in query._kinds] + if query._limit is not None: + sliced = sliced[: query._limit] + return sliced + + +class InMemoryTapeStore(InMemoryQueryMixin): + """In-memory tape storage.""" + + def __init__(self) -> None: + self._tapes: dict[str, list[TapeEntry]] = {} + self._next_id: dict[str, int] = {} + + def list_tapes(self) -> list[str]: + return sorted(self._tapes.keys()) + + def reset(self, tape: str) -> None: + self._tapes.pop(tape, None) + self._next_id.pop(tape, None) + + def read(self, tape: str) -> list[TapeEntry] | None: + entries = self._tapes.get(tape) + if entries is None: + return None + return [entry.copy() for entry in entries] + + def append(self, tape: str, entry: TapeEntry) -> None: + next_id = self._next_id.get(tape, 1) + self._next_id[tape] = next_id + 1 + stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta), entry.date) + self._tapes.setdefault(tape, []).append(stored) + + +class AsyncTapeStoreAdapter: + """Adapt a sync TapeStore to AsyncTapeStore.""" + + def __init__(self, store: TapeStore) -> None: + self._store = store + + async def list_tapes(self) -> list[str]: + return await asyncio.to_thread(self._store.list_tapes) + + async def reset(self, tape: str) -> None: + await asyncio.to_thread(self._store.reset, tape) + + async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: + return await asyncio.to_thread(self._store.fetch_all, query) + + async def append(self, tape: str, entry: TapeEntry) -> None: + await asyncio.to_thread(self._store.append, tape, entry) + + +class UnavailableTapeStore: + """Sync TapeStore sentinel that always fails with a clear message.""" + + def __init__(self, message: str) -> None: + self._message = message + + def _raise(self) -> NoReturn: + raise BubError(ErrorKind.INVALID_INPUT, self._message) + + def list_tapes(self) -> list[str]: + self._raise() + + def reset(self, tape: str) -> None: + self._raise() + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: + self._raise() + + def append(self, tape: str, entry: TapeEntry) -> None: + self._raise() + + +class ForkTapeStore: + def __init__(self, parent: AsyncTapeStore, tape: str, *, sidecars: Iterable[str] = ()) -> None: + self._parent = parent + self._store = InMemoryTapeStore() + self._tape = tape + self._sidecars = tuple(dict.fromkeys(sidecars)) + self._managed_tapes = {tape, *self._sidecars} + self._reset_tapes: set[str] = set() + + async def list_tapes(self) -> list[str]: + return await self._parent.list_tapes() + + async def reset(self, tape: str) -> None: + if tape not in self._managed_tapes: + await self._parent.reset(tape) + return + self._store.reset(tape) + self._reset_tapes.add(tape) + + async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: + if query.tape not in self._managed_tapes: + return await self._parent.fetch_all(query) + + parent_entries: Iterable[TapeEntry] = [] + if query.tape not in self._reset_tapes: + try: + parent_entries = await self._parent.fetch_all(query) + except Exception: + parent_entries = [] + this_entries: list[TapeEntry] = [] + for entry in self._store.read(query.tape) or []: + if entry.kind == "anchor": # noqa: SIM102 + if query._after_last or (query._after_anchor and entry.payload.get("name") == query._after_anchor): + this_entries.clear() + parent_entries = [] + continue + if query._kinds and entry.kind not in query._kinds: + continue + this_entries.append(entry) + entries = itertools.chain(parent_entries, this_entries) + return itertools.islice(entries, query._limit) if query._limit is not None else entries + + @staticmethod + def _redact_prompt(prompt: list[dict]) -> Any: + if not isinstance(prompt, list): + return prompt + new_prompt = [] + for part in prompt: + if part.get("type") == "text": + new_prompt.append(part) + return new_prompt + + @staticmethod + def _redact_payload(payload: dict) -> None: + if "content" in payload: + payload["content"] = ForkTapeStore._redact_prompt(payload["content"]) + elif "prompt" in payload: + payload["prompt"] = ForkTapeStore._redact_prompt(payload["prompt"]) + + async def append(self, tape: str, entry: TapeEntry) -> None: + self._redact_payload(entry.payload) + if tape not in self._managed_tapes: + await self._parent.append(tape, entry) + return + self._store.append(tape, entry) + + async def merge_back(self) -> None: + total = 0 + for sidecar in self._sidecars: + entries = self._store.read(sidecar) or [] + try: + if sidecar in self._reset_tapes: + await self._parent.reset(sidecar) + for entry in entries: + await self._parent.append(sidecar, entry) + except Exception as exc: + logger.warning('Failed to merge sidecar "{}" into tape "{}": {}', sidecar, self._tape, exc) + self._store.append( + self._tape, + TapeEntry.event( + "sidecar.merge", + {"tape": sidecar, "status": "error", "error": str(exc)}, + context=False, + ), + ) + else: + total += len(entries) + + if self._tape in self._reset_tapes: + await self._parent.reset(self._tape) + entries = self._store.read(self._tape) or [] + for entry in entries: + await self._parent.append(self._tape, entry) + total += len(entries) + if total: + logger.info('Merged {} entries into tape fork "{}"', total, self._tape) + + +class FileTapeStore(InMemoryQueryMixin): + """TapeStore implementation that persists tapes as JSONL files under a directory.""" + + def __init__(self, directory: Path) -> None: + self._directory = directory + self._directory.mkdir(parents=True, exist_ok=True) + self._tape_files: dict[str, TapeFile] = {} + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: + if not query._query: + result: Iterable[TapeEntry] = super().fetch_all(query) + return result + unlimited_query = replace(query, _limit=None) + entries: Iterable[TapeEntry] = super().fetch_all(unlimited_query) + return self._filter_entries(list(entries), query._query, query._limit or 20) + + def _filter_entries(self, entries: list[TapeEntry], query: str, limit: int) -> list[TapeEntry]: + normalized_query = query.strip().lower() + if not normalized_query: + return [] + results: list[TapeEntry] = [] + seen: set[str] = set() + + count = 0 + for entry in reversed(entries): + payload_text = get_entry_text(entry).lower() + if payload_text in seen: + continue + seen.add(payload_text) + + if normalized_query in payload_text or self._is_fuzzy_match(normalized_query, payload_text): + results.append(entry) + count += 1 + if count >= limit: + break + return results + + @staticmethod + def _is_fuzzy_match(normalized_query: str, payload_text: str) -> bool: + from rapidfuzz import fuzz, process + + if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH: + return False + + query_tokens = WORD_PATTERN.findall(normalized_query) + if not query_tokens: + return False + query_phrase = " ".join(query_tokens) + window_size = len(query_tokens) + + source_tokens = WORD_PATTERN.findall(payload_text) + if not source_tokens: + return False + + candidates: list[str] = [] + for token in source_tokens: + candidates.append(token) + if len(candidates) >= MAX_FUZZY_CANDIDATES: + break + + if window_size > 1: + max_window_start = len(source_tokens) - window_size + 1 + for idx in range(max(0, max_window_start)): + candidates.append(" ".join(source_tokens[idx : idx + window_size])) + if len(candidates) >= MAX_FUZZY_CANDIDATES: + break + + best_match = process.extractOne( + query_phrase, + candidates, + scorer=fuzz.WRatio, + score_cutoff=MIN_FUZZY_SCORE, + ) + return best_match is not None + + def _tape_file(self, tape: str) -> TapeFile: + if tape not in self._tape_files: + self._tape_files[tape] = TapeFile(self._directory / f"{tape}.jsonl") + return self._tape_files[tape] + + def list_tapes(self) -> list[str]: + return sorted(file.stem for file in self._directory.glob("*.jsonl") if file.stem.count("__") == 1) + + def reset(self, tape: str) -> None: + self._tape_file(tape).reset() + + def append(self, tape: str, entry: TapeEntry) -> None: + self._tape_file(tape).append(entry) + + def read(self, tape: str) -> list[TapeEntry] | None: + return self._tape_file(tape).read() + + +class TapeFile: + """Helper for one tape file.""" + + def __init__(self, path: Path) -> None: + self.path = path + self._lock = threading.Lock() + self._read_entries: list[TapeEntry] = [] + self._read_offset = 0 + + def _next_id(self) -> int: + if self._read_entries: + return self._read_entries[-1].id + 1 + return 1 + + def _reset(self) -> None: + self._read_entries = [] + self._read_offset = 0 + + def reset(self) -> None: + with self._lock: + if self.path.exists(): + self.path.unlink() + self._reset() + + def read(self) -> list[TapeEntry]: + with self._lock: + return self._read_locked() + + def _read_locked(self) -> list[TapeEntry]: + if not self.path.exists(): + self._reset() + return [] + + file_size = self.path.stat().st_size + if file_size < self._read_offset: + # The file was truncated or replaced, so cached entries are stale. + self._reset() + + with self.path.open("r", encoding="utf-8") as handle: + handle.seek(self._read_offset) + for raw_line in handle: + line = raw_line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + entry = self.entry_from_payload(payload) + if entry is not None: + self._read_entries.append(entry) + self._read_offset = handle.tell() + + return list(self._read_entries) + + @staticmethod + def entry_from_payload(payload: object) -> TapeEntry | None: + if not isinstance(payload, dict): + return None + entry_id = payload.get("id") + kind = payload.get("kind") + entry_payload = payload.get("payload") + meta = payload.get("meta") + if not isinstance(entry_id, int): + return None + if not isinstance(kind, str): + return None + if not isinstance(entry_payload, dict): + return None + if not isinstance(meta, dict): + meta = {} + if "date" in payload: + date = payload["date"] + else: + date = datetime.fromtimestamp(payload.get("timestamp", 0.0), tz=UTC).isoformat() + return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), date) + + def append(self, entry: TapeEntry) -> None: + with self._lock: + # Keep cache and offset in sync before allocating new IDs. + self._read_locked() + with self.path.open("a", encoding="utf-8") as handle: + next_id = self._next_id() + stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta), entry.date) + handle.write(json.dumps(asdict(stored), ensure_ascii=False) + "\n") + self._read_entries.append(stored) + self._read_offset = handle.tell() diff --git a/src/bub/core/system_prompt.py b/src/bub/core/system_prompt.py new file mode 100644 index 00000000..aa01739b --- /dev/null +++ b/src/bub/core/system_prompt.py @@ -0,0 +1,91 @@ +"""System prompts assembled from registered sections.""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable, Iterable +from contextlib import suppress +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeVar + +from bub.prompt import UserContent +from bub.turn import TurnState + +if TYPE_CHECKING: + from bub.core.agent import Agent + from bub.core.tape import Tape + from bub.core.tools import Tool + + +@dataclass(frozen=True) +class PromptContext: + """What a system prompt section sees when rendered for one model step.""" + + agent: Agent + tape: Tape + prompt: list[UserContent] + """The step's new user message; empty when the step continues the conversation.""" + tools: list[Tool] + """Model-facing tools offered in this step.""" + model: str + """The ``provider:model`` spec called in this step.""" + + @property + def state(self) -> TurnState: + """The run's turn state.""" + return self.tape.context.state + + @property + def available_tools(self) -> list[Tool]: + """All tools the agent was given, before per-step selection.""" + return list(self.agent.tools.values()) + + +PromptSection = str | Callable[[PromptContext], str | None | Awaitable[str | None]] +"""Fixed text, or a function of the step's context returning text (empty or None to skip).""" + +S = TypeVar("S", bound=PromptSection) + + +class SystemPrompt: + """Ordered system prompt sections, rendered for each model step and joined by blank lines.""" + + def __init__(self, sections: Iterable[PromptSection] = ()) -> None: + self._sections: list[PromptSection] = list(sections) + + @classmethod + def of(cls, value: SystemPrompt | PromptSection | Iterable[PromptSection]) -> SystemPrompt: + """Build a system prompt from one section, sections, or a copy of another system prompt.""" + if isinstance(value, SystemPrompt): + return cls(value.sections) + if isinstance(value, str) or callable(value): + return cls([value]) + return cls(value) + + @property + def sections(self) -> tuple[PromptSection, ...]: + return tuple(self._sections) + + def add(self, section: S) -> S: + """Append a section and return it, so this also works as a decorator:: + + @system_prompt.add + def tools(context: PromptContext) -> str: ... + """ + self._sections.append(section) + return section + + def remove(self, section: PromptSection) -> None: + """Remove a section; do nothing if it is not present.""" + with suppress(ValueError): + self._sections.remove(section) + + async def render(self, context: PromptContext) -> str: + blocks: list[str] = [] + for section in list(self._sections): + text = section if isinstance(section, str) else section(context) + if inspect.isawaitable(text): + text = await text + if text: + blocks.append(text) + return "\n\n".join(blocks) diff --git a/src/bub/core/tape.py b/src/bub/core/tape.py new file mode 100644 index 00000000..36c881b3 --- /dev/null +++ b/src/bub/core/tape.py @@ -0,0 +1,521 @@ +"""Append-only tape primitives owned by Bub.""" + +from __future__ import annotations + +import contextlib +import hashlib +import inspect +import json +from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping, MutableMapping +from dataclasses import asdict, dataclass, field, replace +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import republic + +from bub import tracing +from bub.core.sidecars import TapeSidecar, sidecar_tape_name +from bub.errors import BubError, ErrorKind + +__all__ = [ + "LAST_ANCHOR", + "AnchorSelector", + "AnchorSummary", + "AsyncTapeStore", + "AsyncTapeStoreAdapter", + "ContextSelector", + "InMemoryQueryMixin", + "InMemoryTapeStore", + "SelectedMessages", + "Tape", + "TapeContext", + "TapeEntry", + "TapeInfo", + "TapeQuery", + "TapeStore", + "UnavailableTapeStore", + "build_messages", + "is_async_tape_store", + "session_tape_name", + "to_message", + "to_messages", + "utc_now", +] + +if TYPE_CHECKING: + from bub.core.store import ( + AsyncTapeStore, + AsyncTapeStoreAdapter, + InMemoryQueryMixin, + InMemoryTapeStore, + TapeQuery, + TapeStore, + UnavailableTapeStore, + is_async_tape_store, + ) + + +_STORE_EXPORTS = frozenset({ + "AsyncTapeStore", + "AsyncTapeStoreAdapter", + "InMemoryQueryMixin", + "InMemoryTapeStore", + "TapeQuery", + "TapeStore", + "UnavailableTapeStore", + "is_async_tape_store", +}) + + +def __getattr__(name: str) -> Any: + if name not in _STORE_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + from bub.core import store + + return getattr(store, name) + + +def __dir__() -> list[str]: + return sorted({*globals(), *_STORE_EXPORTS}) + + +def utc_now() -> str: + return datetime.now(UTC).isoformat() + + +@dataclass(frozen=True) +class TapeEntry: + """A single append-only entry in a tape.""" + + id: int + kind: str + payload: dict[str, Any] + meta: dict[str, Any] = field(default_factory=dict) + date: str = field(default_factory=utc_now) + + def copy(self) -> TapeEntry: + return TapeEntry(self.id, self.kind, dict(self.payload), dict(self.meta), self.date) + + @classmethod + def message(cls, message: dict[str, Any], **meta: Any) -> TapeEntry: + return cls(id=0, kind="message", payload=dict(message), meta=dict(meta)) + + @classmethod + def system(cls, content: str, **meta: Any) -> TapeEntry: + return cls(id=0, kind="system", payload={"content": content}, meta=dict(meta)) + + @classmethod + def anchor(cls, name: str, state: dict[str, Any] | None = None, **meta: Any) -> TapeEntry: + payload: dict[str, Any] = {"name": name} + if state is not None: + payload["state"] = dict(state) + return cls(id=0, kind="anchor", payload=payload, meta=dict(meta)) + + @classmethod + def tool_call(cls, calls: list[dict[str, Any]], *, content: str = "", **meta: Any) -> TapeEntry: + payload: dict[str, Any] = {"calls": calls, "content": content} + return cls(id=0, kind="tool_call", payload=payload, meta=dict(meta)) + + @classmethod + def tool_result(cls, results: list[Any], **meta: Any) -> TapeEntry: + return cls(id=0, kind="tool_result", payload={"results": results}, meta=dict(meta)) + + @classmethod + def error(cls, error: BubError, **meta: Any) -> TapeEntry: + return cls(id=0, kind="error", payload=error.as_dict(), meta=dict(meta)) + + @classmethod + def event(cls, name: str, data: dict[str, Any] | None = None, **meta: Any) -> TapeEntry: + payload: dict[str, Any] = {"name": name} + if data is not None: + payload["data"] = dict(data) + return cls(id=0, kind="event", payload=payload, meta=dict(meta)) + + +class _LastAnchor: + def __repr__(self) -> str: + return "LAST_ANCHOR" + + +LAST_ANCHOR = _LastAnchor() +type AnchorSelector = str | None | _LastAnchor +type SelectedMessages = list[republic.Message] | Coroutine[Any, Any, list[republic.Message]] +type ContextSelector = Callable[[Iterable[TapeEntry], "TapeContext"], SelectedMessages] + + +@dataclass(frozen=True) +class TapeContext: + """Rules for selecting tape entries into a prompt context.""" + + anchor: AnchorSelector = LAST_ANCHOR + select: ContextSelector | None = None + state: dict[str, Any] = field(default_factory=dict) + + def build_query(self, query: TapeQuery) -> TapeQuery: + if self.anchor is None: + return query + if isinstance(self.anchor, _LastAnchor): + return query.last_anchor() + return query.after_anchor(self.anchor) + + +def build_messages(entries: Iterable[TapeEntry], context: TapeContext) -> SelectedMessages: + if context.select is not None: + return context.select(entries, context) + return _default_messages(entries) + + +def _default_messages(entries: Iterable[TapeEntry]) -> list[republic.Message]: + return to_messages( + entry.payload for entry in entries if entry.kind == "message" and isinstance(entry.payload, dict) + ) + + +def to_messages(payloads: Iterable[Mapping[str, Any]]) -> list[republic.Message]: + """Convert stored message payloads to Republic messages, resolving tool results to earlier calls.""" + calls: dict[str, republic.ToolCall] = {} + return [to_message(payload, calls) for payload in payloads] + + +def to_message( + payload: Mapping[str, Any], calls: MutableMapping[str, republic.ToolCall] | None = None +) -> republic.Message: + """Convert one stored message payload; assistant tool calls are added to ``calls``. + + Payloads are ``republic.Message.to_dict()`` output. Older Bub payloads, with media URLs, + ``tool_call_id``, ``reasoning`` and ``provider_data`` fields, and tool calls in the Chat + Completions ``{"function": {"name", "arguments"}}`` form, are converted to that form first. + """ + calls = {} if calls is None else calls + role = payload.get("role") + if role not in {"system", "user", "assistant", "tool"}: + raise BubError(ErrorKind.INVALID_INPUT, f"Unknown message role: {role}") + data = dict(payload) + raw_content = data.pop("content", None) + if role == "tool" and raw_content is not None and not isinstance(raw_content, str | list): + from bub.core.tools import render_result + + raw_content = render_result(raw_content) + content = _content_parts(raw_content) + if isinstance(tool_calls := data.get("tool_calls"), list): + data["tool_calls"] = [_tool_call_dict(call) for call in tool_calls] + if role == "tool" and "tool_call" not in data: + call_id = data.pop("tool_call_id", None) + if not isinstance(call_id, str) or not call_id: + raise BubError(ErrorKind.INVALID_INPUT, "Tool result without a tool call ID.") + # Republic announces calls missing from the conversation before their results. + call = calls.get(call_id) or republic.ToolCall(call_id, data.pop("name", None) or "", "{}") + data["tool_call"] = asdict(call) + if reasoning := data.pop("reasoning", None): + content.append({"type": "reasoning", "text": reasoning}) + content.extend({"type": "provider_data", **item} for item in data.pop("provider_data", None) or []) + data["content"] = content + try: + message = republic.Message.from_dict(data) + except (KeyError, TypeError, ValueError) as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"Invalid {role} message: {exc}") from exc + if role == "system" and any(not isinstance(part, republic.Text) for part in message.parts): + raise BubError(ErrorKind.INVALID_INPUT, "System messages only support text content.") + calls.update((call.id, call) for call in message.tool_calls) + return message + + +def _tool_call_dict(call: object) -> object: + """Convert a Chat Completions tool call, as recorded by older Bub versions, to Republic's form.""" + if not isinstance(call, dict) or not isinstance(function := call.get("function"), dict): + return call + arguments = function.get("arguments") + return { + "id": call.get("id") or "", + "name": function.get("name") or "", + "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments or {}), + } + + +def _content_parts(content: object) -> list[dict[str, Any]]: + if content is None: + return [] + if isinstance(content, str): + return [{"type": "text", "text": content}] + if not isinstance(content, list): + raise BubError(ErrorKind.INVALID_INPUT, "Expected text or a list of message content blocks.") + parts: list[dict[str, Any]] = [] + for block in content: + match block: + case {"type": "image" | "audio" | "video" as kind, "url": str(url), "media_type": str(mime)} if ( + url.startswith("data:") and "data" not in block + ): + # Older payloads keep inline media as data URLs. + media = {"image": republic.image, "audio": republic.audio, "video": republic.video}[kind]( + url, media_type=mime + ) + parts.append({"type": kind, "media_type": media.media_type, "data": media.base64_data}) + case {"type": "image" | "audio" | "video" as kind} if "url" not in block and "data" not in block: + raise BubError(ErrorKind.INVALID_INPUT, f"The {kind} content block needs a url or data.") + case {"type": "text" | "reasoning" | "image" | "audio" | "video" | "provider_data"}: + parts.append(block) + case _: + kind = block.get("type") if isinstance(block, dict) else type(block).__name__ + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported message content block: {kind}") + return parts + + +@dataclass(frozen=True) +class TapeInfo: + """Runtime tape info summary.""" + + name: str + entries: int + anchors: int + last_anchor: str | None + entries_since_last_anchor: int + last_token_usage: int | None + last_token_cache_hit_rate: float | None + + +@dataclass(frozen=True) +class AnchorSummary: + """Rendered anchor summary.""" + + name: str + state: dict[str, object] + + +def session_tape_name(session_id: str, workspace: Path) -> str: + """Return the tape name of a session in a workspace.""" + workspace_hash = hashlib.md5(str(workspace.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + return workspace_hash + "__" + hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + + +@dataclass(frozen=True) +class Tape: + """Tape abstraction for recording agent interactions.""" + + store: AsyncTapeStore + context: TapeContext + sidecars: tuple[TapeSidecar, ...] = field(default=(), repr=False) + _name: str | None = field(default=None, repr=False) + + @property + def name(self) -> str: + if self._name is None: + raise ValueError("tape is not scoped") + return self._name + + def with_context(self, context: TapeContext) -> Tape: + return replace(self, context=context) + + def scoped(self, name: str, context: TapeContext | None = None) -> Tape: + return replace(self, context=context or self.context, _name=name) + + def query(self) -> TapeQuery[AsyncTapeStore]: + from bub.core.store import TapeQuery + + return TapeQuery(tape=self.name, store=self.store) + + def get_sidecar(self, name: str) -> TapeSidecar | None: + """Return a mounted sidecar by its public name.""" + + return next((sidecar for sidecar in self.sidecars if sidecar.name == name), None) + + def sidecar_tape_name(self, name: str) -> str: + """Return the sibling tape name for a mounted sidecar.""" + + if self.get_sidecar(name) is None: + raise KeyError(f"tape sidecar {name!r} is not mounted") + return sidecar_tape_name(self.name, name) + + async def info(self) -> TapeInfo: + entries = list(await self.store.fetch_all(self.query())) + anchors = [(i, entry) for i, entry in enumerate(entries) if entry.kind == "anchor"] + if anchors: + last_anchor = anchors[-1][1].payload.get("name") + entries_since_last_anchor = len(entries) - anchors[-1][0] - 1 + else: + last_anchor = None + entries_since_last_anchor = len(entries) + last_token_usage: int | None = None + last_token_cache_hit_rate: float | None = None + for entry in reversed(entries): + if entry.kind == "event" and entry.payload.get("name") == "run": + data = entry.payload.get("data") + usage = data.get("usage") if isinstance(data, Mapping) else None + if not isinstance(usage, Mapping): + continue + token_usage = usage.get("total_tokens") + if not isinstance(token_usage, int) or isinstance(token_usage, bool): + continue + last_token_usage = token_usage + input_tokens = usage.get("input_tokens") + cached_tokens = usage.get("cached_tokens") + if ( + isinstance(input_tokens, int) + and not isinstance(input_tokens, bool) + and input_tokens > 0 + and isinstance(cached_tokens, int) + and not isinstance(cached_tokens, bool) + ): + last_token_cache_hit_rate = cached_tokens / input_tokens + break + return TapeInfo( + name=self.name, + entries=len(entries), + anchors=len(anchors), + last_anchor=str(last_anchor) if last_anchor else None, + entries_since_last_anchor=entries_since_last_anchor, + last_token_usage=last_token_usage, + last_token_cache_hit_rate=last_token_cache_hit_rate, + ) + + async def ensure_bootstrap_anchor(self) -> None: + anchors = list(await self.store.fetch_all(self.query().kinds("anchor"))) + if not anchors: + await self.handoff(name="session/start", state={"owner": "human"}) + + async def anchors(self, limit: int = 20) -> list[AnchorSummary]: + entries = list(await self.store.fetch_all(self.query().kinds("anchor"))) + results: list[AnchorSummary] = [] + for entry in entries[-limit:]: + name = str(entry.payload.get("name", "-")) + state = entry.payload.get("state") + state_dict: dict[str, object] = dict(state) if isinstance(state, dict) else {} + results.append(AnchorSummary(name=name, state=state_dict)) + return results + + async def search(self, query: TapeQuery[AsyncTapeStore]) -> list[TapeEntry]: + return list(await self.store.fetch_all(query)) + + async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None: + tracing.event(f"bub.{name}", **payload) + await self.store.append(self.name, TapeEntry.event(name, payload, **(tracing.correlation() | meta))) + + async def read_messages(self) -> list[republic.Message]: + query = self.context.build_query(self.query()) + entries = await self.store.fetch_all(query) + context_entries = (entry for entry in entries if entry.meta.get("context") is not False) + messages = build_messages(context_entries, self.context) + if inspect.isawaitable(messages): + messages = await messages + return messages + + async def handoff( + self, + *, + name: str, + state: dict[str, Any] | None = None, + **meta: Any, + ) -> list[TapeEntry]: + tape_name = self.name + meta = tracing.correlation() | meta + tracing.event("bub.handoff", anchor=name, state=state) + entry = TapeEntry.anchor(name, state=state, **meta) + event = TapeEntry.event("handoff", {"name": name, "state": state or {}}, **meta) + await self.store.append(tape_name, entry) + await self.store.append(tape_name, event) + return [entry, event] + + async def record_chat( # noqa: C901 + self, + *, + run_id: str, + system_prompt: str | None, + new_messages: list[republic.Message], + response_text: str | None, + context_error: BubError | None = None, + tool_calls: list[dict[str, Any]] | None = None, + tool_results: list[Any] | None = None, + error: BubError | None = None, + provider: str | None = None, + model: str | None = None, + usage: dict[str, Any] | None = None, + assistant_fields: dict[str, Any] | None = None, + ) -> None: + tape_name = self.name + meta = {"run_id": run_id, **tracing.correlation()} + if system_prompt: + await self.store.append(tape_name, TapeEntry.system(system_prompt, **meta)) + if context_error is not None: + await self.store.append(tape_name, TapeEntry.error(context_error, **meta)) + for message in new_messages: + await self.store.append(tape_name, TapeEntry.message(message.to_dict(), **meta)) + if tool_calls: + call_entry = TapeEntry.tool_call(tool_calls, content=response_text or "", **meta) + call_entry.payload.update(assistant_fields or {}) + await self.store.append(tape_name, call_entry) + if tool_results is not None: + await self.store.append(tape_name, TapeEntry.tool_result(tool_results, **meta)) + if error is not None and error is not context_error: + await self.store.append(tape_name, TapeEntry.error(error, **meta)) + if response_text is not None and not tool_calls: + await self.store.append( + tape_name, + TapeEntry.message({"role": "assistant", "content": response_text, **(assistant_fields or {})}, **meta), + ) + + data: dict[str, Any] = {"status": "error" if error is not None else "ok"} + if usage is not None: + data["usage"] = usage + if provider: + data["provider"] = provider + if model: + data["model"] = model + await self.store.append(tape_name, TapeEntry.event("run", data, **meta)) + + @staticmethod + def _sidecar_lifecycle_data( + *, sidecar: str, status: str, reason: str, error: Exception | None = None + ) -> dict[str, Any]: + data: dict[str, Any] = {"sidecar": sidecar, "status": status, "reason": reason} + if error is not None: + data["error"] = str(error) + return data + + async def _try_reset_sidecar(self, sidecar: TapeSidecar, *, reason: str) -> dict[str, Any]: + try: + await self.store.reset(sidecar_tape_name(self.name, sidecar.name)) + except Exception as exc: + return self._sidecar_lifecycle_data(sidecar=sidecar.name, status="error", reason=reason, error=exc) + return self._sidecar_lifecycle_data(sidecar=sidecar.name, status="ok", reason=reason) + + async def reset_sidecar(self, name: str, *, reason: str = "gc") -> str: + """Reset one mounted sidecar and record the outcome on the main tape.""" + + sidecar = self.get_sidecar(name) + if sidecar is None: + raise KeyError(f"tape sidecar {name!r} is not mounted") + reset_data = await self._try_reset_sidecar(sidecar, reason=reason) + await self.append_event("sidecar.reset", reset_data, context=False) + return f"{name} reset failed: {reset_data['error']}" if reset_data["status"] == "error" else "ok" + + async def reset(self, *, state: dict[str, Any] | None = None, keep_sidecars: Iterable[str] = ()) -> None: + """Clear the tape and start a new context with a ``session/start`` anchor. + + ``state`` is added to the anchor's state. Mounted sidecars are reset too, except + those named in ``keep_sidecars``; each reset is recorded as ``sidecar.reset``. + """ + keep = set(keep_sidecars) + await self.store.reset(self.name) + await self.handoff(name="session/start", state={"owner": "human", **(state or {})}) + for sidecar in self.sidecars: + if sidecar.name in keep: + continue + reset_data = await self._try_reset_sidecar(sidecar, reason="tape.reset") + await self.append_event("sidecar.reset", reset_data, context=False) + + def session_tape(self, session_id: str, workspace: Path, context: TapeContext | None = None) -> Tape: + return self.scoped(session_tape_name(session_id, workspace), context=context) + + @contextlib.asynccontextmanager + async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[Tape, None]: + from bub.core.store import ForkTapeStore + + managed_sidecars = tuple(sidecar_tape_name(self.name, sidecar.name) for sidecar in self.sidecars) + fork_store = ForkTapeStore(self.store, self.name, sidecars=managed_sidecars) + forked = replace(self, store=fork_store) + try: + yield forked + finally: + if merge_back: + await fork_store.merge_back() diff --git a/src/bub/core/tools.py b/src/bub/core/tools.py new file mode 100644 index 00000000..93954e29 --- /dev/null +++ b/src/bub/core/tools.py @@ -0,0 +1,633 @@ +from __future__ import annotations + +import asyncio +import inspect +import json +import time +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass, field, replace +from typing import Any, Literal, get_args, get_type_hints, overload + +import republic +from loguru import logger +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call + +from bub.core.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult +from bub.core.tape import Tape +from bub.errors import BubError, ErrorKind +from bub.prompt import UserContent +from bub.tracing import Span + + +@dataclass(frozen=True) +class ToolContext: + """Runtime context passed to tools that opt into context. + + ``code_mode`` is true when the tool is invoked from model-written code rather than + as a direct model tool call. Whether the result is rendered to text is decided by the + ``ToolExecutor`` running the call, not by this flag. ``command`` is true when the tool + runs as a command prompt rather than for the model. ``events`` is the bus of the agent + running the call, for tools that execute further tool calls. + """ + + tape: Tape + run_id: str | None = None + state: dict[str, Any] = field(default_factory=dict) + code_mode: bool = False + command: bool = False + events: EventBus | None = field(default=None, repr=False, compare=False) + + +def render_result(result: Any) -> str: + """Render a structured tool result as plain text (the default tool renderer).""" + if isinstance(result, str): + return result + if isinstance(result, BaseModel): + return result.model_dump_json() + try: + return json.dumps(result, ensure_ascii=False) + except TypeError: + return str(result) + + +CONTENT_RESULT_TYPE = "tool_content" + + +def content_result(content: Sequence[UserContent]) -> dict[str, Any]: + """Build a model-facing tool result that carries media along with text. + + The result is JSON so it can be recorded on the tape; ``result_content`` reads it back. + """ + blocks = [ + {"type": "text", "text": item} if isinstance(item, str) else republic.user(item).to_dict()["content"][0] + for item in content + ] + return {"type": CONTENT_RESULT_TYPE, "content": blocks} + + +def is_content_result(result: Any) -> bool: + return ( + isinstance(result, dict) + and result.get("type") == CONTENT_RESULT_TYPE + and isinstance(result.get("content"), list) + ) + + +def result_content(result: Any) -> list[UserContent]: + """Return a tool result as content for the model: media of ``content_result`` results, otherwise text.""" + if not is_content_result(result): + return [render_result(result)] + parts = republic.Message.from_dict({"role": "user", "content": result["content"]}).parts + return [ + part.text if isinstance(part, republic.Text) else part + for part in parts + if isinstance(part, republic.Text | republic.Image | republic.Audio | republic.Video) + ] + + +def _to_snake_case(name: str) -> str: + return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") + + +def _callable_name(func: Callable[..., Any]) -> str: + name = getattr(func, "__name__", None) + if isinstance(name, str) and name: + return name + return func.__class__.__name__ + + +def _schema_from_annotation(annotation: Any) -> dict[str, Any]: + if annotation is inspect._empty: + annotation = Any + try: + return TypeAdapter(annotation).json_schema() + except Exception as exc: + raise ValueError(f"Failed to build JSON schema for type: {annotation!r}") from exc + + +def _schema_from_signature(signature: inspect.Signature, *, ignore_params: set[str] | None = None) -> dict[str, Any]: + ignore = ignore_params or set() + properties: dict[str, Any] = {} + required: list[str] = [] + for param in signature.parameters.values(): + if param.name in ignore: + continue + if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): + continue + property_schema = _schema_from_annotation(param.annotation) + if param.default is param.empty: + required.append(param.name) + elif _is_json_value(param.default): + property_schema = {**property_schema, "default": param.default} + properties[param.name] = property_schema + schema: dict[str, Any] = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return schema + + +def _is_json_value(value: Any) -> bool: + try: + json.dumps(value) + except (TypeError, ValueError): + return False + return True + + +def _output_schema(func: Callable[..., Any]) -> dict[str, Any] | None: + """Build a JSON schema for the callable's return annotation, if it can be resolved.""" + try: + annotation = get_type_hints(func).get("return", inspect.Signature.empty) + except Exception: + return None + if annotation is inspect.Signature.empty: + return None + try: + return _schema_from_annotation(annotation) + except ValueError: + return None + + +def _signature_without_context(signature: inspect.Signature) -> inspect.Signature: + parameters = [param for param in signature.parameters.values() if param.name != "context"] + return signature.replace(parameters=parameters) + + +def _validate_without_context(func: Callable[..., Any], signature: inspect.Signature) -> Callable[..., Any]: + def validate_target(*args: Any, **kwargs: Any) -> tuple[tuple[Any, ...], dict[str, Any]]: + return args, kwargs + + validate_target.__name__ = _callable_name(func) + validate_target.__qualname__ = getattr(func, "__qualname__", validate_target.__name__) + # Only arguments are validated; dropping the return annotation also avoids resolving + # forward references to types that live in the tool's module, not this one. + validate_target.__annotations__ = dict(getattr(func, "__annotations__", {})) + validate_target.__annotations__.pop("context", None) + validate_target.__annotations__.pop("return", None) + validate_target.__signature__ = _signature_without_context(signature).replace( # type: ignore[attr-defined] + return_annotation=inspect.Signature.empty + ) + return validate_call(validate_target) + + +ToolExposure = Literal["auto", "direct", "code", "command"] +"""Where a tool can be called from. + +- ``auto``: by the model directly, or only from ``run_code`` in code mode. +- ``direct``: by the model directly, also in code mode; never from ``run_code``. +- ``code``: only from ``run_code`` in code mode; never by the model directly. +- ``command``: only as a comma command; never by the model or from code. +""" + + +@dataclass(frozen=True) +class Tool: + """A callable unit the model can invoke.""" + + name: str + handler: Callable[..., Any] + description: str = "" + parameters: dict[str, Any] = field(default_factory=dict) + context: bool = False + exposure: ToolExposure = "auto" + """Where the tool can be called from, see ``ToolExposure``.""" + renderer: Callable[[Any], str] | None = None + output_schema: dict[str, Any] | None = None + """JSON schema of the structured result, used to describe the tool to model-written code.""" + deferred: bool = False + """Load the tool on demand: only its name is listed until ``tool.describe`` loads its definition.""" + + def __post_init__(self) -> None: + if self.exposure not in get_args(ToolExposure): + raise ValueError(f"Tool '{self.name}' has unknown exposure {self.exposure!r}.") + + async def run(self, *args: Any, **kwargs: Any) -> Any: + """Call the handler, awaiting its result when it is awaitable.""" + result = self.handler(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + return result + + def render(self, result: Any) -> str: + """Render a structured result of this tool as plain text for the model.""" + if self.renderer is None: + return render_result(result) + return self.renderer(result) + + def to_schema(self) -> dict[str, Any]: + """Build a function tool schema for hooks and diagnostics.""" + return {"name": self.name, "description": self.description, "parameters": self.parameters} + + @classmethod + def from_callable( + cls, + func: Callable[..., Any], + *, + name: str | None = None, + description: str | None = None, + context: bool = False, + exposure: ToolExposure = "auto", + renderer: Callable[[Any], str] | None = None, + deferred: bool = False, + ) -> Tool: + signature = inspect.signature(func) + if context and "context" not in signature.parameters: + raise TypeError("Tool context is enabled but the callable lacks a 'context' parameter.") + tool_name = name or _to_snake_case(_callable_name(func)) + tool_description = description if description is not None else (inspect.getdoc(func) or "") + parameters = _schema_from_signature(signature, ignore_params={"context"} if context else None) + if context: + validate_args = _validate_without_context(func, signature) + + def validated(*args: Any, **kwargs: Any) -> Any: + tool_context = kwargs.pop("context") + validated_args, validated_kwargs = validate_args(*args, **kwargs) + return func(*validated_args, context=tool_context, **validated_kwargs) + + else: + validated = validate_call(config=ConfigDict(arbitrary_types_allowed=True))(func) + return cls( + name=tool_name, + description=tool_description, + parameters=parameters, + handler=validated, + context=context, + exposure=exposure, + renderer=renderer, + output_schema=_output_schema(func), + deferred=deferred, + ) + + +def model_tools(tools: Iterable[Tool]) -> list[Tool]: + """Convert tools callable by the model or code into model-safe aliases; comma commands are dropped.""" + return [ + replace(tool_item, name=tool_item.name.replace(".", "_")) + for tool_item in tools + if tool_item.exposure != "command" + ] + + +@dataclass(frozen=True) +class ToolExecution: + tool_results: list[Any] = field(default_factory=list) + error: BubError | None = None + + +@dataclass(frozen=True) +class _FailedToolResult: + error: BubError + result: Any = None + + +class ToolExecutor: + """Execute already-resolved Bub tool invocations. + + With ``render`` (the default) successful results are rendered to model-facing text with + ``Tool.render``; ``render=False`` returns tools' structured results unchanged, as needed + when the caller is code rather than the model. ``BeforeToolCall`` and ``AfterToolCall`` + events are emitted on ``events``. + """ + + def __init__(self, events: EventBus | None = None, *, render: bool = True) -> None: + self._events = events + self._render = render + + async def execute( + self, + tool: Tool, + arguments: dict[str, Any], + *, + context: ToolContext | None = None, + call_id: str | None = None, + ) -> Any: + """Execute one tool call and return its result; raise its ``BubError`` when it fails.""" + result = await self._trace_tool_response(tool, arguments, context, call_id) + if isinstance(result, _FailedToolResult): + raise result.error + return result + + async def execute_many( + self, + invocations: Sequence[tuple[Tool, dict[str, Any]]], + *, + context: ToolContext | None = None, + call_ids: Sequence[str] | None = None, + ) -> ToolExecution: + if not invocations: + return ToolExecution(tool_results=[]) + + results: list[Any] = [] + error: BubError | None = None + gathered = await asyncio.gather( + *( + self._trace_tool_response(tool_obj, tool_args, context, call_ids[index] if call_ids else None) + for index, (tool_obj, tool_args) in enumerate(invocations) + ), + return_exceptions=True, + ) + for result in gathered: + if isinstance(result, _FailedToolResult): + error = result.error + results.append(result.error.as_dict() if result.result is None else result.result) + elif isinstance(result, BubError): + error = result + results.append(result.as_dict()) + elif isinstance(result, BaseException): + raise result + else: + results.append(result) + + return ToolExecution(tool_results=results, error=error) + + async def _trace_tool_response( + self, + tool: Tool, + arguments: dict[str, Any], + context: ToolContext | None, + call_id: str | None, + ) -> Any: + span = Span( + f"execute_tool {tool.name}", + { + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": tool.name, + "gen_ai.tool.type": "function", + "gen_ai.tool.call.id": call_id, + "bub.run_id": context.run_id if context else None, + "gen_ai.conversation.id": context.state.get("session_id") if context else None, + }, + ) + try: + with span.activate(): + result = await self._handle_tool_response_async(tool, arguments, context, span=span) + if isinstance(result, _FailedToolResult): + span.fail(result.error) + span.set(**{ + "gen_ai.tool.call.result": result.error.as_dict() if result.result is None else result.result + }) + else: + span.set(**{"gen_ai.tool.call.result": result}) + return result + except BaseException as exc: + span.fail(exc) + raise + finally: + span.end() + + def _invoke_tool( + self, + *, + tool_name: str, + tool_obj: Tool, + tool_args: dict[str, Any], + context: ToolContext | None, + ) -> Any: + if tool_obj.context: + if context is None: + raise BubError(ErrorKind.INVALID_INPUT, f"Tool '{tool_name}' requires context but none was provided.") + return tool_obj.run(context=context, **tool_args) + return tool_obj.run(**tool_args) + + async def _handle_tool_response_async( + self, + tool_obj: Tool, + tool_args: dict[str, Any], + context: ToolContext | None, + *, + span: Span | None = None, + ) -> Any: + tool_name = tool_obj.name + call = ToolCall( + run_id=(context.run_id if context is not None else None) or "", + tool=tool_name, + arguments=dict(tool_args), + context=context, + ) + hook_state = context.state if context is not None else {} + if self._events is not None and context is not None: + hook_state["_runtime_tape"] = context.tape + started = time.monotonic() + call, short_circuit = await self._apply_before_tool_call(call, hook_state, started) + if span is not None: + span.set(**{"gen_ai.tool.call.arguments": call.arguments}) + if short_circuit is not None: + return short_circuit() + + _log_tool_call(call) + invoked = time.monotonic() + try: + result = await self._invoke_normalized(tool_obj, call, context) + except BubError as exc: + elapsed_ms = (time.monotonic() - invoked) * 1000 + logger.opt(exception=exc).error("tool.call.error name={} elapsed_time={:.2f}ms", tool_name, elapsed_ms) + outcome = await self._fire_after_tool_call(call, hook_state, started, error=exc) + return _FailedToolResult(error=exc, result=outcome.result) + else: + elapsed_ms = (time.monotonic() - invoked) * 1000 + logger.info("tool.call.success name={} elapsed_time={:.2f}ms", tool_name, elapsed_ms) + outcome = await self._fire_after_tool_call(call, hook_state, started, result=result) + return outcome.result + + async def _invoke_normalized(self, tool_obj: Tool, call: ToolCall, context: ToolContext | None) -> Any: + """Run the tool with errors normalized to BubError; render the result if configured to.""" + + tool_name = tool_obj.name + try: + value = self._invoke_tool( + tool_name=tool_name, + tool_obj=tool_obj, + tool_args=call.arguments, + context=context, + ) + if inspect.isawaitable(value): + value = await value + # Content results already carry model-facing text and media. + if self._render and not is_content_result(value): + value = tool_obj.render(value) + except BubError: + raise + except ValidationError as exc: + raise BubError( + ErrorKind.INVALID_INPUT, + f"Tool '{tool_name}' argument validation failed.", + details={"errors": json.loads(exc.json())}, + ) from exc + except Exception as exc: + raise BubError( + ErrorKind.TOOL, + f"Tool '{tool_name}' execution failed.", + details={"error": repr(exc)}, + ) from exc + return value + + async def _apply_before_tool_call( + self, + call: ToolCall, + hook_state: dict[str, Any], + started: float, + ) -> tuple[ToolCall, Callable[[], Any] | None]: + """Emit BeforeToolCall 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)) + call, decision = before.call, before.decision + if decision.action == "deny": + error = BubError( + ErrorKind.TOOL, + decision.message or f"Tool '{call.tool}' call denied by policy hook.", + ) + outcome = await self._fire_after_tool_call(call, hook_state, started, error=error) + return call, lambda: _FailedToolResult(error=error, result=outcome.result) + if decision.action == "replace": + outcome = await self._fire_after_tool_call(call, hook_state, started, result=decision.result) + return call, lambda: outcome.result + return call, None + + async def _fire_after_tool_call( + self, + call: ToolCall, + state: dict[str, Any], + started: float, + *, + result: Any = None, + error: Exception | None = None, + ) -> ToolCallResult: + duration_ms = int((time.monotonic() - started) * 1000) + outcome = ToolCallResult( + run_id=call.run_id, + tool=call.tool, + arguments=call.arguments, + result=None if error is not None else result, + error=error, + duration_ms=duration_ms, + ) + if self._events is not None: + await self._events.emit(AfterToolCall(state=state, call=call, result=outcome)) + return outcome + + +# Central registry for tools. Tools defined with the @tool decorator are automatically added here. +REGISTRY: dict[str, Tool] = {} + + +def _shorten_text(text: str, width: int = 30, placeholder: str = "...") -> str: + if len(text) <= width: + return text + + # Reserve space for placeholder + available = width - len(placeholder) + if available <= 0: + return placeholder + + return text[:available] + placeholder + + +def _render_value(value: Any) -> str: + try: + rendered = json.dumps(value, ensure_ascii=False) + except TypeError: + rendered = repr(value) + rendered = _shorten_text(rendered, width=100, placeholder="...") + if rendered.startswith('"') and not rendered.endswith('"'): + rendered = rendered + '"' + if rendered.startswith("{") and not rendered.endswith("}"): + rendered = rendered + "}" + if rendered.startswith("[") and not rendered.endswith("]"): + rendered = rendered + "]" + return rendered + + +def _log_tool_call(call: ToolCall) -> None: + params = [f"{key}={_render_value(value)}" for key, value in call.arguments.items()] + params_str = f" {{ {', '.join(params)} }}" if params else "" + logger.info("tool.call.start name={}{}", call.tool, params_str) + + +@overload +def tool( + func: Callable, + *, + name: str | None = ..., + model: type[BaseModel] | None = ..., + description: str | None = ..., + context: bool = ..., + exposure: ToolExposure = ..., + renderer: Callable[[Any], str] | None = ..., + deferred: bool = ..., +) -> Tool: ... + + +@overload +def tool( + func: None = ..., + *, + name: str | None = ..., + model: type[BaseModel] | None = ..., + description: str | None = ..., + context: bool = ..., + exposure: ToolExposure = ..., + renderer: Callable[[Any], str] | None = ..., + deferred: bool = ..., +) -> Callable[[Callable], Tool]: ... + + +def tool( + func: Callable | None = None, + *, + name: str | None = None, + model: type[BaseModel] | None = None, + description: str | None = None, + context: bool = False, + exposure: ToolExposure = "auto", + renderer: Callable[[Any], str] | None = None, + deferred: bool = False, +) -> Tool | Callable[[Callable], Tool]: + """Decorator to convert a function into a Tool instance. + + Tools should return structured results; ``renderer`` turns such a result into the + plain text shown to the model outside code mode (defaults to JSON for non-strings). + ``exposure`` decides where the tool can be called from (see ``ToolExposure``), and + ``deferred`` tools are loaded on demand through ``tool.describe``. + """ + + def decorator(func: Callable) -> Tool: + if model is not None: + if context and "context" not in inspect.signature(func).parameters: + raise TypeError("Tool context is enabled but the handler lacks a 'context' parameter.") + + def handler(*args: Any, **kwargs: Any) -> Any: + tool_context = kwargs.pop("context", None) + parsed = model(*args, **kwargs) + if context: + return func(parsed, context=tool_context) + return func(parsed) + + result = Tool( + name=name or _to_snake_case(model.__name__), + description=description if description is not None else (inspect.getdoc(func) or model.__doc__ or ""), + parameters=model.model_json_schema(), + handler=handler, + context=context, + exposure=exposure, + renderer=renderer, + output_schema=_output_schema(func), + deferred=deferred, + ) + else: + result = Tool.from_callable( + func, + name=name, + description=description, + context=context, + exposure=exposure, + renderer=renderer, + deferred=deferred, + ) + REGISTRY[result.name] = result + return result + + if func is None: + return decorator + return decorator(func) diff --git a/src/bub/framework.py b/src/bub/framework.py index 600ec6ee..e9300eab 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.sidecars import TapeSidecar +from bub.core.store import AsyncTapeStore, TapeStore +from bub.core.tape import Tape, TapeContext from bub.envelope import Envelope, content_of, field_of, unpack_batch from bub.environment import ENVIRONMENT_STATE_KEY, Environment from bub.errors import BubError, ErrorKind -from bub.hooks.interception import AgentHooks +from bub.hooks.interception import PluginAgentHooks from bub.hooks.runtime import _SKIP_VALUE, HookRuntime from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs from bub.model_selection import ModelOptions from bub.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 @@ -60,7 +60,7 @@ def __init__(self, config_file: Path = DEFAULT_CONFIG_FILE) -> None: self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) self._plugin_manager.add_hookspecs(BubHookSpecs) self._hook_runtime = HookRuntime(self._plugin_manager) - self._agent_hooks = AgentHooks(self._hook_runtime) + self._agent_hooks = PluginAgentHooks(self._hook_runtime) self._plugin_status: dict[str, PluginStatus] = {} self._channel_router: ChannelRouter | None = None self._tape_store: TapeStore | AsyncTapeStore | None = None @@ -471,7 +471,7 @@ def get_steering_inbox(self) -> SteeringInbox | None: """Return the inbox acquired by ``running()``, or None when unavailable.""" return self._steering_inbox - def get_agent_hooks(self) -> AgentHooks: + def get_agent_hooks(self) -> PluginAgentHooks: """Return the model and tool interception adapter for this framework's hooks.""" return self._agent_hooks diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index f94f7d47..a16c9c74 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -1,123 +1,41 @@ -"""Contracts and execution semantics for agent-loop interception hooks.""" +"""Pluggy adapter turning agent-loop events into interception hook calls.""" from __future__ import annotations -from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, Any, Literal +from dataclasses import replace +from typing import Any -import republic from loguru import logger +from bub.core.events import ( + AfterLlmCall, + AfterToolCall, + BeforeLlmCall, + BeforeToolCall, + EventBus, + LlmCallDecision, + LlmCallRequest, + LlmCallResult, + ToolCall, + ToolCallDecision, + ToolCallResult, +) from bub.hooks.runtime import _SKIP_VALUE, HookRuntime from bub.turn import TurnState -if TYPE_CHECKING: - from bub.tools import ToolContext +__all__ = [ + "LlmCallDecision", + "LlmCallRequest", + "LlmCallResult", + "PluginAgentHooks", + "ToolCall", + "ToolCallDecision", + "ToolCallResult", +] -@dataclass(frozen=True) -class LlmCallRequest: - """Outgoing agent-loop LLM request exposed to interception hooks. - - Hooks may return a modified copy (``dataclasses.replace``) to change the - model or messages (``republic.Message`` objects) for this call. Tool objects are not exposed; - ``tool_names`` is observational and altering the toolset is out of scope. - """ - - run_id: str - model: str - messages: list[republic.Message] - tool_names: tuple[str, ...] = () - max_tokens: int | None = None - - -@dataclass(frozen=True) -class LlmCallResult: - """Terminal outcome of one LLM call exposed to interception hooks. - - For streaming completions this is the fully accumulated final state, not - a per-chunk view. ``error`` is the original raised exception (and other - fields are best effort) when the call failed. Cancellation and consumer - close are not observed. - """ - - run_id: str - text: str | None = None - tool_calls: list[dict[str, Any]] = field(default_factory=list) - usage: dict[str, Any] | None = None - error: Exception | None = None - duration_ms: int = 0 - - -@dataclass(frozen=True) -class LlmCallDecision: - """Short-circuit verdict returned by a ``before_llm_call`` hook.""" - - action: Literal["finish"] = "finish" - text: str = "" - - @classmethod - def finish(cls, text: str) -> LlmCallDecision: - """Skip the provider call and emit ``text`` as its final output.""" - - return cls(action="finish", text=text) - - -@dataclass(frozen=True) -class ToolCall: - """One tool invocation exposed to interception hooks. - - ``context`` is the ``ToolContext`` the call runs with, when one is available; for - example, ``context.code_mode`` marks calls made from model-written code (``run_code``). - """ - - run_id: str - tool: str - arguments: dict[str, Any] - context: ToolContext | None = field(default=None, repr=False, compare=False) - - -@dataclass(frozen=True) -class ToolCallDecision: - """Verdict returned by a ``before_tool_call`` hook. - - ``proceed`` continues with optional argument changes. ``replace`` skips - the handler and uses the supplied result. ``deny`` skips the handler and - surfaces the supplied message as a tool error. - """ - - action: Literal["proceed", "replace", "deny"] = "proceed" - arguments: dict[str, Any] | None = None - result: Any = None - message: str | None = None - - @classmethod - def proceed(cls, arguments: dict[str, Any] | None = None) -> ToolCallDecision: - return cls(action="proceed", arguments=arguments) - - @classmethod - def replace(cls, result: Any) -> ToolCallDecision: - return cls(action="replace", result=result) - - @classmethod - def deny(cls, message: str) -> ToolCallDecision: - return cls(action="deny", message=message) - - -@dataclass -class ToolCallResult: - """Terminal outcome exposed to hooks; model-facing results may be replaced in place.""" - - run_id: str - tool: str - arguments: dict[str, Any] - result: Any = None - error: Exception | None = None - duration_ms: int = 0 - - -class AgentHooks: - """Fault-isolated executor for agent-loop interception hooks. +class PluginAgentHooks: + """Fault-isolated adapter running the framework's pluggy interception hooks for agent events. Blocking a tool call is only possible through a returned :class:`ToolCallDecision`; an exception inside ``before_tool_call`` is @@ -127,6 +45,27 @@ class AgentHooks: def __init__(self, runtime: HookRuntime) -> None: self._runtime = runtime + def subscribe(self, events: EventBus | None = None) -> EventBus: + """Handle an agent's ``Before/After*Call`` events with the plugin hooks; return the bus.""" + events = EventBus() if events is None else events + events.on(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) + return events + + async def _on_before_llm_call(self, event: BeforeLlmCall) -> None: + event.request, event.decision = await self.before_llm_call(event.request, event.state) + + async def _on_after_llm_call(self, event: AfterLlmCall) -> None: + await self.after_llm_call(event.request, event.result, event.state) + + async def _on_before_tool_call(self, event: BeforeToolCall) -> None: + event.call, event.decision = await self.before_tool_call(event.call, event.state) + + async def _on_after_tool_call(self, event: AfterToolCall) -> 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]: diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index 24b4f5ad..50f1619c 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -10,9 +10,7 @@ from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import MessageHandler -from bub.envelope import Envelope -from bub.environment import Environment -from bub.hooks.interception import ( +from bub.core.events import ( LlmCallDecision, LlmCallRequest, LlmCallResult, @@ -20,12 +18,14 @@ ToolCallDecision, ToolCallResult, ) +from bub.core.sidecars import TapeSidecar +from bub.core.store import AsyncTapeStore, TapeStore +from bub.core.tape import Tape, TapeContext +from bub.envelope import Envelope +from bub.environment import Environment from bub.model_selection import ModelOptions from bub.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 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/sidecars.py b/src/bub/sidecars.py index 2ca767ee..88e59846 100644 --- a/src/bub/sidecars.py +++ b/src/bub/sidecars.py @@ -1,20 +1,8 @@ -"""Contracts for tapes mounted beside a session tape.""" +"""Compatibility alias for :mod:`bub.core.sidecars`.""" -from __future__ import annotations +import sys -from typing import Protocol +from bub.core import sidecars as _module +from bub.core.sidecars import * # noqa: F403 -SIDECAR_TAPE_MARKER = "__sidecar__" - - -class TapeSidecar(Protocol): - """A named capability backed by a sibling tape.""" - - @property - def name(self) -> str: ... - - -def sidecar_tape_name(owner: str, sidecar: str) -> str: - """Return the physical tape name for a mounted sidecar.""" - - return f"{owner}{SIDECAR_TAPE_MARKER}{sidecar}" +sys.modules[__name__] = _module diff --git a/src/bub/store.py b/src/bub/store.py index 06184baf..3fd8aeca 100644 --- a/src/bub/store.py +++ b/src/bub/store.py @@ -1,560 +1,8 @@ -from __future__ import annotations +"""Compatibility alias for :mod:`bub.core.store`.""" -import asyncio -import inspect -import itertools -import json -import re -import threading -from collections.abc import Coroutine, Iterable, Sequence -from dataclasses import asdict, dataclass, field, replace -from datetime import UTC, datetime, time -from datetime import date as date_type -from pathlib import Path -from typing import Any, NoReturn, Protocol, Self, overload +import sys -from loguru import logger -from typing_extensions import TypeIs +from bub.core import store as _module +from bub.core.store import * # noqa: F403 -from bub.errors import BubError, ErrorKind -from bub.tape import TapeEntry -from bub.utils import get_entry_text - -WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") -MIN_FUZZY_QUERY_LENGTH = 3 -MIN_FUZZY_SCORE = 80 -MAX_FUZZY_CANDIDATES = 128 - - -class TapeStore(Protocol): - """Append-only tape storage interface.""" - - def list_tapes(self) -> list[str]: ... - - def reset(self, tape: str) -> None: ... - - def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: ... - - def append(self, tape: str, entry: TapeEntry) -> None: ... - - -class AsyncTapeStore(Protocol): - """Async append-only tape storage interface.""" - - async def list_tapes(self) -> list[str]: ... - - async def reset(self, tape: str) -> None: ... - - async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: ... - - async def append(self, tape: str, entry: TapeEntry) -> None: ... - - -def is_async_tape_store(store: TapeStore | AsyncTapeStore) -> TypeIs[AsyncTapeStore]: - return hasattr(store, "append") and inspect.iscoroutinefunction(store.append) - - -@dataclass(frozen=True) -class TapeQuery[T: TapeStore | AsyncTapeStore]: - tape: str - store: T - _query: str | None = None - _after_anchor: str | None = None - _after_last: bool = False - _between_anchors: tuple[str, str] | None = None - _between_dates: tuple[str, str] | None = None - _kinds: tuple[str, ...] = field(default_factory=tuple) - _limit: int | None = None - - def query(self, value: str) -> Self: - return replace(self, _query=value) - - def after_anchor(self, name: str) -> Self: - if not name: - return replace(self, _after_anchor=None, _after_last=False) - return replace(self, _after_anchor=name, _after_last=False) - - def last_anchor(self) -> Self: - return replace(self, _after_anchor=None, _after_last=True) - - def between_anchors(self, start: str, end: str) -> Self: - return replace(self, _between_anchors=(start, end)) - - def between_dates(self, start: str | date_type, end: str | date_type) -> Self: - start_value = start.isoformat() if isinstance(start, date_type) else start - end_value = end.isoformat() if isinstance(end, date_type) else end - return replace(self, _between_dates=(start_value, end_value)) - - def kinds(self, *kinds: str) -> Self: - return replace(self, _kinds=kinds) - - def limit(self, value: int) -> Self: - return replace(self, _limit=value) - - @overload - def all(self: TapeQuery[TapeStore]) -> Iterable[TapeEntry]: ... - - @overload - async def all(self: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: ... - - def all(self) -> Iterable[TapeEntry] | Coroutine[None, None, Iterable[TapeEntry]]: - return self.store.fetch_all(self) - - -def _anchor_index( - entries: Sequence[TapeEntry], - name: str | None, - *, - default: int, - forward: bool, - start: int = 0, -) -> int: - rng = range(start, len(entries)) if forward else range(len(entries) - 1, start - 1, -1) - for idx in rng: - entry = entries[idx] - if entry.kind != "anchor": - continue - if name is not None and entry.payload.get("name") != name: - continue - return idx - return default - - -def _parse_datetime_boundary(value: str, *, is_end: bool) -> datetime: - if "T" not in value and " " not in value: - try: - parsed_date = date_type.fromisoformat(value) - except ValueError: - pass - else: - boundary_time = time.max if is_end else time.min - return datetime.combine(parsed_date, boundary_time, tzinfo=UTC) - try: - parsed = datetime.fromisoformat(value) - except ValueError: - try: - parsed_date = date_type.fromisoformat(value) - except ValueError as exc: - raise BubError(ErrorKind.INVALID_INPUT, f"Invalid ISO date or datetime: '{value}'.") from exc - boundary_time = time.max if is_end else time.min - parsed = datetime.combine(parsed_date, boundary_time, tzinfo=UTC) - if parsed.tzinfo is None: - return parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC) - - -def _entry_in_datetime_range(entry: TapeEntry, start_dt: datetime, end_dt: datetime) -> bool: - entry_dt = _parse_datetime_boundary(entry.date, is_end=False) - return start_dt <= entry_dt <= end_dt - - -def _entry_matches_query(entry: TapeEntry, query: str) -> bool: - needle = query.casefold() - haystack = json.dumps( - { - "kind": entry.kind, - "date": entry.date, - "payload": entry.payload, - "meta": entry.meta, - }, - sort_keys=True, - default=str, - ).casefold() - return needle in haystack - - -class InMemoryQueryMixin: - """Mixin to implement in-memory query support for simple stores.""" - - def read(self, tape: str) -> list[TapeEntry] | None: - raise NotImplementedError("InMemoryQueryMixin requires a read() method to be implemented.") - - def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: # noqa: C901 - entries = self.read(query.tape) or [] - start_index = 0 - end_index: int | None = None - - if query._between_anchors is not None: - start_name, end_name = query._between_anchors - start_idx = _anchor_index(entries, start_name, default=-1, forward=False) - if start_idx < 0: - raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{start_name}' was not found.") - end_idx = _anchor_index(entries, end_name, default=-1, forward=True, start=start_idx + 1) - if end_idx < 0: - raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{end_name}' was not found.") - start_index = min(start_idx + 1, len(entries)) - end_index = min(max(start_index, end_idx), len(entries)) - elif query._after_last: - anchor_index = _anchor_index(entries, None, default=-1, forward=False) - if anchor_index < 0: - raise BubError(ErrorKind.NOT_FOUND, "No anchors found in tape.") - start_index = min(anchor_index + 1, len(entries)) - elif query._after_anchor is not None: - anchor_index = _anchor_index(entries, query._after_anchor, default=-1, forward=False) - if anchor_index < 0: - raise BubError(ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found.") - start_index = min(anchor_index + 1, len(entries)) - - sliced = entries[start_index:end_index] - if query._between_dates is not None: - start_date, end_date = query._between_dates - start_dt = _parse_datetime_boundary(start_date, is_end=False) - end_dt = _parse_datetime_boundary(end_date, is_end=True) - if start_dt > end_dt: - raise BubError(ErrorKind.INVALID_INPUT, "Start date must be earlier than or equal to end date.") - sliced = [entry for entry in sliced if _entry_in_datetime_range(entry, start_dt, end_dt)] - if query._query: - sliced = [entry for entry in sliced if _entry_matches_query(entry, query._query)] - if query._kinds: - sliced = [entry for entry in sliced if entry.kind in query._kinds] - if query._limit is not None: - sliced = sliced[: query._limit] - return sliced - - -class InMemoryTapeStore(InMemoryQueryMixin): - """In-memory tape storage.""" - - def __init__(self) -> None: - self._tapes: dict[str, list[TapeEntry]] = {} - self._next_id: dict[str, int] = {} - - def list_tapes(self) -> list[str]: - return sorted(self._tapes.keys()) - - def reset(self, tape: str) -> None: - self._tapes.pop(tape, None) - self._next_id.pop(tape, None) - - def read(self, tape: str) -> list[TapeEntry] | None: - entries = self._tapes.get(tape) - if entries is None: - return None - return [entry.copy() for entry in entries] - - def append(self, tape: str, entry: TapeEntry) -> None: - next_id = self._next_id.get(tape, 1) - self._next_id[tape] = next_id + 1 - stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta), entry.date) - self._tapes.setdefault(tape, []).append(stored) - - -class AsyncTapeStoreAdapter: - """Adapt a sync TapeStore to AsyncTapeStore.""" - - def __init__(self, store: TapeStore) -> None: - self._store = store - - async def list_tapes(self) -> list[str]: - return await asyncio.to_thread(self._store.list_tapes) - - async def reset(self, tape: str) -> None: - await asyncio.to_thread(self._store.reset, tape) - - async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: - return await asyncio.to_thread(self._store.fetch_all, query) - - async def append(self, tape: str, entry: TapeEntry) -> None: - await asyncio.to_thread(self._store.append, tape, entry) - - -class UnavailableTapeStore: - """Sync TapeStore sentinel that always fails with a clear message.""" - - def __init__(self, message: str) -> None: - self._message = message - - def _raise(self) -> NoReturn: - raise BubError(ErrorKind.INVALID_INPUT, self._message) - - def list_tapes(self) -> list[str]: - self._raise() - - def reset(self, tape: str) -> None: - self._raise() - - def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: - self._raise() - - def append(self, tape: str, entry: TapeEntry) -> None: - self._raise() - - -class ForkTapeStore: - def __init__(self, parent: AsyncTapeStore, tape: str, *, sidecars: Iterable[str] = ()) -> None: - self._parent = parent - self._store = InMemoryTapeStore() - self._tape = tape - self._sidecars = tuple(dict.fromkeys(sidecars)) - self._managed_tapes = {tape, *self._sidecars} - self._reset_tapes: set[str] = set() - - async def list_tapes(self) -> list[str]: - return await self._parent.list_tapes() - - async def reset(self, tape: str) -> None: - if tape not in self._managed_tapes: - await self._parent.reset(tape) - return - self._store.reset(tape) - self._reset_tapes.add(tape) - - async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: - if query.tape not in self._managed_tapes: - return await self._parent.fetch_all(query) - - parent_entries: Iterable[TapeEntry] = [] - if query.tape not in self._reset_tapes: - try: - parent_entries = await self._parent.fetch_all(query) - except Exception: - parent_entries = [] - this_entries: list[TapeEntry] = [] - for entry in self._store.read(query.tape) or []: - if entry.kind == "anchor": # noqa: SIM102 - if query._after_last or (query._after_anchor and entry.payload.get("name") == query._after_anchor): - this_entries.clear() - parent_entries = [] - continue - if query._kinds and entry.kind not in query._kinds: - continue - this_entries.append(entry) - entries = itertools.chain(parent_entries, this_entries) - return itertools.islice(entries, query._limit) if query._limit is not None else entries - - @staticmethod - def _redact_prompt(prompt: list[dict]) -> Any: - if not isinstance(prompt, list): - return prompt - new_prompt = [] - for part in prompt: - if part.get("type") == "text": - new_prompt.append(part) - return new_prompt - - @staticmethod - def _redact_payload(payload: dict) -> None: - if "content" in payload: - payload["content"] = ForkTapeStore._redact_prompt(payload["content"]) - elif "prompt" in payload: - payload["prompt"] = ForkTapeStore._redact_prompt(payload["prompt"]) - - async def append(self, tape: str, entry: TapeEntry) -> None: - self._redact_payload(entry.payload) - if tape not in self._managed_tapes: - await self._parent.append(tape, entry) - return - self._store.append(tape, entry) - - async def merge_back(self) -> None: - total = 0 - for sidecar in self._sidecars: - entries = self._store.read(sidecar) or [] - try: - if sidecar in self._reset_tapes: - await self._parent.reset(sidecar) - for entry in entries: - await self._parent.append(sidecar, entry) - except Exception as exc: - logger.warning('Failed to merge sidecar "{}" into tape "{}": {}', sidecar, self._tape, exc) - self._store.append( - self._tape, - TapeEntry.event( - "sidecar.merge", - {"tape": sidecar, "status": "error", "error": str(exc)}, - context=False, - ), - ) - else: - total += len(entries) - - if self._tape in self._reset_tapes: - await self._parent.reset(self._tape) - entries = self._store.read(self._tape) or [] - for entry in entries: - await self._parent.append(self._tape, entry) - total += len(entries) - if total: - logger.info('Merged {} entries into tape fork "{}"', total, self._tape) - - -class FileTapeStore(InMemoryQueryMixin): - """TapeStore implementation that persists tapes as JSONL files under a directory.""" - - def __init__(self, directory: Path) -> None: - self._directory = directory - self._directory.mkdir(parents=True, exist_ok=True) - self._tape_files: dict[str, TapeFile] = {} - - def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: - if not query._query: - result: Iterable[TapeEntry] = super().fetch_all(query) - return result - unlimited_query = replace(query, _limit=None) - entries: Iterable[TapeEntry] = super().fetch_all(unlimited_query) - return self._filter_entries(list(entries), query._query, query._limit or 20) - - def _filter_entries(self, entries: list[TapeEntry], query: str, limit: int) -> list[TapeEntry]: - normalized_query = query.strip().lower() - if not normalized_query: - return [] - results: list[TapeEntry] = [] - seen: set[str] = set() - - count = 0 - for entry in reversed(entries): - payload_text = get_entry_text(entry).lower() - if payload_text in seen: - continue - seen.add(payload_text) - - if normalized_query in payload_text or self._is_fuzzy_match(normalized_query, payload_text): - results.append(entry) - count += 1 - if count >= limit: - break - return results - - @staticmethod - def _is_fuzzy_match(normalized_query: str, payload_text: str) -> bool: - from rapidfuzz import fuzz, process - - if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH: - return False - - query_tokens = WORD_PATTERN.findall(normalized_query) - if not query_tokens: - return False - query_phrase = " ".join(query_tokens) - window_size = len(query_tokens) - - source_tokens = WORD_PATTERN.findall(payload_text) - if not source_tokens: - return False - - candidates: list[str] = [] - for token in source_tokens: - candidates.append(token) - if len(candidates) >= MAX_FUZZY_CANDIDATES: - break - - if window_size > 1: - max_window_start = len(source_tokens) - window_size + 1 - for idx in range(max(0, max_window_start)): - candidates.append(" ".join(source_tokens[idx : idx + window_size])) - if len(candidates) >= MAX_FUZZY_CANDIDATES: - break - - best_match = process.extractOne( - query_phrase, - candidates, - scorer=fuzz.WRatio, - score_cutoff=MIN_FUZZY_SCORE, - ) - return best_match is not None - - def _tape_file(self, tape: str) -> TapeFile: - if tape not in self._tape_files: - self._tape_files[tape] = TapeFile(self._directory / f"{tape}.jsonl") - return self._tape_files[tape] - - def list_tapes(self) -> list[str]: - return sorted(file.stem for file in self._directory.glob("*.jsonl") if file.stem.count("__") == 1) - - def reset(self, tape: str) -> None: - self._tape_file(tape).reset() - - def append(self, tape: str, entry: TapeEntry) -> None: - self._tape_file(tape).append(entry) - - def read(self, tape: str) -> list[TapeEntry] | None: - return self._tape_file(tape).read() - - -class TapeFile: - """Helper for one tape file.""" - - def __init__(self, path: Path) -> None: - self.path = path - self._lock = threading.Lock() - self._read_entries: list[TapeEntry] = [] - self._read_offset = 0 - - def _next_id(self) -> int: - if self._read_entries: - return self._read_entries[-1].id + 1 - return 1 - - def _reset(self) -> None: - self._read_entries = [] - self._read_offset = 0 - - def reset(self) -> None: - with self._lock: - if self.path.exists(): - self.path.unlink() - self._reset() - - def read(self) -> list[TapeEntry]: - with self._lock: - return self._read_locked() - - def _read_locked(self) -> list[TapeEntry]: - if not self.path.exists(): - self._reset() - return [] - - file_size = self.path.stat().st_size - if file_size < self._read_offset: - # The file was truncated or replaced, so cached entries are stale. - self._reset() - - with self.path.open("r", encoding="utf-8") as handle: - handle.seek(self._read_offset) - for raw_line in handle: - line = raw_line.strip() - if not line: - continue - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - entry = self.entry_from_payload(payload) - if entry is not None: - self._read_entries.append(entry) - self._read_offset = handle.tell() - - return list(self._read_entries) - - @staticmethod - def entry_from_payload(payload: object) -> TapeEntry | None: - if not isinstance(payload, dict): - return None - entry_id = payload.get("id") - kind = payload.get("kind") - entry_payload = payload.get("payload") - meta = payload.get("meta") - if not isinstance(entry_id, int): - return None - if not isinstance(kind, str): - return None - if not isinstance(entry_payload, dict): - return None - if not isinstance(meta, dict): - meta = {} - if "date" in payload: - date = payload["date"] - else: - date = datetime.fromtimestamp(payload.get("timestamp", 0.0), tz=UTC).isoformat() - return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), date) - - def append(self, entry: TapeEntry) -> None: - with self._lock: - # Keep cache and offset in sync before allocating new IDs. - self._read_locked() - with self.path.open("a", encoding="utf-8") as handle: - next_id = self._next_id() - stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta), entry.date) - handle.write(json.dumps(asdict(stored), ensure_ascii=False) + "\n") - self._read_entries.append(stored) - self._read_offset = handle.tell() +sys.modules[__name__] = _module diff --git a/src/bub/tape.py b/src/bub/tape.py index b3e26b15..965d1d18 100644 --- a/src/bub/tape.py +++ b/src/bub/tape.py @@ -1,615 +1,8 @@ -"""Append-only tape primitives owned by Bub.""" +"""Compatibility alias for :mod:`bub.core.tape`.""" -from __future__ import annotations +import sys -import contextlib -import hashlib -import inspect -import json -from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping, MutableMapping -from dataclasses import asdict, dataclass, field, replace -from datetime import UTC, datetime -from pathlib import Path -from typing import TYPE_CHECKING, Any +from bub.core import tape as _module +from bub.core.tape import * # noqa: F403 -import republic - -from bub import tracing -from bub.errors import BubError, ErrorKind -from bub.sidecars import TapeSidecar, sidecar_tape_name - -__all__ = [ - "LAST_ANCHOR", - "AnchorSelector", - "AnchorSummary", - "AsyncTapeStore", - "AsyncTapeStoreAdapter", - "ContextSelector", - "InMemoryQueryMixin", - "InMemoryTapeStore", - "SelectedMessages", - "Tape", - "TapeContext", - "TapeEntry", - "TapeInfo", - "TapeQuery", - "TapeStore", - "UnavailableTapeStore", - "build_messages", - "is_async_tape_store", - "to_message", - "to_messages", - "utc_now", -] - -if TYPE_CHECKING: - from bub.store import ( - AsyncTapeStore, - AsyncTapeStoreAdapter, - InMemoryQueryMixin, - InMemoryTapeStore, - TapeQuery, - TapeStore, - UnavailableTapeStore, - is_async_tape_store, - ) - - -_STORE_EXPORTS = frozenset({ - "AsyncTapeStore", - "AsyncTapeStoreAdapter", - "InMemoryQueryMixin", - "InMemoryTapeStore", - "TapeQuery", - "TapeStore", - "UnavailableTapeStore", - "is_async_tape_store", -}) - - -def __getattr__(name: str) -> Any: - if name not in _STORE_EXPORTS: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - from bub import store - - return getattr(store, name) - - -def __dir__() -> list[str]: - return sorted({*globals(), *_STORE_EXPORTS}) - - -def utc_now() -> str: - return datetime.now(UTC).isoformat() - - -@dataclass(frozen=True) -class TapeEntry: - """A single append-only entry in a tape.""" - - id: int - kind: str - payload: dict[str, Any] - meta: dict[str, Any] = field(default_factory=dict) - date: str = field(default_factory=utc_now) - - def copy(self) -> TapeEntry: - return TapeEntry(self.id, self.kind, dict(self.payload), dict(self.meta), self.date) - - @classmethod - def message(cls, message: dict[str, Any], **meta: Any) -> TapeEntry: - return cls(id=0, kind="message", payload=dict(message), meta=dict(meta)) - - @classmethod - def system(cls, content: str, **meta: Any) -> TapeEntry: - return cls(id=0, kind="system", payload={"content": content}, meta=dict(meta)) - - @classmethod - def anchor(cls, name: str, state: dict[str, Any] | None = None, **meta: Any) -> TapeEntry: - payload: dict[str, Any] = {"name": name} - if state is not None: - payload["state"] = dict(state) - return cls(id=0, kind="anchor", payload=payload, meta=dict(meta)) - - @classmethod - def tool_call(cls, calls: list[dict[str, Any]], *, content: str = "", **meta: Any) -> TapeEntry: - payload: dict[str, Any] = {"calls": calls, "content": content} - return cls(id=0, kind="tool_call", payload=payload, meta=dict(meta)) - - @classmethod - def tool_result(cls, results: list[Any], **meta: Any) -> TapeEntry: - return cls(id=0, kind="tool_result", payload={"results": results}, meta=dict(meta)) - - @classmethod - def error(cls, error: BubError, **meta: Any) -> TapeEntry: - return cls(id=0, kind="error", payload=error.as_dict(), meta=dict(meta)) - - @classmethod - def event(cls, name: str, data: dict[str, Any] | None = None, **meta: Any) -> TapeEntry: - payload: dict[str, Any] = {"name": name} - if data is not None: - payload["data"] = dict(data) - return cls(id=0, kind="event", payload=payload, meta=dict(meta)) - - -class _LastAnchor: - def __repr__(self) -> str: - return "LAST_ANCHOR" - - -LAST_ANCHOR = _LastAnchor() -type AnchorSelector = str | None | _LastAnchor -type SelectedMessages = list[republic.Message] | Coroutine[Any, Any, list[republic.Message]] -type ContextSelector = Callable[[Iterable[TapeEntry], "TapeContext"], SelectedMessages] - - -@dataclass(frozen=True) -class TapeContext: - """Rules for selecting tape entries into a prompt context.""" - - anchor: AnchorSelector = LAST_ANCHOR - select: ContextSelector | None = None - state: dict[str, Any] = field(default_factory=dict) - - def build_query(self, query: TapeQuery) -> TapeQuery: - if self.anchor is None: - return query - if isinstance(self.anchor, _LastAnchor): - return query.last_anchor() - return query.after_anchor(self.anchor) - - -def build_messages(entries: Iterable[TapeEntry], context: TapeContext) -> SelectedMessages: - if context.select is not None: - return context.select(entries, context) - return _default_messages(entries) - - -def _default_messages(entries: Iterable[TapeEntry]) -> list[republic.Message]: - return to_messages( - entry.payload for entry in entries if entry.kind == "message" and isinstance(entry.payload, dict) - ) - - -def to_messages(payloads: Iterable[Mapping[str, Any]]) -> list[republic.Message]: - """Convert stored message payloads to Republic messages, resolving tool results to earlier calls.""" - calls: dict[str, republic.ToolCall] = {} - return [to_message(payload, calls) for payload in payloads] - - -def to_message( - payload: Mapping[str, Any], calls: MutableMapping[str, republic.ToolCall] | None = None -) -> republic.Message: - """Convert one stored message payload; assistant tool calls are added to ``calls``. - - Payloads are ``republic.Message.to_dict()`` output. Older Bub payloads, with media URLs, - ``tool_call_id``, ``reasoning`` and ``provider_data`` fields, and tool calls in the Chat - Completions ``{"function": {"name", "arguments"}}`` form, are converted to that form first. - """ - calls = {} if calls is None else calls - role = payload.get("role") - if role not in {"system", "user", "assistant", "tool"}: - raise BubError(ErrorKind.INVALID_INPUT, f"Unknown message role: {role}") - data = dict(payload) - raw_content = data.pop("content", None) - if role == "tool" and raw_content is not None and not isinstance(raw_content, str | list): - from bub.tools import render_result - - raw_content = render_result(raw_content) - content = _content_parts(raw_content) - if isinstance(tool_calls := data.get("tool_calls"), list): - data["tool_calls"] = [_tool_call_dict(call) for call in tool_calls] - if role == "tool" and "tool_call" not in data: - call_id = data.pop("tool_call_id", None) - if not isinstance(call_id, str) or not call_id: - raise BubError(ErrorKind.INVALID_INPUT, "Tool result without a tool call ID.") - # Republic announces calls missing from the conversation before their results. - call = calls.get(call_id) or republic.ToolCall(call_id, data.pop("name", None) or "", "{}") - data["tool_call"] = asdict(call) - if reasoning := data.pop("reasoning", None): - content.append({"type": "reasoning", "text": reasoning}) - content.extend({"type": "provider_data", **item} for item in data.pop("provider_data", None) or []) - data["content"] = content - try: - message = republic.Message.from_dict(data) - except (KeyError, TypeError, ValueError) as exc: - raise BubError(ErrorKind.INVALID_INPUT, f"Invalid {role} message: {exc}") from exc - if role == "system" and any(not isinstance(part, republic.Text) for part in message.parts): - raise BubError(ErrorKind.INVALID_INPUT, "System messages only support text content.") - calls.update((call.id, call) for call in message.tool_calls) - return message - - -def _tool_call_dict(call: object) -> object: - """Convert a Chat Completions tool call, as recorded by older Bub versions, to Republic's form.""" - if not isinstance(call, dict) or not isinstance(function := call.get("function"), dict): - return call - arguments = function.get("arguments") - return { - "id": call.get("id") or "", - "name": function.get("name") or "", - "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments or {}), - } - - -def _content_parts(content: object) -> list[dict[str, Any]]: - if content is None: - return [] - if isinstance(content, str): - return [{"type": "text", "text": content}] - if not isinstance(content, list): - raise BubError(ErrorKind.INVALID_INPUT, "Expected text or a list of message content blocks.") - parts: list[dict[str, Any]] = [] - for block in content: - match block: - case {"type": "image" | "audio" | "video" as kind, "url": str(url), "media_type": str(mime)} if ( - url.startswith("data:") and "data" not in block - ): - # Older payloads keep inline media as data URLs. - media = {"image": republic.image, "audio": republic.audio, "video": republic.video}[kind]( - url, media_type=mime - ) - parts.append({"type": kind, "media_type": media.media_type, "data": media.base64_data}) - case {"type": "image" | "audio" | "video" as kind} if "url" not in block and "data" not in block: - raise BubError(ErrorKind.INVALID_INPUT, f"The {kind} content block needs a url or data.") - case {"type": "text" | "reasoning" | "image" | "audio" | "video" | "provider_data"}: - parts.append(block) - case _: - kind = block.get("type") if isinstance(block, dict) else type(block).__name__ - raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported message content block: {kind}") - return parts - - -@dataclass(frozen=True) -class TapeInfo: - """Runtime tape info summary.""" - - name: str - entries: int - anchors: int - last_anchor: str | None - entries_since_last_anchor: int - last_token_usage: int | None - last_token_cache_hit_rate: float | None - - -@dataclass(frozen=True) -class AnchorSummary: - """Rendered anchor summary.""" - - name: str - state: dict[str, object] - - -@dataclass(frozen=True) -class Tape: - """Tape abstraction for recording agent interactions.""" - - archive_path: Path - store: AsyncTapeStore - context: TapeContext - sidecars: tuple[TapeSidecar, ...] = field(default=(), repr=False) - _name: str | None = field(default=None, repr=False) - - @property - def name(self) -> str: - if self._name is None: - raise ValueError("tape is not scoped") - return self._name - - def with_context(self, context: TapeContext) -> Tape: - return replace(self, context=context) - - def scoped(self, name: str, context: TapeContext | None = None) -> Tape: - return replace(self, context=context or self.context, _name=name) - - def query(self) -> TapeQuery[AsyncTapeStore]: - from bub.store import TapeQuery - - return TapeQuery(tape=self.name, store=self.store) - - def get_sidecar(self, name: str) -> TapeSidecar | None: - """Return a mounted sidecar by its public name.""" - - return next((sidecar for sidecar in self.sidecars if sidecar.name == name), None) - - def sidecar_tape_name(self, name: str) -> str: - """Return the sibling tape name for a mounted sidecar.""" - - if self.get_sidecar(name) is None: - raise KeyError(f"tape sidecar {name!r} is not mounted") - return sidecar_tape_name(self.name, name) - - async def info(self) -> TapeInfo: - entries = list(await self.store.fetch_all(self.query())) - anchors = [(i, entry) for i, entry in enumerate(entries) if entry.kind == "anchor"] - if anchors: - last_anchor = anchors[-1][1].payload.get("name") - entries_since_last_anchor = len(entries) - anchors[-1][0] - 1 - else: - last_anchor = None - entries_since_last_anchor = len(entries) - last_token_usage: int | None = None - last_token_cache_hit_rate: float | None = None - for entry in reversed(entries): - if entry.kind == "event" and entry.payload.get("name") == "run": - data = entry.payload.get("data") - usage = data.get("usage") if isinstance(data, Mapping) else None - if not isinstance(usage, Mapping): - continue - token_usage = usage.get("total_tokens") - if not isinstance(token_usage, int) or isinstance(token_usage, bool): - continue - last_token_usage = token_usage - input_tokens = usage.get("input_tokens") - cached_tokens = usage.get("cached_tokens") - if ( - isinstance(input_tokens, int) - and not isinstance(input_tokens, bool) - and input_tokens > 0 - and isinstance(cached_tokens, int) - and not isinstance(cached_tokens, bool) - ): - last_token_cache_hit_rate = cached_tokens / input_tokens - break - return TapeInfo( - name=self.name, - entries=len(entries), - anchors=len(anchors), - last_anchor=str(last_anchor) if last_anchor else None, - entries_since_last_anchor=entries_since_last_anchor, - last_token_usage=last_token_usage, - last_token_cache_hit_rate=last_token_cache_hit_rate, - ) - - async def ensure_bootstrap_anchor(self) -> None: - anchors = list(await self.store.fetch_all(self.query().kinds("anchor"))) - if not anchors: - await self.handoff(name="session/start", state={"owner": "human"}) - - async def anchors(self, limit: int = 20) -> list[AnchorSummary]: - entries = list(await self.store.fetch_all(self.query().kinds("anchor"))) - results: list[AnchorSummary] = [] - for entry in entries[-limit:]: - name = str(entry.payload.get("name", "-")) - state = entry.payload.get("state") - state_dict: dict[str, object] = dict(state) if isinstance(state, dict) else {} - results.append(AnchorSummary(name=name, state=state_dict)) - return results - - async def search(self, query: TapeQuery[AsyncTapeStore]) -> list[TapeEntry]: - return list(await self.store.fetch_all(query)) - - async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None: - tracing.event(f"bub.{name}", **payload) - await self.store.append(self.name, TapeEntry.event(name, payload, **(tracing.correlation() | meta))) - - async def read_messages(self) -> list[republic.Message]: - query = self.context.build_query(self.query()) - entries = await self.store.fetch_all(query) - context_entries = (entry for entry in entries if entry.meta.get("context") is not False) - messages = build_messages(context_entries, self.context) - if inspect.isawaitable(messages): - messages = await messages - return messages - - async def handoff( - self, - *, - name: str, - state: dict[str, Any] | None = None, - **meta: Any, - ) -> list[TapeEntry]: - tape_name = self.name - meta = tracing.correlation() | meta - tracing.event("bub.handoff", anchor=name, state=state) - entry = TapeEntry.anchor(name, state=state, **meta) - event = TapeEntry.event("handoff", {"name": name, "state": state or {}}, **meta) - await self.store.append(tape_name, entry) - await self.store.append(tape_name, event) - return [entry, event] - - async def record_chat( # noqa: C901 - self, - *, - run_id: str, - system_prompt: str | None, - new_messages: list[republic.Message], - response_text: str | None, - context_error: BubError | None = None, - tool_calls: list[dict[str, Any]] | None = None, - tool_results: list[Any] | None = None, - error: BubError | None = None, - provider: str | None = None, - model: str | None = None, - usage: dict[str, Any] | None = None, - assistant_fields: dict[str, Any] | None = None, - ) -> None: - tape_name = self.name - meta = {"run_id": run_id, **tracing.correlation()} - if system_prompt: - await self.store.append(tape_name, TapeEntry.system(system_prompt, **meta)) - if context_error is not None: - await self.store.append(tape_name, TapeEntry.error(context_error, **meta)) - for message in new_messages: - await self.store.append(tape_name, TapeEntry.message(message.to_dict(), **meta)) - if tool_calls: - call_entry = TapeEntry.tool_call(tool_calls, content=response_text or "", **meta) - call_entry.payload.update(assistant_fields or {}) - await self.store.append(tape_name, call_entry) - if tool_results is not None: - await self.store.append(tape_name, TapeEntry.tool_result(tool_results, **meta)) - if error is not None and error is not context_error: - await self.store.append(tape_name, TapeEntry.error(error, **meta)) - if response_text is not None and not tool_calls: - await self.store.append( - tape_name, - TapeEntry.message({"role": "assistant", "content": response_text, **(assistant_fields or {})}, **meta), - ) - - data: dict[str, Any] = {"status": "error" if error is not None else "ok"} - if usage is not None: - data["usage"] = usage - if provider: - data["provider"] = provider - if model: - data["model"] = model - await self.store.append(tape_name, TapeEntry.event("run", data, **meta)) - - async def _archive_tape(self, tape_name: str, stamp: str) -> Path: - from bub.store import TapeQuery - - self.archive_path.mkdir(parents=True, exist_ok=True) - archive_path = self.archive_path / f"{tape_name}.jsonl.{stamp}.bak" - with archive_path.open("w", encoding="utf-8") as f: - query = TapeQuery(tape=tape_name, store=self.store) - for entry in await self.store.fetch_all(query): - f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n") - return archive_path - - @staticmethod - def _sidecar_lifecycle_data( - *, - sidecar: str, - status: str, - reason: str, - archive_path: Path | None = None, - error: Exception | None = None, - cause: str | None = None, - ) -> dict[str, Any]: - data: dict[str, Any] = {"sidecar": sidecar, "status": status, "reason": reason} - if archive_path is not None: - data["archive"] = str(archive_path) - if error is not None: - data["error"] = str(error) - if cause is not None: - data["cause"] = cause - return data - - async def _try_archive_sidecar( - self, - sidecar: TapeSidecar, - *, - reason: str, - stamp: str | None = None, - ) -> tuple[Path | None, dict[str, Any]]: - archive_stamp = stamp or datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") - try: - archive_path = await self._archive_tape(sidecar_tape_name(self.name, sidecar.name), archive_stamp) - except Exception as exc: - return None, self._sidecar_lifecycle_data( - sidecar=sidecar.name, - status="error", - reason=reason, - error=exc, - ) - return archive_path, self._sidecar_lifecycle_data( - sidecar=sidecar.name, - status="ok", - reason=reason, - archive_path=archive_path, - ) - - async def _try_reset_sidecar(self, sidecar: TapeSidecar, *, reason: str) -> dict[str, Any]: - try: - await self.store.reset(sidecar_tape_name(self.name, sidecar.name)) - except Exception as exc: - return self._sidecar_lifecycle_data(sidecar=sidecar.name, status="error", reason=reason, error=exc) - return self._sidecar_lifecycle_data(sidecar=sidecar.name, status="ok", reason=reason) - - def _require_sidecar(self, name: str) -> TapeSidecar: - sidecar = self.get_sidecar(name) - if sidecar is None: - raise KeyError(f"tape sidecar {name!r} is not mounted") - return sidecar - - async def archive_sidecar(self, name: str, *, reason: str = "manual") -> str: - """Archive one mounted sidecar without changing the main tape.""" - - sidecar = self._require_sidecar(name) - archive_path, event_data = await self._try_archive_sidecar(sidecar, reason=reason) - await self.append_event("sidecar.archive", event_data, context=False) - return ( - f"Archived {name}: {archive_path}" - if archive_path is not None - else f"{name} archive failed: {event_data['error']}" - ) - - async def reset_sidecar(self, name: str, *, archive: bool = False, reason: str = "gc") -> str: - """Reset one mounted sidecar and record the outcome on the main tape.""" - - sidecar = self._require_sidecar(name) - archive_path: Path | None = None - archive_data: dict[str, Any] | None = None - if archive: - archive_path, archive_data = await self._try_archive_sidecar(sidecar, reason=reason) - - if archive_data is not None and archive_data["status"] == "error": - reset_data = self._sidecar_lifecycle_data( - sidecar=name, - status="skipped", - reason=reason, - cause="archive_failed", - ) - else: - reset_data = await self._try_reset_sidecar(sidecar, reason=reason) - if archive_data is not None: - await self.append_event("sidecar.archive", archive_data, context=False) - await self.append_event("sidecar.reset", reset_data, context=False) - - if reset_data["status"] == "error": - return f"{name} reset failed: {reset_data['error']}" - if reset_data["status"] == "skipped" and archive_data is not None: - return f"{name} archive failed: {archive_data['error']}; {name} reset skipped" - return f"Archived {name}: {archive_path}" if archive_path is not None else "ok" - - async def reset(self, *, archive: bool = False) -> str: - archive_path: Path | None = None - sidecar_archives: dict[str, dict[str, Any]] = {} - if archive: - stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") - archive_path = await self._archive_tape(self.name, stamp) - for sidecar in self.sidecars: - _, sidecar_archive = await self._try_archive_sidecar(sidecar, reason="tape.reset", stamp=stamp) - sidecar_archives[sidecar.name] = sidecar_archive - await self.store.reset(self.name) - state = {"owner": "human"} - if archive_path is not None: - state["archived"] = str(archive_path) - await self.handoff(name="session/start", state=state) - for sidecar in self.sidecars: - archive_data = sidecar_archives.get(sidecar.name) - if archive_data is not None and archive_data["status"] == "error": - reset_data = self._sidecar_lifecycle_data( - sidecar=sidecar.name, - status="skipped", - reason="tape.reset", - cause="archive_failed", - ) - else: - reset_data = await self._try_reset_sidecar(sidecar, reason="tape.reset") - if archive_data is not None: - await self.append_event("sidecar.archive", archive_data, context=False) - await self.append_event("sidecar.reset", reset_data, context=False) - return f"Archived: {archive_path}" if archive_path else "ok" - - def session_tape(self, session_id: str, workspace: Path, context: TapeContext | None = None) -> Tape: - workspace_hash = hashlib.md5(str(workspace.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:16] - tape_name = ( - workspace_hash + "__" + hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] - ) - return self.scoped(tape_name, context=context) - - @contextlib.asynccontextmanager - async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[Tape, None]: - from bub.store import ForkTapeStore - - managed_sidecars = tuple(sidecar_tape_name(self.name, sidecar.name) for sidecar in self.sidecars) - fork_store = ForkTapeStore(self.store, self.name, sidecars=managed_sidecars) - forked = replace(self, store=fork_store) - try: - yield forked - finally: - if merge_back: - await fork_store.merge_back() +sys.modules[__name__] = _module diff --git a/src/bub/tools.py b/src/bub/tools.py index 840196e0..76d4ee66 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -1,678 +1,8 @@ -from __future__ import annotations +"""Compatibility alias for :mod:`bub.core.tools`.""" -import asyncio -import contextlib -import contextvars -import inspect -import json -import time -from collections.abc import Awaitable, Callable, Iterable, Sequence -from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, Any, Literal, Protocol, get_args, get_type_hints, overload +import sys -import republic -from loguru import logger -from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call +from bub.core import tools as _module +from bub.core.tools import * # noqa: F403 -from bub.errors import BubError, ErrorKind -from bub.hooks.interception import ToolCall, ToolCallResult -from bub.prompt import UserContent -from bub.tape import Tape -from bub.tracing import Span - -if TYPE_CHECKING: - from bub.hooks.interception import AgentHooks - - -@dataclass(frozen=True) -class ToolContext: - """Runtime context passed to tools that opt into context. - - ``code_mode`` is true when the tool is invoked from model-written code rather than - as a direct model tool call. Whether the result is rendered to text is decided by the - ``ToolExecutor`` running the call, not by this flag. - """ - - tape: Tape - run_id: str | None = None - state: dict[str, Any] = field(default_factory=dict) - code_mode: bool = False - - -def render_result(result: Any) -> str: - """Render a structured tool result as plain text (the default tool renderer).""" - if isinstance(result, str): - return result - if isinstance(result, BaseModel): - return result.model_dump_json() - try: - return json.dumps(result, ensure_ascii=False) - except TypeError: - return str(result) - - -CONTENT_RESULT_TYPE = "tool_content" - - -def content_result(content: Sequence[UserContent]) -> dict[str, Any]: - """Build a model-facing tool result that carries media along with text. - - The result is JSON so it can be recorded on the tape; ``result_content`` reads it back. - """ - blocks = [ - {"type": "text", "text": item} if isinstance(item, str) else republic.user(item).to_dict()["content"][0] - for item in content - ] - return {"type": CONTENT_RESULT_TYPE, "content": blocks} - - -def is_content_result(result: Any) -> bool: - return ( - isinstance(result, dict) - and result.get("type") == CONTENT_RESULT_TYPE - and isinstance(result.get("content"), list) - ) - - -def result_content(result: Any) -> list[UserContent]: - """Return a tool result as content for the model: media of ``content_result`` results, otherwise text.""" - if not is_content_result(result): - return [render_result(result)] - parts = republic.Message.from_dict({"role": "user", "content": result["content"]}).parts - return [ - part.text if isinstance(part, republic.Text) else part - for part in parts - if isinstance(part, republic.Text | republic.Image | republic.Audio | republic.Video) - ] - - -def _to_snake_case(name: str) -> str: - return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") - - -def _callable_name(func: Callable[..., Any]) -> str: - name = getattr(func, "__name__", None) - if isinstance(name, str) and name: - return name - return func.__class__.__name__ - - -def _schema_from_annotation(annotation: Any) -> dict[str, Any]: - if annotation is inspect._empty: - annotation = Any - try: - return TypeAdapter(annotation).json_schema() - except Exception as exc: - raise ValueError(f"Failed to build JSON schema for type: {annotation!r}") from exc - - -def _schema_from_signature(signature: inspect.Signature, *, ignore_params: set[str] | None = None) -> dict[str, Any]: - ignore = ignore_params or set() - properties: dict[str, Any] = {} - required: list[str] = [] - for param in signature.parameters.values(): - if param.name in ignore: - continue - if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): - continue - property_schema = _schema_from_annotation(param.annotation) - if param.default is param.empty: - required.append(param.name) - elif _is_json_value(param.default): - property_schema = {**property_schema, "default": param.default} - properties[param.name] = property_schema - schema: dict[str, Any] = {"type": "object", "properties": properties} - if required: - schema["required"] = required - return schema - - -def _is_json_value(value: Any) -> bool: - try: - json.dumps(value) - except (TypeError, ValueError): - return False - return True - - -def _output_schema(func: Callable[..., Any]) -> dict[str, Any] | None: - """Build a JSON schema for the callable's return annotation, if it can be resolved.""" - try: - annotation = get_type_hints(func).get("return", inspect.Signature.empty) - except Exception: - return None - if annotation is inspect.Signature.empty: - return None - try: - return _schema_from_annotation(annotation) - except ValueError: - return None - - -def _signature_without_context(signature: inspect.Signature) -> inspect.Signature: - parameters = [param for param in signature.parameters.values() if param.name != "context"] - return signature.replace(parameters=parameters) - - -def _validate_without_context(func: Callable[..., Any], signature: inspect.Signature) -> Callable[..., Any]: - def validate_target(*args: Any, **kwargs: Any) -> tuple[tuple[Any, ...], dict[str, Any]]: - return args, kwargs - - validate_target.__name__ = _callable_name(func) - validate_target.__qualname__ = getattr(func, "__qualname__", validate_target.__name__) - # Only arguments are validated; dropping the return annotation also avoids resolving - # forward references to types that live in the tool's module, not this one. - validate_target.__annotations__ = dict(getattr(func, "__annotations__", {})) - validate_target.__annotations__.pop("context", None) - validate_target.__annotations__.pop("return", None) - validate_target.__signature__ = _signature_without_context(signature).replace( # type: ignore[attr-defined] - return_annotation=inspect.Signature.empty - ) - return validate_call(validate_target) - - -ToolExposure = Literal["auto", "direct", "code", "command"] -"""Where a tool can be called from. - -- ``auto``: by the model directly, or only from ``run_code`` in code mode. -- ``direct``: by the model directly, also in code mode; never from ``run_code``. -- ``code``: only from ``run_code`` in code mode; never by the model directly. -- ``command``: only as a comma command; never by the model or from code. -""" - - -@dataclass(frozen=True) -class Tool: - """A callable unit the model can invoke.""" - - name: str - handler: Callable[..., Any] - description: str = "" - parameters: dict[str, Any] = field(default_factory=dict) - context: bool = False - exposure: ToolExposure = "auto" - """Where the tool can be called from, see ``ToolExposure``.""" - renderer: Callable[[Any], str] | None = None - output_schema: dict[str, Any] | None = None - """JSON schema of the structured result, used to describe the tool to model-written code.""" - deferred: bool = False - """Load the tool on demand: only its name is listed until ``tool.describe`` loads its definition.""" - - def __post_init__(self) -> None: - if self.exposure not in get_args(ToolExposure): - raise ValueError(f"Tool '{self.name}' has unknown exposure {self.exposure!r}.") - - def run(self, *args: Any, **kwargs: Any) -> Any: - return self.handler(*args, **kwargs) - - def render(self, result: Any) -> str: - """Render a structured result of this tool as plain text for the model.""" - if self.renderer is None: - return render_result(result) - return self.renderer(result) - - def to_schema(self) -> dict[str, Any]: - """Build a function tool schema for hooks and diagnostics.""" - return {"name": self.name, "description": self.description, "parameters": self.parameters} - - @classmethod - def from_callable( - cls, - func: Callable[..., Any], - *, - name: str | None = None, - description: str | None = None, - context: bool = False, - exposure: ToolExposure = "auto", - renderer: Callable[[Any], str] | None = None, - deferred: bool = False, - ) -> Tool: - signature = inspect.signature(func) - if context and "context" not in signature.parameters: - raise TypeError("Tool context is enabled but the callable lacks a 'context' parameter.") - tool_name = name or _to_snake_case(_callable_name(func)) - tool_description = description if description is not None else (inspect.getdoc(func) or "") - parameters = _schema_from_signature(signature, ignore_params={"context"} if context else None) - if context: - validate_args = _validate_without_context(func, signature) - - def validated(*args: Any, **kwargs: Any) -> Any: - tool_context = kwargs.pop("context") - validated_args, validated_kwargs = validate_args(*args, **kwargs) - return func(*validated_args, context=tool_context, **validated_kwargs) - - else: - validated = validate_call(config=ConfigDict(arbitrary_types_allowed=True))(func) - return cls( - name=tool_name, - description=tool_description, - parameters=parameters, - handler=validated, - context=context, - exposure=exposure, - renderer=renderer, - output_schema=_output_schema(func), - deferred=deferred, - ) - - -def model_tools(tools: Iterable[Tool]) -> list[Tool]: - """Convert tools callable by the model or code into model-safe aliases; comma commands are dropped.""" - return [ - replace(tool_item, name=tool_item.name.replace(".", "_")) - for tool_item in tools - if tool_item.exposure != "command" - ] - - -@dataclass(frozen=True) -class ToolExecution: - tool_results: list[Any] = field(default_factory=list) - error: BubError | None = None - - -@dataclass(frozen=True) -class _FailedToolResult: - error: BubError - result: Any = None - - -class ToolCallReporter(Protocol): - def start(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Awaitable[None] | None: ... - - def success(self, name: str, result: Any, elapsed_ms: float) -> Awaitable[None] | None: ... - - def error(self, name: str, error: BaseException, elapsed_ms: float) -> Awaitable[None] | None: ... - - -_TOOL_CALL_REPORTER: contextvars.ContextVar[ToolCallReporter | None] = contextvars.ContextVar( - "bub_tool_call_reporter", default=None -) - - -@contextlib.contextmanager -def tool_call_reporter(reporter: ToolCallReporter): - token = _TOOL_CALL_REPORTER.set(reporter) - try: - yield - finally: - _TOOL_CALL_REPORTER.reset(token) - - -async def _await_report(report: Awaitable[None] | None) -> None: - if report is not None: - await report - - -class ToolExecutor: - """Execute already-resolved Bub tool invocations. - - With ``render`` (the default) successful results are rendered to model-facing text with - ``Tool.render``; ``render=False`` returns tools' structured results unchanged, as needed - when the caller is code rather than the model. - """ - - def __init__(self, hooks: AgentHooks | None = None, *, render: bool = True) -> None: - self._hooks = hooks - self._render = render - - async def execute_async( - self, - invocations: Sequence[tuple[Tool, dict[str, Any]]], - *, - context: ToolContext | None = None, - call_ids: Sequence[str] | None = None, - ) -> ToolExecution: - if not invocations: - return ToolExecution(tool_results=[]) - - results: list[Any] = [] - error: BubError | None = None - gathered = await asyncio.gather( - *( - self._trace_tool_response(tool_obj, tool_args, context, call_ids[index] if call_ids else None) - for index, (tool_obj, tool_args) in enumerate(invocations) - ), - return_exceptions=True, - ) - for result in gathered: - if isinstance(result, _FailedToolResult): - error = result.error - results.append(result.error.as_dict() if result.result is None else result.result) - elif isinstance(result, BubError): - error = result - results.append(result.as_dict()) - elif isinstance(result, BaseException): - raise result - else: - results.append(result) - - return ToolExecution(tool_results=results, error=error) - - async def _trace_tool_response( - self, - tool: Tool, - arguments: dict[str, Any], - context: ToolContext | None, - call_id: str | None, - ) -> Any: - span = Span( - f"execute_tool {tool.name}", - { - "gen_ai.operation.name": "execute_tool", - "gen_ai.tool.name": tool.name, - "gen_ai.tool.type": "function", - "gen_ai.tool.call.id": call_id, - "bub.run_id": context.run_id if context else None, - "gen_ai.conversation.id": context.state.get("session_id") if context else None, - }, - ) - try: - with span.activate(): - result = await self._handle_tool_response_async(tool, arguments, context, span=span) - if isinstance(result, _FailedToolResult): - span.fail(result.error) - span.set(**{ - "gen_ai.tool.call.result": result.error.as_dict() if result.result is None else result.result - }) - else: - span.set(**{"gen_ai.tool.call.result": result}) - return result - except BaseException as exc: - span.fail(exc) - raise - finally: - span.end() - - def _invoke_tool( - self, - *, - tool_name: str, - tool_obj: Tool, - tool_args: dict[str, Any], - context: ToolContext | None, - ) -> Any: - if tool_obj.context: - if context is None: - raise BubError(ErrorKind.INVALID_INPUT, f"Tool '{tool_name}' requires context but none was provided.") - return tool_obj.run(context=context, **tool_args) - return tool_obj.run(**tool_args) - - async def _handle_tool_response_async( - self, - tool_obj: Tool, - tool_args: dict[str, Any], - context: ToolContext | None, - *, - span: Span | None = None, - ) -> Any: - tool_name = tool_obj.name - call = ToolCall( - run_id=(context.run_id if context is not None else None) or "", - tool=tool_name, - arguments=dict(tool_args), - context=context, - ) - hook_state = context.state if context is not None else {} - if self._hooks is not None and context is not None: - hook_state["_runtime_tape"] = context.tape - started = time.monotonic() - call, short_circuit = await self._apply_before_tool_call(call, hook_state, started) - if span is not None: - span.set(**{"gen_ai.tool.call.arguments": call.arguments}) - if short_circuit is not None: - return short_circuit() - - try: - result = await self._invoke_normalized(tool_obj, call, context) - except BubError as exc: - outcome = await self._fire_after_tool_call(call, hook_state, started, error=exc) - return _FailedToolResult(error=exc, result=outcome.result) - else: - outcome = await self._fire_after_tool_call(call, hook_state, started, result=result) - return outcome.result - - async def _invoke_normalized(self, tool_obj: Tool, call: ToolCall, context: ToolContext | None) -> Any: - """Run the tool with errors normalized to BubError; render the result if configured to.""" - - tool_name = tool_obj.name - try: - value = self._invoke_tool( - tool_name=tool_name, - tool_obj=tool_obj, - tool_args=call.arguments, - context=context, - ) - if inspect.isawaitable(value): - value = await value - # Content results already carry model-facing text and media. - if self._render and not is_content_result(value): - value = tool_obj.render(value) - except BubError: - raise - except ValidationError as exc: - raise BubError( - ErrorKind.INVALID_INPUT, - f"Tool '{tool_name}' argument validation failed.", - details={"errors": json.loads(exc.json())}, - ) from exc - except Exception as exc: - raise BubError( - ErrorKind.TOOL, - f"Tool '{tool_name}' execution failed.", - details={"error": repr(exc)}, - ) from exc - return value - - async def _apply_before_tool_call( - self, - call: ToolCall, - hook_state: dict[str, Any], - started: float, - ) -> tuple[ToolCall, Callable[[], Any] | None]: - """Run before_tool_call and translate deny/replace into a short-circuit thunk.""" - - if self._hooks is None: - return call, None - call, decision = await self._hooks.before_tool_call(call, state=hook_state) - if decision.action == "deny": - error = BubError( - ErrorKind.TOOL, - decision.message or f"Tool '{call.tool}' call denied by policy hook.", - ) - outcome = await self._fire_after_tool_call(call, hook_state, started, error=error) - return call, lambda: _FailedToolResult(error=error, result=outcome.result) - if decision.action == "replace": - outcome = await self._fire_after_tool_call(call, hook_state, started, result=decision.result) - return call, lambda: outcome.result - return call, None - - async def _fire_after_tool_call( - self, - call: ToolCall, - state: dict[str, Any], - started: float, - *, - result: Any = None, - error: Exception | None = None, - ) -> ToolCallResult: - duration_ms = int((time.monotonic() - started) * 1000) - outcome = ToolCallResult( - run_id=call.run_id, - tool=call.tool, - arguments=call.arguments, - result=None if error is not None else result, - error=error, - duration_ms=duration_ms, - ) - if self._hooks is not None: - await self._hooks.after_tool_call(call, outcome, state=state) - return outcome - - -# Central registry for tools. Tools defined with the @tool decorator are automatically added here. -REGISTRY: dict[str, Tool] = {} - - -def _add_logging(tool: Tool) -> Tool: - handler = tool.handler - - async def wrapped(*args, **kwargs): - call_kwargs = kwargs.copy() - if tool.context: - call_kwargs.pop("context", None) - reporter = _TOOL_CALL_REPORTER.get() - if reporter is None: - _log_tool_call(tool.name, args, call_kwargs) - else: - await _await_report(reporter.start(tool.name, args, call_kwargs)) - start = time.monotonic() - - try: - result = handler(*args, **kwargs) - if inspect.isawaitable(result): - result = await result - except Exception as exc: - elapsed_time = (time.monotonic() - start) * 1000 - if reporter is None: - logger.exception("tool.call.error name={} elapsed_time={:.2f}ms", tool.name, elapsed_time) - else: - await _await_report(reporter.error(tool.name, exc, elapsed_time)) - raise - else: - elapsed_time = (time.monotonic() - start) * 1000 - if reporter is None: - logger.info("tool.call.success name={} elapsed_time={:.2f}ms", tool.name, elapsed_time) - else: - await _await_report(reporter.success(tool.name, result, elapsed_time)) - return result - - return replace(tool, handler=wrapped) - - -def _shorten_text(text: str, width: int = 30, placeholder: str = "...") -> str: - if len(text) <= width: - return text - - # Reserve space for placeholder - available = width - len(placeholder) - if available <= 0: - return placeholder - - return text[:available] + placeholder - - -def _render_value(value: Any) -> str: - try: - rendered = json.dumps(value, ensure_ascii=False) - except TypeError: - rendered = repr(value) - rendered = _shorten_text(rendered, width=100, placeholder="...") - if rendered.startswith('"') and not rendered.endswith('"'): - rendered = rendered + '"' - if rendered.startswith("{") and not rendered.endswith("}"): - rendered = rendered + "}" - if rendered.startswith("[") and not rendered.endswith("]"): - rendered = rendered + "]" - return rendered - - -def _log_tool_call(name: str, args: Any, kwargs: dict[str, Any]) -> None: - params: list[str] = [] - - for value in args: - params.append(_render_value(value)) - for key, value in kwargs.items(): - rendered = _render_value(value) - params.append(f"{key}={rendered}") - params_str = f" {{ {', '.join(params)} }}" if params else "" - logger.info("tool.call.start name={}{}", name, params_str) - - -@overload -def tool( - func: Callable, - *, - name: str | None = ..., - model: type[BaseModel] | None = ..., - description: str | None = ..., - context: bool = ..., - exposure: ToolExposure = ..., - renderer: Callable[[Any], str] | None = ..., - deferred: bool = ..., -) -> Tool: ... - - -@overload -def tool( - func: None = ..., - *, - name: str | None = ..., - model: type[BaseModel] | None = ..., - description: str | None = ..., - context: bool = ..., - exposure: ToolExposure = ..., - renderer: Callable[[Any], str] | None = ..., - deferred: bool = ..., -) -> Callable[[Callable], Tool]: ... - - -def tool( - func: Callable | None = None, - *, - name: str | None = None, - model: type[BaseModel] | None = None, - description: str | None = None, - context: bool = False, - exposure: ToolExposure = "auto", - renderer: Callable[[Any], str] | None = None, - deferred: bool = False, -) -> Tool | Callable[[Callable], Tool]: - """Decorator to convert a function into a Tool instance. - - Tools should return structured results; ``renderer`` turns such a result into the - plain text shown to the model outside code mode (defaults to JSON for non-strings). - ``exposure`` decides where the tool can be called from (see ``ToolExposure``), and - ``deferred`` tools are loaded on demand through ``tool.describe``. - """ - - def decorator(func: Callable) -> Tool: - if model is not None: - if context and "context" not in inspect.signature(func).parameters: - raise TypeError("Tool context is enabled but the handler lacks a 'context' parameter.") - - def handler(*args: Any, **kwargs: Any) -> Any: - tool_context = kwargs.pop("context", None) - parsed = model(*args, **kwargs) - if context: - return func(parsed, context=tool_context) - return func(parsed) - - result = Tool( - name=name or _to_snake_case(model.__name__), - description=description if description is not None else (inspect.getdoc(func) or model.__doc__ or ""), - parameters=model.model_json_schema(), - handler=handler, - context=context, - exposure=exposure, - renderer=renderer, - output_schema=_output_schema(func), - deferred=deferred, - ) - else: - result = Tool.from_callable( - func, - name=name, - description=description, - context=context, - exposure=exposure, - renderer=renderer, - deferred=deferred, - ) - tool_instance = _add_logging(result) - REGISTRY[tool_instance.name] = tool_instance - return tool_instance - - if func is None: - return decorator - return decorator(func) +sys.modules[__name__] = _module diff --git a/src/bub/types.py b/src/bub/types.py deleted file mode 100644 index 7518373d..00000000 --- a/src/bub/types.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Deprecated compatibility aliases for the former shared type module.""" - -import warnings - -from bub.channels.contracts import MessageHandler -from bub.envelope import Envelope -from bub.turn import TurnState as State - -__all__ = ["Envelope", "MessageHandler", "State"] - -warnings.warn( - "`bub.types` is deprecated; use `bub.envelope.Envelope`, `bub.turn.TurnState`, " - "and `bub.channels.contracts.MessageHandler` instead.", - DeprecationWarning, - stacklevel=2, -) diff --git a/src/bub/utils.py b/src/bub/utils.py index aab1af4a..73882cd3 100644 --- a/src/bub/utils.py +++ b/src/bub/utils.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any -from bub.tape import TapeEntry +from bub.core.tape import TapeEntry from bub.turn import TurnState diff --git a/tests/model_fakes.py b/tests/model_fakes.py index cea7755e..ee8f98e6 100644 --- a/tests/model_fakes.py +++ b/tests/model_fakes.py @@ -3,10 +3,47 @@ from __future__ import annotations import json +from collections.abc import Sequence from typing import Any import httpx2 +from bub.builtin.settings import AgentSettings +from bub.core import Agent as CoreAgent +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: + """Run one model step through the core agent, assembled from settings like the builtin agent.""" + + def __init__(self, settings: AgentSettings, hooks: PluginAgentHooks | None = None) -> None: + self.settings = settings + self.hooks = hooks + + def run( + self, + *, + tape: Tape, + model: str, + tools: Sequence[Tool], + system_prompt: str | None, + prompt: list[UserContent] | None, + steering_messages: list[list[UserContent]] | None = None, + ) -> AsyncStreamEvents: + class Agent(CoreAgent): + async def pending_messages(self, tape: Tape) -> list[list[UserContent]]: + return steering_messages or [] + + chat_model = self.settings.chat_model(model, reasoning_effort=tape.context.state.get("reasoning_effort")) + agent = Agent(store=tape.store, tools=tools, system_prompt=system_prompt or ()) + if self.hooks is not None: + self.hooks.subscribe(agent.events) + return agent.run_once(prompt, model=chat_model, tape=tape) + def sse(events: list[Any]) -> httpx2.Response: return httpx2.Response( diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 33a8885a..59d65f5f 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -11,15 +11,14 @@ import republic from republic.errors import StreamIncompleteError -from bub.builtin.model_runner import ModelRunner from bub.builtin.settings import AgentSettings from bub.errors import BubError from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import ( - AgentHooks, LlmCallDecision, LlmCallRequest, LlmCallResult, + PluginAgentHooks, ToolCall, ToolCallDecision, ToolCallResult, @@ -29,15 +28,15 @@ from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore from bub.tape import Tape, TapeContext from bub.tools import Tool, ToolExecutor -from tests.model_fakes import ProviderService, sse +from tests.model_fakes import ProviderService, StepRunner, sse -def make_hooks(*plugins: Any) -> AgentHooks: +def make_hooks(*plugins: Any) -> PluginAgentHooks: pm = pluggy.PluginManager(BUB_HOOK_NAMESPACE) pm.add_hookspecs(BubHookSpecs) for plugin in plugins: pm.register(plugin) - return AgentHooks(HookRuntime(pm)) + return PluginAgentHooks(HookRuntime(pm)) def request() -> LlmCallRequest: @@ -156,8 +155,8 @@ class Deny: def before_tool_call(self, call: ToolCall, state: dict) -> ToolCallDecision: return ToolCallDecision.deny("blocked by policy") - executor = ToolExecutor(hooks=make_hooks(Deny())) - execution = await executor.execute_async([(self.tool(), {"cmd": "rm -rf /"})]) + executor = ToolExecutor(make_hooks(Deny()).subscribe()) + execution = await executor.execute_many([(self.tool(), {"cmd": "rm -rf /"})]) assert execution.error is not None assert "blocked by policy" in execution.error.message assert execution.tool_results[0]["message"] == "blocked by policy" @@ -169,8 +168,8 @@ class Replace: def before_tool_call(self, call: ToolCall, state: dict) -> ToolCallDecision: return ToolCallDecision.replace("cached result") - executor = ToolExecutor(hooks=make_hooks(Replace())) - execution = await executor.execute_async([(self.tool(), {"cmd": "ls"})]) + executor = ToolExecutor(make_hooks(Replace()).subscribe()) + execution = await executor.execute_many([(self.tool(), {"cmd": "ls"})]) assert execution.error is None assert execution.tool_results == ["cached result"] @@ -186,9 +185,9 @@ def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict) - def failing(cmd: str) -> str: raise ValueError("nope") - executor = ToolExecutor(hooks=make_hooks(Observe())) - await executor.execute_async([(self.tool(), {"cmd": "ls"})]) - await executor.execute_async([(Tool(name="bad", handler=failing, description="", parameters={}), {"cmd": "x"})]) + executor = ToolExecutor(make_hooks(Observe()).subscribe()) + await executor.execute_many([(self.tool(), {"cmd": "ls"})]) + await executor.execute_many([(Tool(name="bad", handler=failing, description="", parameters={}), {"cmd": "x"})]) assert observed[0].result == "ran:ls" assert observed[0].error is None assert isinstance(observed[1].error, BubError) # original error object, kind/details preserved @@ -203,8 +202,8 @@ def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict) - if isinstance(result.result, str): result.result = f"bounded:{result.result}" - executor = ToolExecutor(hooks=make_hooks(BoundResult())) - execution = await executor.execute_async([(self.tool(), {"cmd": "ls"})]) + executor = ToolExecutor(make_hooks(BoundResult()).subscribe()) + execution = await executor.execute_many([(self.tool(), {"cmd": "ls"})]) assert execution.tool_results == ["bounded:ran:ls"] @@ -215,13 +214,13 @@ class Rewrite: def before_tool_call(self, call: ToolCall, state: dict) -> ToolCallDecision: return ToolCallDecision.proceed(arguments={"cmd": "safe-ls"}) - executor = ToolExecutor(hooks=make_hooks(Rewrite())) - execution = await executor.execute_async([(self.tool(), {"cmd": "rm"})]) + executor = ToolExecutor(make_hooks(Rewrite()).subscribe()) + execution = await executor.execute_many([(self.tool(), {"cmd": "rm"})]) assert execution.tool_results == ["ran:safe-ls"] @pytest.mark.asyncio async def test_no_hooks_keeps_current_behavior(self) -> None: - execution = await ToolExecutor().execute_async([(self.tool(), {"cmd": "ls"})]) + execution = await ToolExecutor().execute_many([(self.tool(), {"cmd": "ls"})]) assert execution.tool_results == ["ran:ls"] @@ -268,14 +267,14 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> None: class TestModelRunnerHookIntegration: """Regression tests for PR #255 review findings (effective request, exactly-once).""" - def _runner_and_tape(self, hooks: AgentHooks, tmp_path: Path, api_format: str | None = None): - runner = ModelRunner( + def _runner_and_tape(self, hooks: PluginAgentHooks, tmp_path: Path, api_format: str | None = None): + runner = StepRunner( AgentSettings( model="openai:orig", max_tokens=100, client_args={"api_format": api_format} if api_format else {} ), hooks=hooks, ) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("t1") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("t1") return runner, tape @pytest.mark.asyncio @@ -417,11 +416,9 @@ async def blocking(cmd: str) -> str: await asyncio.Event().wait() # blocks until cancelled return "unreachable" - executor = ToolExecutor(hooks=make_hooks(Observe())) + executor = ToolExecutor(make_hooks(Observe()).subscribe()) task = asyncio.create_task( - executor.execute_async([ - (Tool(name="block", handler=blocking, description="", parameters={}), {"cmd": "x"}) - ]) + executor.execute_many([(Tool(name="block", handler=blocking, description="", parameters={}), {"cmd": "x"})]) ) await started.wait() task.cancel() diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index c7d75477..435780f8 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -11,14 +11,16 @@ import bub.builtin.codemode import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`) -from bub.builtin.agent import Agent -from bub.builtin.model_runner import ModelRunner +from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox -from bub.errors import BubError +from bub.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 TapeContext +from bub.tape import Tape from bub.tools import REGISTRY, tool from tests.model_fakes import ProviderService @@ -34,7 +36,7 @@ def model_provider(provider_transport: ProviderService) -> ProviderService: return provider_transport -def _make_agent() -> Agent: +def _make_agent() -> BubAgent: """Build an Agent with a mocked framework, bypassing real LLM/tape init.""" framework = MagicMock() framework.get_tape_store.return_value = None @@ -47,113 +49,44 @@ async def build_prompt(message: dict[str, Any], session_id: str, state: dict[str framework.build_prompt = build_prompt - with patch.object(Agent, "__init__", lambda self, fw: None): - agent = Agent.__new__(Agent) - - agent.settings = AgentSettings.model_construct( + settings = AgentSettings.model_construct( model="openai:model", api_key="k", api_base=None, client_args={"api_format": "chat"} ) - agent.command_prefix = agent.settings.command_prefix - agent.framework = framework - agent.tools = REGISTRY.copy() - agent.tape_store = None - agent.skill_dirs = None - agent.model_runner = ModelRunner(agent.settings) + with patch("bub.builtin.agent.load_settings", return_value=settings): + agent = BubAgent(framework) + agent.__dict__["_tapes"] = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()) return agent class _ForkCapture: - """Captures fork_tape enter and exit behavior.""" + """Records how session tapes are forked and when the forks close.""" - def __init__(self) -> None: + def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: self.merge_back_values: list[bool] = [] self.exit_count = 0 + fork_tape = Tape.fork_tape + + @contextlib.asynccontextmanager + async def capture(tape: Tape, merge_back: bool = True) -> AsyncGenerator[Tape, None]: + self.merge_back_values.append(merge_back) + try: + async with fork_tape(tape, merge_back=merge_back) as forked: + yield forked + finally: + self.exit_count += 1 + + monkeypatch.setattr(Tape, "fork_tape", capture) - @contextlib.asynccontextmanager - async def fork_tape(self, tape_name: str, merge_back: bool = True) -> AsyncGenerator[None, None]: - self.merge_back_values.append(merge_back) - try: - yield - finally: - self.exit_count += 1 - - -class _FakeTape: - """Scoped tape stand-in for testing Agent.run().""" - - def __init__(self, fork_capture: _ForkCapture) -> None: - self._fork = fork_capture - self.name = "test-tape" - self.context = TapeContext(state={}) - self.messages: list[republic.Message] = [] - self.events: list[tuple[str, str, dict[str, Any]]] = [] - - async def ensure_bootstrap_anchor(self) -> None: - pass - - @contextlib.asynccontextmanager - async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[_FakeTape, None]: - async with self._fork.fork_tape(self.name, merge_back=merge_back): - yield self - - async def read_messages(self) -> list[republic.Message]: - return list(self.messages) - - async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None: - self.events.append((self.name, name, payload)) - - async def record_chat( - self, - *, - run_id: str, - system_prompt: str | None, - new_messages: list[republic.Message], - response_text: str | None, - context_error: BubError | None = None, - tool_calls: list[dict[str, Any]] | None = None, - tool_results: list[Any] | None = None, - error: BubError | None = None, - assistant_fields: dict[str, Any] | None = None, - provider: str | None = None, - model: str | None = None, - usage: dict[str, Any] | None = None, - ) -> None: - if system_prompt: - self.events.append((self.name, "system", {"content": system_prompt})) - if context_error is not None: - self.events.append((self.name, "error", context_error.as_dict())) - self.messages.extend(new_messages) - if tool_calls: - self.events.append((self.name, "tool_call", {"calls": tool_calls})) - if tool_results is not None: - self.events.append((self.name, "tool_result", {"results": tool_results})) - if error is not None and error is not context_error: - self.events.append((self.name, "error", error.as_dict())) - if response_text is not None: - self.messages.append(republic.assistant(response_text)) - self.events.append((self.name, "run", {"run_id": run_id, "model": model, "error": error is not None})) - - -class _FakeTapeFactory: - """Minimal tape factory stand-in for testing Agent.run().""" - - def __init__(self, fork_capture: _ForkCapture) -> None: - self.tape = _FakeTape(fork_capture) - self.context = self.tape.context - - def session_tape(self, session_id: str, workspace: Any, context: TapeContext | None = None) -> _FakeTape: - if context is not None: - self.tape.context = context - self.context = context - return self.tape + +@pytest.fixture +def fork_capture(monkeypatch: pytest.MonkeyPatch) -> _ForkCapture: + return _ForkCapture(monkeypatch) @pytest.mark.asyncio -async def test_agent_run_regular_session_merges_back() -> None: +async def test_agent_run_regular_session_merges_back(fork_capture: _ForkCapture) -> None: """A regular (non-temp) session should merge tape entries back.""" agent = _make_agent() - fork_capture = _ForkCapture() - agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment] result = await agent.run_stream(session_id="user/session1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 @@ -167,11 +100,9 @@ async def test_agent_run_regular_session_merges_back() -> None: @pytest.mark.asyncio -async def test_agent_run_temp_session_does_not_merge_back() -> None: +async def test_agent_run_temp_session_does_not_merge_back(fork_capture: _ForkCapture) -> None: """A temp/ session should NOT merge tape entries back.""" agent = _make_agent() - fork_capture = _ForkCapture() - agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment] result = await agent.run_stream(session_id="temp/abc123", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 @@ -190,9 +121,6 @@ async def test_agent_run_passes_model_to_llm( ) -> None: """The model parameter should be forwarded to Republic.""" agent = _make_agent() - fork_capture = _ForkCapture() - fake_tapes = _FakeTapeFactory(fork_capture) - agent.tape = fake_tapes # type: ignore[assignment] result = await agent.run_stream( session_id="user/s1", @@ -210,7 +138,6 @@ async def test_agent_run_passes_model_to_llm( @pytest.mark.asyncio async def test_agent_run_empty_prompt_returns_error() -> None: agent = _make_agent() - agent.tape = MagicMock() result = await agent.run_stream(session_id="user/s1", prompt="", state={}) events = [event async for event in result] @@ -227,9 +154,6 @@ async def test_agent_run_model_defaults_to_none( ) -> None: """When model is not specified, settings.model is used for Republic.""" agent = _make_agent() - fork_capture = _ForkCapture() - fake_tapes = _FakeTapeFactory(fork_capture) - agent.tape = fake_tapes # type: ignore[assignment] result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 [event async for event in result] @@ -243,13 +167,13 @@ async def test_agent_run_model_defaults_to_none( @pytest.mark.parametrize("continuation", ["custom continuation", None]) async def test_agent_loop_awaits_continue_prompt_hook_with_stream_state(continuation: str | None) -> None: agent = _make_agent() - tape = _FakeTape(_ForkCapture()) + tape = agent.tape("continuation") prompts: list[list[UserContent] | None] = [] continuation_prompts: list[list[UserContent] | None] = [] observed_usage: list[dict[str, Any] | None] = [] - async def run_once(**kwargs: Any) -> AsyncStreamEvents: - prompts.append(kwargs["prompt"]) + def run_once(prompt: list[UserContent] | None, *, model: Any, tape: Tape, **_: Any) -> AsyncStreamEvents: + prompts.append(prompt) should_continue = len(prompts) == 1 async def iterator() -> AsyncIterator[StreamEvent]: @@ -257,22 +181,15 @@ async def iterator() -> AsyncIterator[StreamEvent]: return AsyncStreamEvents(iterator(), state=StreamState(usage={"step": len(prompts)})) - async def continue_prompt(*, prompt: list[UserContent] | None, tape: _FakeTape, state: StreamState) -> str | None: + async def continue_prompt(*, prompt: list[UserContent] | None, tape: Tape, state: StreamState) -> str | None: continuation_prompts.append(prompt) observed_usage.append(state.usage) return continuation - agent._run_once = run_once # type: ignore[method-assign] agent.framework.continue_prompt = continue_prompt + agent.run_once = run_once # type: ignore[method-assign] - events = [ - event - async for event in agent._stream_events_with_auto_handoff( - tape=tape, # type: ignore[arg-type] - prompt=["initial prompt"], - state=StreamState(), - ) - ] + events = [event async for event in await agent.run_on(tape, ["initial prompt"], model=agent.settings.chat_model())] assert [event.kind for event in events] == ["final", "final"] assert prompts == [["initial prompt"], None if continuation is None else [continuation]] @@ -293,8 +210,6 @@ async def test_agent_run_model_override_does_not_mutate_default( sessions the way a process-global env var would. """ agent = _make_agent() - fork_capture = _ForkCapture() - agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment] default_model = agent.settings.model result = await agent.run_stream( @@ -316,9 +231,6 @@ async def test_agent_run_injects_steering_messages_once_by_session( model_provider: ProviderService, ) -> None: agent = _make_agent() - fork_capture = _ForkCapture() - fake_tapes = _FakeTapeFactory(fork_capture) - agent.tape = fake_tapes # type: ignore[assignment] steering_inbox = InMemorySteeringInbox() agent.framework.get_steering_inbox.return_value = steering_inbox @@ -341,7 +253,8 @@ async def test_agent_run_injects_steering_messages_once_by_session( {"role": "user", "content": "second steer"}, {"role": "user", "content": "hello"}, ] - assert fake_tapes.tape.messages == [ + messages = await agent.tape(session_tape_name("user/s1", Path("/tmp"))).read_messages() # noqa: S108 + assert messages[-4:] == [ republic.user("first steer"), republic.user("second steer"), republic.user("hello"), @@ -376,9 +289,6 @@ def denied_agent_tool() -> str: return "denied" agent = _make_agent() - fork_capture = _ForkCapture() - fake_tapes = _FakeTapeFactory(fork_capture) - agent.tape = fake_tapes # type: ignore[assignment] result = await agent.run_stream( session_id="user/s1", @@ -414,8 +324,6 @@ def internal_agent_tool() -> str: return "internal" agent = _make_agent() - fork_capture = _ForkCapture() - agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment] result = await agent.run_stream( session_id="user/s1", @@ -434,28 +342,23 @@ def internal_agent_tool() -> str: @pytest.mark.asyncio -async def test_agent_run_rejects_unknown_allowed_tools() -> None: +async def test_agent_run_rejects_unknown_allowed_tools(fork_capture: _ForkCapture) -> None: agent = _make_agent() - fork_capture = _ForkCapture() - fake_tapes = _FakeTapeFactory(fork_capture) - agent.tape = fake_tapes # type: ignore[assignment] - - stream = await agent.run_stream( - session_id="user/s1", - prompt="hello", - state={"_runtime_workspace": "/tmp"}, # noqa: S108 - allowed_tools=["tests_missing_agent_tool"], - ) with pytest.raises(ValueError, match="tests_missing_agent_tool"): - [event async for event in stream] + await agent.run_stream( + session_id="user/s1", + prompt="hello", + state={"_runtime_workspace": "/tmp"}, # noqa: S108 + allowed_tools=["tests_missing_agent_tool"], + ) + assert fork_capture.merge_back_values == [] @pytest.mark.asyncio @pytest.mark.parametrize("prompt", [",bash command='echo hello'", ",echo hello"]) async def test_run_command_executes_shell_with_command_parameter(prompt: str, tmp_path) -> None: agent = _make_agent() - agent.tape = _FakeTapeFactory(_ForkCapture()) # type: ignore[assignment] stream = await agent.run_stream(session_id="user/s1", prompt=prompt, state={"_runtime_workspace": str(tmp_path)}) events = [event async for event in stream] @@ -474,8 +377,6 @@ async def test_run_command_model_switches_session_model_directly() -> None: session tape (merged back at end of turn), not a side effect of save_state. """ agent = _make_agent() - fork_capture = _ForkCapture() - agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment] state: dict[str, Any] = {"_runtime_workspace": "/tmp"} # noqa: S108 assert "model" not in REGISTRY or REGISTRY["model"].context is True @@ -498,7 +399,6 @@ async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub( monkeypatch.setenv("BUB_HOME", str(tmp_path / "home")) agent = _make_agent() agent.tools[RUN_CODE_TOOL_NAME] = run_code - agent.tape = _FakeTapeFactory(_ForkCapture()) # type: ignore[assignment] allowed_tools = ["bash", "bash.output", "fs.read", "tape.info", *([RUN_CODE_TOOL_NAME] if allow_run_code else [])] state: dict[str, Any] = {"_runtime_workspace": str(tmp_path), "code_mode": True} @@ -536,7 +436,6 @@ async def test_run_code_is_hidden_when_session_code_mode_is_off( agent = _make_agent() agent.tools[RUN_CODE_TOOL_NAME] = run_code - agent.tape = _FakeTapeFactory(_ForkCapture()) # type: ignore[assignment] result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": str(tmp_path)}) [event async for event in result] @@ -552,7 +451,6 @@ async def test_run_code_is_hidden_when_session_code_mode_is_off( @pytest.mark.parametrize(("command", "expected"), [(",code_mode enable=true", True), (",code_mode false", False)]) async def test_code_mode_command_switches_session_code_mode(command: str, expected: bool) -> None: agent = _make_agent() - agent.tape = _FakeTapeFactory(_ForkCapture()) # type: ignore[assignment] state: dict[str, Any] = {"_runtime_workspace": "/tmp"} # noqa: S108 stream = await agent.run_stream(session_id="user/s1", prompt=command, state=state) @@ -562,3 +460,28 @@ async def test_code_mode_command_switches_session_code_mode(command: str, expect deltas = [event.data.get("delta", "") for event in events if event.kind == "text"] status = "enabled" if expected else "disabled" assert f"Session code mode {status} (applies from the next turn)." in deltas + + +@pytest.mark.asyncio +async def test_agent_sections_and_handlers_apply_after_builtin_ones(model_provider: ProviderService) -> None: + agent = _make_agent() + agent.skill_dirs = [] + agent.framework.get_system_prompt.return_value = "" + statuses: list[str] = [] + + @agent.system_prompt.add + def session(context: PromptContext) -> str: + return f"{context.state['session_id']}:{len(context.tools)}" + + @agent.events.on(StepFinished) + def record(event: StepFinished) -> None: + statuses.append(event.status) + + result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 + [event async for event in result] + + request = model_provider.body() + system_prompt = request["messages"][0]["content"] + assert system_prompt.endswith(f"user/s1:{len(request['tools'])}") + assert system_prompt.startswith("\n\n") + assert statuses == ["ok"] diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index a8d50e91..878ce94c 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.builtin.model_runner.ModelRunner.run", + "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 95ef56e7..3c6c42c6 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -9,16 +9,15 @@ from typer.testing import CliRunner from bub.builtin.auth import app -from bub.builtin.context import default_tape_context -from bub.builtin.model_runner import ModelRunner from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem +from bub.core.context import default_tape_context 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, sse +from tests.model_fakes import ProviderService, StepRunner, sse @pytest.mark.asyncio @@ -45,8 +44,8 @@ async def test_codex_sends_content_with_file_credentials( ]) ) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("content") + runner = StepRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("content") await tape.ensure_bootstrap_anchor() if content_kind == "assistant-history": await tape.store.append( @@ -115,8 +114,8 @@ async def test_codex_replays_encrypted_reasoning_and_tool_results_after_reload( ]) ) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(api_key="test", client_args={"http_client": client})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("codex") + runner = StepRunner(AgentSettings(api_key="test", client_args={"http_client": client})) + tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("codex") await tape.ensure_bootstrap_anchor() tools = [Tool(name="echo", handler=lambda: "echoed")] _ = [ @@ -125,9 +124,7 @@ async def test_codex_replays_encrypted_reasoning_and_tool_results_after_reload( tape=tape, model="codex:test", tools=tools, system_prompt=None, prompt=to_content("echo") ) ] - reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( - "codex" - ) + reopened = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("codex") events = [ event async for event in runner.run( @@ -156,7 +153,7 @@ async def test_named_endpoint_falls_back_to_codex_with_its_own_credentials( ]) ) async with provider_service.client() as client: - runner = ModelRunner( + runner = StepRunner( AgentSettings( model="relay:sample", fallback_models=["codex:sample"], @@ -165,7 +162,7 @@ async def test_named_endpoint_falls_back_to_codex_with_its_own_credentials( client_args={"http_client": client, "max_retries": 0}, ) ) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") events = [ event async for event in runner.run( diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index ba85e663..b2a9ee62 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -9,6 +9,7 @@ from bub.builtin.hook_impl import AGENTS_FILE_NAME, DEFAULT_SYSTEM_PROMPT, BuiltinImpl from bub.channels.message import ChannelMessage +from bub.core.tape import session_tape_name from bub.framework import BubFramework from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent @@ -30,7 +31,6 @@ async def __aexit__(self, exc_type, exc, traceback) -> None: def _fake_tape(home: Path) -> Tape: return Tape( - archive_path=home / "tapes", store=AsyncTapeStoreAdapter(InMemoryTapeStore()), context=TapeContext(), ) @@ -43,10 +43,13 @@ def __init__(self, home: Path, *, tape: Tape | None = None) -> None: self.tools = REGISTRY.copy() # A real in-memory async tape so load_state's recovery path runs against # the same store the tests write `model_switch` events to. - self.tape = tape if tape is not None else _fake_tape(home) + self.tapes = tape if tape is not None else _fake_tape(home) self.run_calls: list[tuple[str, str, dict[str, object]]] = [] self.run_stream_calls: list[tuple[str, str, dict[str, object], str | None]] = [] + def tape(self, name: str) -> Tape: + return self.tapes.scoped(name) + async def run(self, *, session_id: str, prompt: str, state: dict[str, object]) -> str: self.run_calls.append((session_id, prompt, state)) return "agent-output" @@ -134,7 +137,7 @@ async def test_load_state_and_save_state_manage_lifespan_and_context(tmp_path: P async def test_load_state_injects_model_recorded_on_session_tape(tmp_path: Path) -> None: """A model_switch event recorded on the session tape is restored into state on load.""" _, impl, agent = _build_impl(tmp_path) - session = agent.tape.session_tape("resolved-session", impl.framework.workspace) + session = agent.tape(session_tape_name("resolved-session", impl.framework.workspace)) await session.append_event("model_switch", {"model": "openai:gpt-4o"}) message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello") @@ -147,7 +150,7 @@ async def test_load_state_injects_model_recorded_on_session_tape(tmp_path: Path) @pytest.mark.asyncio async def test_load_state_injects_reasoning_effort_recorded_on_session_tape(tmp_path: Path) -> None: _, impl, agent = _build_impl(tmp_path) - session = agent.tape.session_tape("resolved-session", impl.framework.workspace) + session = agent.tape(session_tape_name("resolved-session", impl.framework.workspace)) await session.append_event("reasoning_effort_switch", {"reasoning_effort": "high"}) message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello") @@ -160,7 +163,7 @@ async def test_load_state_injects_reasoning_effort_recorded_on_session_tape(tmp_ @pytest.mark.asyncio async def test_load_state_injects_latest_code_mode_recorded_on_session_tape(tmp_path: Path) -> None: _, impl, agent = _build_impl(tmp_path) - session = agent.tape.session_tape("resolved-session", impl.framework.workspace) + session = agent.tape(session_tape_name("resolved-session", impl.framework.workspace)) await session.append_event("code_mode_switch", {"code_mode": True}) await session.append_event("code_mode_switch", {"code_mode": False}) message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello") @@ -188,7 +191,7 @@ async def test_load_state_does_not_inject_model_for_unknown_session(tmp_path: Pa async def test_load_state_restores_latest_recorded_model(tmp_path: Path) -> None: """When several switches were recorded, the most recent one wins.""" _, impl, agent = _build_impl(tmp_path) - session = agent.tape.session_tape("resolved-session", impl.framework.workspace) + session = agent.tape(session_tape_name("resolved-session", impl.framework.workspace)) await session.append_event("model_switch", {"model": "openai:gpt-4o"}) await session.append_event("model_switch", {"model": "anthropic:claude-3"}) message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello") @@ -339,9 +342,10 @@ def test_provide_channels_returns_cli_and_telegram(tmp_path: Path, monkeypatch: class DummyCliChannel: name = "cli" - def __init__(self, on_receive, agent) -> None: + def __init__(self, on_receive, *, get_agent, workspace: Path) -> None: self.on_receive = on_receive - self.agent = agent + self.get_agent = get_agent + self.workspace = workspace class DummyTelegramChannel: name = "telegram" @@ -367,7 +371,8 @@ def message_handler(message) -> None: assert [channel.name for channel in channels] == ["telegram", "cli"] assert channels[0].on_receive is message_handler assert channels[1].on_receive is message_handler - assert channels[1].agent is agent + assert channels[1].get_agent() is agent + assert channels[1].workspace == impl.framework.workspace @pytest.mark.asyncio diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index ab8107fe..209a502f 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -33,7 +33,7 @@ def _tool_context(tmp_path, **state) -> ToolContext: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") return ToolContext(tape=tape, run_id="test-run", state={"_runtime_workspace": str(tmp_path), **state}) @@ -378,7 +378,7 @@ async def test_bash_non_zero_exit_is_returned_as_tool_error(tmp_path) -> None: command = _python_shell("import sys; print('boom'); sys.exit(7)") executor = ToolExecutor() - result = await executor.execute_async([(bash, {"command": command})], context=_tool_context(tmp_path)) + result = await executor.execute_many([(bash, {"command": command})], context=_tool_context(tmp_path)) assert result.error is not None assert result.error.kind is ErrorKind.TOOL diff --git a/tests/test_channels.py b/tests/test_channels.py index 52d55474..ec71b592 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1331,9 +1331,10 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp @pytest.mark.asyncio -async def test_cli_tool_reporter_finishes_before_next_model_output() -> None: - from bub.channels.cli import _CliToolCallReporter - from bub.tools import REGISTRY, tool, tool_call_reporter +async def test_cli_tool_renderer_finishes_before_next_model_output() -> None: + from bub.channels.cli import _CliToolCallRenderer + from bub.core.events import EventBus + from bub.tools import Tool, ToolExecutor events: list[str] = [] @@ -1348,25 +1349,47 @@ async def write(self, function) -> None: tool_call_error=lambda **kwargs: events.append("tool-error"), ) presenter = OrderedPresenter() - reporter = _CliToolCallReporter(renderer, presenter) # type: ignore[arg-type] - tool_name = "tests.cli_ordered_tool" - REGISTRY.pop(tool_name, None) + bus = EventBus() + _CliToolCallRenderer(renderer, presenter).subscribe(bus) # type: ignore[arg-type] - @tool(name=tool_name) def ordered_tool() -> str: events.append("tool-body") return "done" - try: - with tool_call_reporter(reporter): - assert await ordered_tool.run() == "done" - await presenter.write(lambda: events.append("next-model-text")) - finally: - REGISTRY.pop(tool_name, None) + execution = await ToolExecutor(bus).execute_many([(Tool.from_callable(ordered_tool), {})]) + await presenter.write(lambda: events.append("next-model-text")) + assert execution.tool_results == ["done"] assert events == ["tool-start", "tool-body", "tool-success", "next-model-text"] +@pytest.mark.asyncio +async def test_cli_tool_renderer_shows_the_error_the_tool_raised() -> None: + from bub.channels.cli import _CliToolCallRenderer + from bub.core.events import EventBus + from bub.tools import Tool, ToolExecutor + + errors: list[BaseException] = [] + + class Presenter: + async def write(self, function) -> None: + function() + + renderer = SimpleNamespace( + tool_call_start=lambda **kwargs: None, + tool_call_error=lambda **kwargs: errors.append(kwargs["error"]), + ) + bus = EventBus() + _CliToolCallRenderer(renderer, Presenter()).subscribe(bus) # type: ignore[arg-type] + + def failing_tool() -> str: + raise RuntimeError("boom") + + await ToolExecutor(bus).execute_many([(Tool.from_callable(failing_tool), {})]) + + assert [(type(error), str(error)) for error in errors] == [(RuntimeError, "boom")] + + @pytest.mark.asyncio @pytest.mark.parametrize("expand_thinking", [False, True]) async def test_cli_stream_prints_panel_head_after_reasoning_block(expand_thinking: bool) -> None: diff --git a/tests/test_cli_renderer.py b/tests/test_cli_renderer.py index e3997706..4e90bdf5 100644 --- a/tests/test_cli_renderer.py +++ b/tests/test_cli_renderer.py @@ -23,18 +23,17 @@ def test_panel_head_and_end_commit_separate_terminal_lines() -> None: assert lines[3] == "next prompt" -def test_tool_call_renderer_hides_success_output() -> None: +def test_tool_call_renderer_shows_call_and_elapsed_time() -> None: console = Console(record=True, force_terminal=False, width=120) renderer = CliRenderer(console) - renderer.tool_call_start(name="demo.echo", args=(), kwargs={"value": "hello"}) - renderer.tool_call_success(name="demo.echo", result={"secret": "hidden-result"}, elapsed_ms=12.3) + renderer.tool_call_start(name="demo.echo", kwargs={"value": "hello"}) + renderer.tool_call_success(name="demo.echo", elapsed_ms=12.3) output = console.export_text() assert '● demo.echo(value: "hello")' in output assert " ⎿ completed in 12 ms" in output - assert "hidden-result" not in output assert "Tool call:" not in output assert "Tool result:" not in output diff --git a/tests/test_codemode.py b/tests/test_codemode.py index a87df637..75ca8be5 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -23,9 +23,10 @@ write_tool_stub, ) from bub.builtin.environment import LocalEnvironment, LocalProcess +from bub.core.events import BeforeToolCall, EventBus from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, CodeFailed from bub.errors import BubError, ErrorKind -from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult +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 @@ -52,11 +53,13 @@ def get_order(order_id: str, include_items: bool = False, *, context: ToolContex return {"id": order_id, "total": 12.5} -def _context(tmp_path: Path, tools: list[Tool] | None = None, **state: Any) -> ToolContext: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") +def _context( + tmp_path: Path, tools: list[Tool] | None = None, *, events: EventBus | None = None, **state: Any +) -> ToolContext: + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") if tools is not None: state[CODE_TOOLS_STATE_KEY] = model_tools(tools) - return ToolContext(tape=tape, run_id="run-1", state=state) + return ToolContext(tape=tape, run_id="run-1", state=state, events=events) def test_stub_declares_keyword_only_functions_with_types_and_docs() -> None: @@ -149,22 +152,18 @@ def get_mode(*, context: ToolContext) -> dict[str, bool]: async def test_run_code_routes_tool_calls_through_hooks(tmp_path: Path) -> None: calls: list[tuple[str, bool]] = [] - class Hooks: - async def before_tool_call(self, call: ToolCall, state: dict[str, Any]) -> tuple[ToolCall, ToolCallDecision]: - calls.append((call.tool, call.context is not None and call.context.code_mode)) - if call.tool == "blocked": - return call, ToolCallDecision.deny("not allowed") - return call, ToolCallDecision.proceed() + def before_tool_call(event: BeforeToolCall) -> None: + calls.append((event.call.tool, event.call.context is not None and event.call.context.code_mode)) + if event.call.tool == "blocked": + event.decision = ToolCallDecision.deny("not allowed") - async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict[str, Any]) -> None: - return None - - agent = type("FakeAgent", (), {"model_runner": type("Runner", (), {"hooks": Hooks()})()})() + events = EventBus() + events.on(BeforeToolCall, before_tool_call) order_tool = Tool.from_callable(get_order, name="orders.get", context=True) blocked = Tool.from_callable(lambda: "secret", name="blocked") code = "try:\n await tools.blocked()\nexcept Exception as exc:\n print(type(exc).__name__, exc)\n" - output = await run_code.run(code=code, context=_context(tmp_path, [order_tool, blocked], _runtime_agent=agent)) + output = await run_code.run(code=code, context=_context(tmp_path, [order_tool, blocked], events=events)) assert output == "BubError [tool] not allowed\n" assert calls == [("blocked", True)] @@ -197,7 +196,7 @@ async def test_run_code_rejects_positional_tool_arguments(tmp_path: Path) -> Non @pytest.mark.asyncio async def test_run_code_requires_code_mode(tmp_path: Path) -> None: - execution = await ToolExecutor().execute_async([(run_code, {"code": "print(1)"})], context=_context(tmp_path)) + execution = await ToolExecutor().execute_many([(run_code, {"code": "print(1)"})], context=_context(tmp_path)) assert execution.error is not None assert execution.error.message == "Code mode is not enabled for this run." @@ -389,17 +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]] = [] - class Hooks: - async def before_tool_call(self, call: ToolCall, state: dict[str, Any]) -> tuple[ToolCall, ToolCallDecision]: - calls.append((call.tool, call.context is not None and call.context.code_mode)) - if call.tool == "blocked": - return call, ToolCallDecision.deny("not allowed") - return call, ToolCallDecision.proceed() - - async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict[str, Any]) -> None: - return None + def before_tool_call(event: BeforeToolCall) -> None: + calls.append((event.call.tool, event.call.context is not None and event.call.context.code_mode)) + if event.call.tool == "blocked": + event.decision = ToolCallDecision.deny("not allowed") - agent = type("FakeAgent", (), {"model_runner": type("Runner", (), {"hooks": Hooks()})()})() + events = EventBus() + events.on(BeforeToolCall, before_tool_call) order_tool = Tool.from_callable(get_order, name="orders.get", context=True) blocked = Tool.from_callable(lambda: "secret", name="blocked") code = ( @@ -408,7 +403,7 @@ async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: d "try:\n await tools.blocked()\nexcept Exception as exc:\n print(exc)\n" ) context = _context( - tmp_path, [order_tool, blocked], _runtime_agent=agent, **{ENVIRONMENT_STATE_KEY: _InProcessEnvironment()} + tmp_path, [order_tool, blocked], events=events, **{ENVIRONMENT_STATE_KEY: _InProcessEnvironment()} ) output = await run_code.run(code=code, context=context) @@ -531,7 +526,7 @@ 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.builtin.context import default_tape_context + from bub.core.context import default_tape_context from bub.tape import TapeEntry from bub.tools import content_result diff --git a/tests/test_core_agent.py b/tests/test_core_agent.py new file mode 100644 index 00000000..f998dc4e --- /dev/null +++ b/tests/test_core_agent.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import ast +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest +import republic + +import bub.core +from bub.core import ( + AfterLlmCall, + AfterToolCall, + Agent, + AgentEvent, + BeforeLlmCall, + BeforeToolCall, + ChatCompleted, + CommandFinished, + EventBus, + LlmCallDecision, + PromptContext, + RunStarted, + StepFinished, + Tool, + ToolCallDecision, +) +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.errors import BubError +from tests.model_fakes import ProviderService, chat_events, sse, tool_events + + +def _model(client: Any) -> republic.ChatModel: + return republic.get_model("openai:test", http_client=client, api_format="chat", api_key="test") + + +def _tool_call(name: str) -> object: + return sse(tool_events([{"index": 0, "id": "call-1", "function": {"name": name, "arguments": "{}"}}])) + + +@pytest.mark.asyncio +async def test_core_agent_runs_tool_loop_and_emits_events(provider_service: ProviderService) -> None: + provider_service.reply(_tool_call("fs_read")) + provider_service.reply(sse(chat_events("It is a readme."))) + prompts: list[object] = [] + seen: list[str] = [] + store = InMemoryTapeStore() + read = Tool.from_callable(lambda path: f"contents of {path}", name="fs.read") + async with provider_service.client() as client: + model = _model(client) + agent = Agent(store=store, tools=[read], system_prompt="Be brief.") + + @agent.system_prompt.add + def tools_section(context: PromptContext) -> str: + prompts.append((context.prompt, context.state.get("user"), [tool.name for tool in context.tools])) + assert context.model == "openai:test" + assert context.available_tools == [read] + return "Tools: " + ", ".join(tool.name for tool in context.tools) + + @agent.system_prompt.add + async def empty_section(context: PromptContext) -> None: + return None + + @agent.events.on(BeforeToolCall) + def rewrite_path(event: BeforeToolCall) -> None: + event.call = replace(event.call, arguments={"path": "README.md"}) + + assert agent.system_prompt.add("Removed section.") == "Removed section." + agent.system_prompt.remove("Removed section.") + assert agent.system_prompt.sections == ("Be brief.", tools_section, empty_section) + agent.events.on(AgentEvent, lambda event: seen.append(type(event).__name__)) + async with agent.run( + ["What is this file?"], model=model, options={"max_tokens": 64}, tape="session", state={"user": "ada"} + ) as stream: + events = [event async for event in stream] + + assert events[-1].data == {"ok": True, "text": "It is a readme."} + first, second = provider_service.body(0), provider_service.body(1) + assert first["messages"][0] == {"role": "system", "content": "Be brief.\n\nTools: fs_read"} + assert [tool["function"]["name"] for tool in first["tools"]] == ["fs_read"] + assert first["max_completion_tokens"] == 64 + assert second["messages"][-1] == {"role": "tool", "tool_call_id": "call-1", "content": "contents of README.md"} + assert prompts == [(["What is this file?"], "ada", ["fs_read"]), ([], "ada", ["fs_read"])] + assert seen == [ + "RunStarted", + "StepStarted", + "BeforeLlmCall", + "AfterLlmCall", + "BeforeToolCall", + "AfterToolCall", + "ChatCompleted", + "StepFinished", + "StepStarted", + "BeforeLlmCall", + "AfterLlmCall", + "ChatCompleted", + "StepFinished", + ] + # 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") + + +@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] = [] + + 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) + async with agent.session("deny") as tape: + events = [event async for event in agent.run_once(["go"], model=model, tape=tape)] + + result = next(event for event in events if event.kind == "tool_result").data["tool_results"][0] + assert result["message"] == "no secrets" + assert later == ["llm"] + + agent.events.on( + BeforeLlmCall, lambda event: setattr(event, "decision", LlmCallDecision.finish("budget exhausted")) + ) + agent.events.on(AfterLlmCall, lambda event: later.append("after")) + async with agent.run(["again"], model=model, tape="budget") as stream: + events = [event async for event in stream] + + assert events[-1].data == {"ok": True, "text": "budget exhausted"} + assert later == ["llm", "llm"] + assert finished[-1].response_text == "budget exhausted" + assert len(provider_service.requests) == 1 + + +@pytest.mark.asyncio +async def test_removed_handler_no_longer_receives_events() -> None: + bus = EventBus() + received: list[str] = [] + + @bus.on(AgentEvent) + async def record(event: AgentEvent) -> None: + received.append(type(event).__name__) + + def direct(event: AgentEvent) -> None: + received.append("direct") + + assert bus.on(AgentEvent, direct) is direct + await bus.emit(AgentEvent(state={})) + bus.off(AgentEvent, record) + bus.off(AgentEvent, record) + await bus.emit(AgentEvent(state={})) + + assert received == ["AgentEvent", "direct", "direct"] + + +@pytest.mark.asyncio +async def test_core_agent_stops_at_max_steps(provider_service: ProviderService) -> None: + provider_service.reply(_tool_call("noop")) + statuses: list[str] = [] + async with provider_service.client() as client: + model = _model(client) + agent = Agent( + store=InMemoryTapeStore(), + tools=[Tool.from_callable(lambda: "ok", name="noop")], + max_steps=1, + ) + agent.events.on(StepFinished, lambda event: statuses.append(event.status)) + agent.events.on(RunStarted, lambda event: statuses.append(f"model={event.model}")) + agent.events.on(AfterToolCall, lambda event: statuses.append(f"tool={event.result.result}")) + 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"] + + +@pytest.mark.asyncio +async def test_redirected_call_reuses_the_provider_of_a_republic_model(provider_service: ProviderService) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + agent = Agent(store=InMemoryTapeStore()) + + @agent.events.on(BeforeLlmCall) + def reroute(event: BeforeLlmCall) -> None: + event.request = replace(event.request, model="openai:other") + + async with agent.run(["hi"], model=_model(client), tape="redirect") as stream: + events = [event async for event in stream] + + assert events[-1].data == {"ok": True, "text": "done"} + assert provider_service.body()["model"] == "other" + + +def test_core_does_not_depend_on_settings_or_plugins() -> None: + forbidden = ("bub.builtin", "bub.configure", "bub.framework", "bub.hooks", "bub.channels", "pluggy") + for path in Path(bub.core.__file__).parent.glob("*.py"): + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.ImportFrom): + names = [node.module or ""] + elif isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + else: + continue + assert not [name for name in names if name.startswith(forbidden)], f"{path.name} imports {names}" + + +def test_tapes_share_the_agent_store() -> None: + store = InMemoryTapeStore() + agent = Agent(store=store) + + assert agent.tape("a").store is agent.tape("b").store + assert isinstance(Agent().tape("a").store, AsyncTapeStoreAdapter) + + +@pytest.mark.asyncio +async def test_leaving_run_early_closes_the_stream_and_merges_the_fork(provider_service: ProviderService) -> None: + provider_service.reply(sse(chat_events("hello there"))) + async with provider_service.client() as client: + agent = Agent() + async with agent.run(["hi"], model=_model(client), tape="early") as stream: + async for event in stream: + if event.kind == "text": + break + + messages = await agent.tape("early").read_messages() + assert [message.role for message in messages] == [] + names = [entry.payload["name"] for entry in await agent.tape("early").store.fetch_all(agent.tape("early").query())] + assert "loop.start" in names + + +@pytest.mark.asyncio +async def test_commands_are_disabled_without_a_prefix(provider_service: ProviderService) -> None: + provider_service.reply(sse(chat_events("model answer"))) + async with provider_service.client() as client: + agent = Agent(tools=[Tool.from_callable(lambda: "tool ran", name="echo")]) + async with agent.run([",echo"], model=_model(client), tape="plain") as stream: + events = [event async for event in stream] + + assert events[-1].data == {"ok": True, "text": "model answer"} + assert provider_service.body()["messages"][-1] == {"role": "user", "content": ",echo"} + + +@pytest.mark.asyncio +async def test_runs_call_the_agent_model_unless_given_one(provider_service: ProviderService) -> None: + provider_service.reply(sse(chat_events("default answer"))) + provider_service.reply(sse(chat_events("override answer"))) + + async with provider_service.client() as client: + default = republic.get_model("openai:default", http_client=client, api_format="chat", api_key="test") + override = republic.get_model("openai:override", http_client=client, api_format="chat", api_key="test") + agent = Agent(model=default) + async with agent.run("hello", tape="plain") as stream: + default_events = [event async for event in stream] + async with agent.run("again", model=override, tape="plain") as stream: + override_events = [event async for event in stream] + + assert default_events[-1].data == {"ok": True, "text": "default answer"} + assert override_events[-1].data == {"ok": True, "text": "override answer"} + assert [provider_service.body(index)["model"] for index in (0, 1)] == ["default", "override"] + + +@pytest.mark.asyncio +async def test_runs_without_a_model_raise_unless_they_are_commands() -> None: + agent = Agent(tools=[Tool.from_callable(lambda: "pong", name="ping")], command_prefix="/") + + async with agent.run("/ping", tape="plain") as stream: + events = [event async for event in stream] + assert events[-1].data == {"text": "pong", "ok": True} + + with pytest.raises(ValueError, match="no model to run"): + async with agent.run("hello", tape="plain") as stream: + [event async for event in stream] + + +@pytest.mark.asyncio +async def test_command_tool_calls_emit_tool_events_that_can_deny_them() -> None: + def greet(name: str, punctuation: str = "!") -> str: + return f"hello {name}{punctuation}" + + agent = Agent(tools=[Tool.from_callable(greet, name="greet")], command_prefix="/") + calls: list[tuple[str, dict[str, Any]]] = [] + + @agent.events.on(BeforeToolCall) + def deny_bob(event: BeforeToolCall) -> None: + calls.append((event.call.tool, event.call.arguments)) + if event.call.arguments["name"] == "bob": + event.decision = ToolCallDecision.deny("no bob") + + model = republic.get_model("openai:never-called", api_key="test") + async with agent.run(["/greet ada ?"], model=model, tape="commands") as stream: + events = [event async for event in stream] + assert events[-1].data == {"text": "hello ada?", "ok": True} + + with pytest.raises(BubError, match="no bob"): + async with agent.run(["/greet bob"], model=model, tape="commands") as stream: + [event async for event in stream] + with pytest.raises(ValueError, match="at most 2 positional arguments"): + async with agent.run(["/greet ada ? extra"], model=model, tape="commands") as stream: + [event async for event in stream] + assert calls == [("greet", {"name": "ada", "punctuation": "?"}), ("greet", {"name": "bob"})] + + +@pytest.mark.asyncio +async def test_command_prompt_calls_the_tool_without_the_model() -> None: + def greet(name: str, *, punctuation: str = "!") -> str: + return f"hello {name}{punctuation}" + + agent = Agent(tools=[Tool.from_callable(greet, name="greet")], command_prefix="/") + finished: list[CommandFinished] = [] + agent.events.on(CommandFinished, finished.append) + model = republic.get_model("openai:never-called", api_key="test") + async with agent.run([" /greet ada punctuation=?"], model=model, tape="commands") as stream: + events = [event async for event in stream] + + assert [(event.kind, event.data) for event in events] == [ + ("text", {"delta": "hello ada?"}), + ("final", {"text": "hello ada?", "ok": True}), + ] + tape = agent.tape("commands") + (command,) = [ + entry.payload["data"] + for entry in await tape.store.fetch_all(tape.query().kinds("event")) + if entry.payload["name"] == "command" + ] + assert (command["name"], command["status"], command["output"]) == ("greet", "ok", "hello ada?") + + with pytest.raises(ValueError, match="bash tool is not available"): + async with agent.run(["/unknown"], model=model, tape="commands") as stream: + [event async for event in stream] + assert [(event.line, event.name, event.status, event.output) for event in finished] == [ + ("greet ada punctuation=?", "greet", "ok", "hello ada?"), + ("unknown", "unknown", "error", "bash tool is not available"), + ] diff --git a/tests/test_builtin_model_runner.py b/tests/test_core_agent_step.py similarity index 85% rename from tests/test_builtin_model_runner.py rename to tests/test_core_agent_step.py index 85a15bfd..00145bef 100644 --- a/tests/test_builtin_model_runner.py +++ b/tests/test_core_agent_step.py @@ -9,15 +9,14 @@ import republic from republic.errors import APIStatusError, StreamIncompleteError -from bub.builtin.context import default_tape_context -from bub.builtin.model_runner import ModelRunner 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 tests.model_fakes import ProviderService, chat_events, sse, tool_events +from tests.model_fakes import ProviderService, StepRunner, chat_events, sse, tool_events @pytest.mark.asyncio @@ -42,8 +41,8 @@ def create_client(**kwargs): provider_service.reply(sse(chat_events() if outcome == "success" else chat_events()[:-1])) if outcome == "incomplete": error = StreamIncompleteError - runner = ModelRunner(AgentSettings(client_args={"api_format": "chat", "max_retries": 0})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("owned") + runner = StepRunner(AgentSettings(client_args={"api_format": "chat", "max_retries": 0})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("owned") with pytest.raises(error) if error is not None else nullcontext(): events = [ event @@ -64,8 +63,8 @@ async def test_unknown_tool_returns_an_error_result(tmp_path: Path, provider_ser ) ) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("unknown") + runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("unknown") events = [ event async for event in runner.run( @@ -98,8 +97,8 @@ async def test_tool_turn_replays_after_tape_reload( provider_service.reply(sse(chat_events("Compared."))) tools = [Tool(name="inspect", handler=lambda: "files found"), Tool(name="compare", handler=lambda: "bytes differ")] async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) - root = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") + runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + root = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") async with root.fork_tape() as tape: await tape.ensure_bootstrap_anchor() events = [ @@ -112,7 +111,7 @@ async def test_tool_turn_replays_after_tape_reload( prompt=to_content("Compare the outputs."), ) ] - reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") + reopened = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") follow_up = [ event async for event in runner.run( @@ -147,8 +146,8 @@ async def test_tool_turn_replays_after_tape_reload( async def test_continuation_sends_steering_without_a_prompt(tmp_path: Path, provider_service: ProviderService) -> None: provider_service.reply(sse(chat_events())) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("steering") + runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("steering") await tape.ensure_bootstrap_anchor() events = [ event @@ -172,8 +171,8 @@ async def test_tool_result_without_its_call_in_context_is_still_sent( ) -> None: provider_service.reply(sse(chat_events())) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("orphan") + runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("orphan") await tape.store.append( tape.name, TapeEntry.message({"role": "tool", "tool_call_id": "call-1", "name": "inspect", "content": "Ready"}), @@ -202,8 +201,8 @@ async def test_invalid_tool_arguments_do_not_execute_the_handler( ) invoked = [] async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid") + runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid") with pytest.raises(BubError) as exc: _ = [ event @@ -226,8 +225,8 @@ async def test_streaming_reports_usage_and_records_it_in_tape( ) -> None: provider_service.reply(sse(chat_events())) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(max_tokens=100, client_args={"http_client": client, "api_format": "chat"})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("usage") + runner = StepRunner(AgentSettings(max_tokens=100, client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("usage") events = [ event async for event in runner.run( @@ -256,10 +255,10 @@ async def test_anthropic_request_enables_caching_and_generation_options( ]) ) async with provider_service.client() as client: - runner = ModelRunner( + runner = StepRunner( AgentSettings(max_tokens=100, client_args={"http_client": client}, completion_args={"temperature": 0.2}) ) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cache") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cache") events = [ event async for event in runner.run( @@ -278,7 +277,7 @@ async def test_session_reasoning_and_runtime_options_override_completion_default ) -> None: provider_service.reply(sse(chat_events())) async with provider_service.client() as client: - runner = ModelRunner( + runner = StepRunner( AgentSettings( client_args={ "http_client": client, @@ -294,7 +293,6 @@ async def test_session_reasoning_and_runtime_options_override_completion_default ) ) tape = Tape( - tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None, state={"reasoning_effort": "high"}), ).scoped("reasoning") @@ -321,14 +319,13 @@ async def test_wire_reasoning_follows_republic_extra_body_precedence( ) -> None: provider_service.reply(sse(chat_events())) async with provider_service.client() as client: - runner = ModelRunner( + runner = StepRunner( AgentSettings( client_args={"http_client": client, "api_format": "chat", "extra_body": {"reasoning_effort": "low"}}, completion_args={"extra_body": {"reasoning_effort": request_reasoning} if request_reasoning else {}}, ) ) tape = Tape( - tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None, state={"reasoning_effort": "high"}), ).scoped("wire-reasoning") @@ -360,8 +357,8 @@ async def test_truncated_stream_never_executes_tools(tmp_path: Path, provider_se ) invoked = [] async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("truncated") + runner = StepRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("truncated") with pytest.raises(republic.errors.StreamIncompleteError): _ = [ event @@ -411,8 +408,8 @@ async def test_provider_state_is_replayed_only_to_its_api_format_after_reload( ) ) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("metadata") + runner = StepRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("metadata") await tape.ensure_bootstrap_anchor() tools = [Tool(name="lookup", handler=lambda: "lookup result")] _ = [ @@ -421,9 +418,7 @@ async def test_provider_state_is_replayed_only_to_its_api_format_after_reload( tape=tape, model="google:model-one", tools=tools, system_prompt=None, prompt=to_content("lookup") ) ] - reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( - "metadata" - ) + reopened = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("metadata") if next_model.startswith("openai:"): runner.settings.client_args["api_format"] = "chat" events = [ @@ -448,10 +443,10 @@ async def test_named_tool_choice_is_sent_to_anthropic(tmp_path: Path, provider_s ]) ) async with provider_service.client() as client: - runner = ModelRunner( + runner = StepRunner( AgentSettings(client_args={"http_client": client}, completion_args={"tool_choice": republic.Tool("echo")}) ) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("choice") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("choice") _ = [ event async for event in runner.run( diff --git a/tests/test_environment.py b/tests/test_environment.py index 451c6ea9..1169996f 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -60,7 +60,7 @@ async def spawn( def _context(tmp_path: Path, environment: Environment) -> ToolContext: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") return ToolContext( tape=tape, run_id="run", state={"_runtime_workspace": str(tmp_path), ENVIRONMENT_STATE_KEY: environment} ) diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index 966ccc12..ad0f21fa 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -9,17 +9,16 @@ import httpx2 import pytest -from bub.builtin.context import default_tape_context -from bub.builtin.model_runner import ModelRunner 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.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, chat_events, sse +from tests.model_fakes import ProviderService, StepRunner, chat_events, sse @pytest.mark.asyncio @@ -53,8 +52,8 @@ async def video(): sse([{"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}]) ) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("media") + runner = StepRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("media") await tape.ensure_bootstrap_anchor() events = [ event @@ -62,9 +61,7 @@ async def video(): tape=tape, model="google:test", tools=[], system_prompt=None, prompt=to_content(prompt) ) ] - reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( - "media" - ) + reopened = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("media") follow_up = [ event async for event in runner.run( @@ -104,8 +101,8 @@ async def aclose(self): client_args = {"api_format": "chat"} if external_client: client_args["http_client"] = client - runner = ModelRunner(AgentSettings(client_args=client_args)) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cancel") + runner = StepRunner(AgentSettings(client_args=client_args)) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cancel") events = runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello")) assert (await anext(events)).data == {"delta": "first"} await events.aclose() @@ -120,12 +117,12 @@ async def test_primary_success_does_not_require_an_available_fallback( ) -> None: provider_service.reply(sse(chat_events())) async with provider_service.client() as client: - runner = ModelRunner( + runner = StepRunner( AgentSettings( fallback_models=["not-installed:test"], client_args={"http_client": client, "api_format": "chat"} ) ) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") events = [ event async for event in runner.run( @@ -160,10 +157,8 @@ async def test_fresh_process_replays_tool_signatures_to_the_provider( ]) ) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( - "persisted" - ) + runner = StepRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("persisted") await tape.ensure_bootstrap_anchor() _ = [ event @@ -179,8 +174,8 @@ async def test_fresh_process_replays_tool_signatures_to_the_provider( import asyncio, json, sys from pathlib import Path import httpx2 -from bub.builtin.context import default_tape_context -from bub.builtin.model_runner import ModelRunner +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 @@ -192,8 +187,8 @@ def respond(request): return httpx2.Response(200, headers={"content-type": "text/event-stream"}, text="data: " + json.dumps(event) + "\\n\\n") async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as client: directory = Path(sys.argv[1]) - tape = Tape(directory, AsyncTapeStoreAdapter(FileTapeStore(directory)), default_tape_context()).scoped("persisted") - runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(AsyncTapeStoreAdapter(FileTapeStore(directory)), default_tape_context()).scoped("persisted") + runner = StepRunner(AgentSettings(client_args={"http_client": client})) events = [event async for event in runner.run(tape=tape, model="google:test", tools=[], system_prompt=None, prompt=["Continue."])] print(json.dumps({"request": bodies[0], "result": events[-1].data})) asyncio.run(main()) @@ -212,8 +207,8 @@ def respond(request): async def test_media_urls_preserve_explicit_mime_types(tmp_path: Path, provider_service: ProviderService) -> None: provider_service.reply(sse([{"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}])) async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("urls") + runner = StepRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("urls") events = [ event async for event in runner.run( @@ -251,8 +246,8 @@ async def test_invalid_content_fails_without_a_provider_request( tmp_path: Path, provider_service: ProviderService, block: dict ) -> None: async with provider_service.client() as client: - runner = ModelRunner(AgentSettings(client_args={"http_client": client})) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid") + runner = StepRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid") with pytest.raises(BubError) as exc: _ = [ event diff --git a/tests/test_sdk_isolation.py b/tests/test_sdk_isolation.py index 9f5552c5..eab0d938 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -1,12 +1,15 @@ from pathlib import Path -from unittest.mock import Mock import pytest -from bub.builtin import Agent +from bub.builtin import BubAgent from bub.builtin.codemode import run_code, set_code_mode +from bub.builtin.model import BubModel from bub.builtin.tools import resolve_tool_names, run_subagent, show_help +from bub.core import Agent as CoreAgent +from bub.core.tape import session_tape_name 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 @@ -21,6 +24,23 @@ async def events(): return AsyncStreamEvents(events()) +def capture_steps(monkeypatch: pytest.MonkeyPatch) -> list[tuple[CoreAgent, Tape, BubModel]]: + """Replace model steps with a fixed reply and record the core agents, tapes and models that ran them.""" + steps: list[tuple[CoreAgent, Tape, BubModel]] = [] + + def run_once(self: CoreAgent, prompt: object, *, model: BubModel, tape: Tape, **_: object) -> AsyncStreamEvents: + steps.append((self, tape, model)) + return _reply() + + monkeypatch.setattr(CoreAgent, "run_once", run_once) + return steps + + +async def step_tool_names(step: tuple[CoreAgent, Tape, BubModel]) -> list[str]: + agent, tape, _ = step + return [tool.name for tool in await agent.prepare_tools(tape, None)] + + @pytest.fixture def framework(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> BubFramework: monkeypatch.setenv("BUB_HOME", str(tmp_path)) @@ -35,9 +55,9 @@ async def test_sdk_agents_execute_only_their_own_command_prefix( framework: BubFramework, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("BUB_COMMAND_PREFIX", "!") - monkeypatch.setattr("bub.builtin.model_runner.ModelRunner.run", lambda *args, **kwargs: _reply()) - custom = Agent(framework, tools=[show_help], skill_dirs=[], command_prefix="::") - configured = Agent(framework, tools=[show_help], skill_dirs=[]) + monkeypatch.setattr("bub.core.agent.Agent.run_once", lambda *args, **kwargs: _reply()) + custom = BubAgent(framework, tools=[show_help], skill_dirs=[], command_prefix="::") + configured = BubAgent(framework, tools=[show_help], skill_dirs=[]) for agent, prompt, expected in [ (custom, " ::help ", "Commands use '::'"), @@ -54,12 +74,12 @@ async def test_sdk_agents_execute_only_their_own_command_prefix( @pytest.mark.parametrize("prefix", [",", "::"]) @pytest.mark.parametrize("enabled", [True, False]) async def test_prefixed_code_mode_command_is_restored_by_a_new_agent( - framework: BubFramework, prefix: str, enabled: bool + framework: BubFramework, monkeypatch: pytest.MonkeyPatch, prefix: str, enabled: bool ) -> None: store = InMemoryTapeStore() tools = [set_code_mode, run_code] - agent = Agent(framework, tools=tools, tape_store=store, skill_dirs=[], command_prefix=prefix) - tape = agent.tape.session_tape("sdk", framework.workspace) + agent = BubAgent(framework, tools=tools, tape_store=store, skill_dirs=[], command_prefix=prefix) + tape = agent.tape(session_tape_name("sdk", framework.workspace)) await tape.append_event("code_mode_switch", {"code_mode": not enabled}) stream = await agent.run_stream(session_id="sdk", prompt=f"{prefix}code_mode enable={str(enabled).lower()}") @@ -69,15 +89,13 @@ async def test_prefixed_code_mode_command_is_restored_by_a_new_agent( event.data.get("delta") == f"Session code mode {status} (applies from the next turn)." for event in events ) - restored = Agent(framework, tools=tools, tape_store=store, skill_dirs=[], command_prefix=prefix) - runner = Mock(side_effect=lambda **kwargs: _reply()) - restored.model_runner.run = runner + restored = BubAgent(framework, tools=tools, tape_store=store, skill_dirs=[], command_prefix=prefix) + steps = capture_steps(monkeypatch) stream = await restored.run_stream(session_id="sdk", prompt="hello") assert [event.kind async for event in stream] == ["text", "final"] - call = runner.call_args.kwargs - assert call["tape"].context.state["code_mode"] is enabled - assert [tool.name for tool in call["tools"]] == (["run_code"] if enabled else []) + assert steps[-1][1].context.state["code_mode"] is enabled + assert await step_tool_names(steps[-1]) == (["run_code"] if enabled else []) @pytest.mark.asyncio @@ -91,13 +109,13 @@ async def lookup(**kwargs: object) -> dict[str, str]: handler=lookup, renderer=lambda result: result["output"], ) - agent = Agent(framework, tools=[command_tool], skill_dirs=[], command_prefix="::") + agent = BubAgent(framework, tools=[command_tool], skill_dirs=[], command_prefix="::") stream = await agent.run_stream(session_id="sdk", prompt=f"::{command}") output = "".join([event.data["delta"] async for event in stream if event.kind == "text"]) assert output == "found" - tape = agent.tape.session_tape("sdk", framework.workspace) + tape = agent.tape(session_tape_name("sdk", framework.workspace)) events = list(await tape.store.fetch_all(tape.query().kinds("event"))) recorded = [entry.payload["data"] for entry in events if entry.payload.get("name") == "command"] assert len(recorded) == 1 @@ -109,21 +127,20 @@ async def lookup(**kwargs: object) -> dict[str, str]: @pytest.mark.parametrize("has_saved_state", [False, True]) @pytest.mark.parametrize("override", [False, True]) async def test_sdk_recovers_only_its_store_and_honors_explicit_overrides( - framework: BubFramework, has_saved_state: bool, override: bool + framework: BubFramework, monkeypatch: pytest.MonkeyPatch, has_saved_state: bool, override: bool ) -> None: builtin = framework.plugin_manager.get_plugin("builtin") - builtin_tape = builtin._get_agent().tape.session_tape("shared", framework.workspace) + builtin_tape = builtin._get_agent().tape(session_tape_name("shared", framework.workspace)) await builtin_tape.append_event("model_switch", {"model": "test:other"}) await builtin_tape.append_event("reasoning_effort_switch", {"reasoning_effort": "low"}) - agent = Agent(framework, tools=[], tape_store=InMemoryTapeStore(), skill_dirs=[]) - tape = agent.tape.session_tape("shared", framework.workspace) + agent = BubAgent(framework, tools=[], tape_store=InMemoryTapeStore(), skill_dirs=[]) + tape = agent.tape(session_tape_name("shared", framework.workspace)) if has_saved_state: await tape.append_event("model_switch", {"model": "test:saved"}) await tape.append_event("reasoning_effort_switch", {"reasoning_effort": "high"}) - runner = Mock(side_effect=lambda **kwargs: _reply()) - agent.model_runner.run = runner + steps = capture_steps(monkeypatch) stream = await agent.run_stream( session_id="shared", prompt="hello", @@ -131,11 +148,12 @@ async def test_sdk_recovers_only_its_store_and_honors_explicit_overrides( reasoning_effort="medium" if override else None, ) assert [event.kind async for event in stream] == ["text", "final"] - call = runner.call_args.kwargs expected_model = "test:saved" if has_saved_state else agent.settings.model - assert call["model"] == ("test:explicit" if override else expected_model) - state = call["tape"].context.state - assert state.get("reasoning_effort") == ("medium" if override else "high" if has_saved_state else None) + assert steps[-1][2].name == ("test:explicit" if override else expected_model) + expected_effort = "medium" if override else "high" if has_saved_state else None + assert steps[-1][2].options.get("reasoning_effort") == expected_effort + state = steps[-1][1].context.state + assert state.get("reasoning_effort") == expected_effort assert state["_runtime_agent"] is agent @@ -152,24 +170,26 @@ def test_instance_tool_names_resolve_aliases_and_exclusions_from_one_index() -> @pytest.mark.asyncio -async def test_agent_allowlist_accepts_unregistered_instance_tool(framework: BubFramework) -> None: +async def test_agent_allowlist_accepts_unregistered_instance_tool( + framework: BubFramework, monkeypatch: pytest.MonkeyPatch +) -> None: tool = Tool.from_callable(lambda: "found", name="sdk.lookup") - agent = Agent(framework, tools=[tool], tape_store=InMemoryTapeStore(), skill_dirs=[]) - runner = Mock(side_effect=lambda **kwargs: _reply()) - agent.model_runner.run = runner + agent = BubAgent(framework, tools=[tool], tape_store=InMemoryTapeStore(), skill_dirs=[]) + steps = capture_steps(monkeypatch) stream = await agent.run_stream(session_id="sdk", prompt="lookup", allowed_tools=[" SDK_LOOKUP "]) assert [event.kind async for event in stream] == ["text", "final"] - assert [tool.name for tool in runner.call_args.kwargs["tools"]] == ["sdk_lookup"] + assert await step_tool_names(steps[-1]) == ["sdk_lookup"] @pytest.mark.asyncio @pytest.mark.parametrize("allowed_tools", [None, ["SDK_LOOKUP"]]) -async def test_subagent_uses_parent_instance_tools(framework: BubFramework, allowed_tools: list[str] | None) -> None: +async def test_subagent_uses_parent_instance_tools( + framework: BubFramework, monkeypatch: pytest.MonkeyPatch, allowed_tools: list[str] | None +) -> None: tool = Tool.from_callable(lambda: "found", name="sdk.lookup") - agent = Agent(framework, tools=[tool, run_subagent], tape_store=InMemoryTapeStore(), skill_dirs=[]) - runner = Mock(side_effect=lambda **kwargs: _reply()) - agent.model_runner.run = runner - tape: Tape = agent.tape.session_tape("parent", framework.workspace) + agent = BubAgent(framework, tools=[tool, run_subagent], tape_store=InMemoryTapeStore(), skill_dirs=[]) + steps = capture_steps(monkeypatch) + tape: Tape = agent.tape(session_tape_name("parent", framework.workspace)) context = ToolContext( tape=tape, state={"_runtime_agent": agent, "session_id": "parent", "_runtime_workspace": str(framework.workspace)}, @@ -177,4 +197,22 @@ async def test_subagent_uses_parent_instance_tools(framework: BubFramework, allo result = await run_subagent.run(prompt="lookup", allowed_tools=allowed_tools, context=context) assert result["output"] == "done" assert run_subagent.render(result) == "done" - assert [tool.name for tool in runner.call_args.kwargs["tools"]] == ["sdk_lookup"] + assert await step_tool_names(steps[-1]) == ["sdk_lookup"] + + +@pytest.mark.asyncio +async def test_builtin_channels_create_the_agent_with_the_running_store(framework: BubFramework) -> None: + store = InMemoryTapeStore() + + class StorePlugin: + @hookimpl + def provide_tape_store(self) -> InMemoryTapeStore: + return store + + framework.plugin_manager.register(StorePlugin(), name="store") + cli = framework.get_channels(lambda message: None)["cli"] # Channels exist before running(). + + async with framework.running(): + await cli._agent.tape("early").append_event("probe", {}) # type: ignore[attr-defined] + + assert store.list_tapes() == ["early"] diff --git a/tests/test_session_settings.py b/tests/test_session_settings.py index 0996138c..bc49ab9f 100644 --- a/tests/test_session_settings.py +++ b/tests/test_session_settings.py @@ -11,7 +11,7 @@ def _context(tmp_path: Path) -> ToolContext: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("session") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("session") return ToolContext(tape=tape, run_id="run-1", state={}) diff --git a/tests/test_settings.py b/tests/test_settings.py index 8f960027..632fd546 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,14 +8,13 @@ import pytest from pydantic import ValidationError -from bub.builtin.model_runner import ModelRunner 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, chat_events, sse +from tests.model_fakes import ProviderService, StepRunner, chat_events, sse def _settings_with_env(env: dict[str, str]) -> AgentSettings: @@ -187,10 +186,10 @@ def test_load_settings_returns_loaded_config(load_config) -> None: async def _run(settings: AgentSettings, tmp_path: Path) -> str: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("settings") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("settings") events = [ event - async for event in ModelRunner(settings).run( + async for event in StepRunner(settings).run( tape=tape, model=settings.model, tools=[], system_prompt=None, prompt=to_content("Hello") ) ] @@ -365,3 +364,18 @@ def test_model_clients_identify_as_bub() -> None: assert provider.headers["User-Agent"] == f"bub/{bub.__version__}" custom = AgentSettings(api_key="sk-test", client_args={"headers": {"User-Agent": "mine", "X-Team": "a"}}) assert custom.model_client_kwargs("openai")["headers"] == {"User-Agent": "mine", "X-Team": "a"} + + +def test_bub_model_resolves_custom_providers_and_default_fallbacks() -> None: + from bub.builtin.model import BubModel + + model = BubModel( + "relay:gpt", fallbacks=["OpenAI:mini"], providers={"relay": "openai"}, client_options=lambda name: {"n": name} + ) + + assert [(c.provider, c.model_id, c.provider_name) for c in model.candidates()] == [ + ("openai", "gpt", "relay"), + ("openai", "mini", None), + ] + assert [c.name for c in model.candidates("anthropic:claude")] == ["anthropic:claude"] + assert model.client_kwargs("relay") == {"n": "relay"} diff --git a/tests/test_spill.py b/tests/test_spill.py index 7669c3b9..2e870955 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -8,9 +8,8 @@ import pytest import republic -from bub.builtin.context import default_tape_context +from bub.builtin.archive import reset_tape from bub.builtin.hook_impl import BuiltinImpl -from bub.builtin.model_runner import ModelRunner from bub.builtin.settings import AgentSettings from bub.builtin.spill import ( SPILL_READ_MODEL_NAME, @@ -21,25 +20,28 @@ spill_read, ) from bub.builtin.tools import render_tools_prompt +from bub.core.context import default_tape_context +from bub.core.events import AfterToolCall, EventBus from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl -from bub.hooks.interception import AgentHooks, ToolCall, ToolCallDecision, ToolCallResult +from bub.hooks.interception import PluginAgentHooks, ToolCall, ToolCallResult from bub.hooks.runtime import HookRuntime from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore from bub.tape import Tape from bub.tools import Tool, ToolContext, ToolExecutor, model_tools -from tests.model_fakes import ProviderService +from tests.model_fakes import ProviderService, StepRunner -class _SpillHooks: - async def before_tool_call(self, call: ToolCall, state: dict[str, Any]) -> tuple[ToolCall, ToolCallDecision]: - return call, ToolCallDecision.proceed() +def _spill_events() -> EventBus: + async def spill(event: AfterToolCall) -> None: + await BuiltinImpl.after_tool_call(None, event.call, event.result, event.state) # type: ignore[arg-type] - async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict[str, Any]) -> None: - await BuiltinImpl.after_tool_call(self, call, result, state) # type: ignore[arg-type] + events = EventBus() + events.on(AfterToolCall, spill) + return events def _spill_executor() -> ToolExecutor: - return ToolExecutor(hooks=_SpillHooks()) # type: ignore[arg-type] + return ToolExecutor(_spill_events()) def _spill_context(*, tape: Tape, run_id: str | None = None, code_mode: bool = False) -> ToolContext: @@ -66,7 +68,7 @@ def _page_field(page: str, name: str) -> str: def _root_tape(tmp_path: Path, store: TapeStore, *, threshold: int = 1) -> Tape: spill = SpillStore(SpillSettings(threshold=threshold)) - return Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context(), sidecars=(spill,)).scoped("session") + return Tape(AsyncTapeStoreAdapter(store), default_tape_context(), sidecars=(spill,)).scoped("session") async def _read_page( @@ -77,7 +79,7 @@ async def _read_page( count: int = 1, from_end: bool = False, ) -> str: - execution = await _spill_executor().execute_async( + execution = await _spill_executor().execute_many( [(spill_read, {"handle": handle, "cursor": cursor, "count": count, "from_end": from_end})], context=context, ) @@ -95,7 +97,7 @@ async def test_oversized_result_is_bounded_and_readable_across_merge(tmp_path: P async with root.fork_tape() as tape: context = _spill_context(tape=tape, run_id="run-1") tool = Tool(name="large", handler=lambda: output) - execution = await _spill_executor().execute_async([(tool, {})], context=context) + execution = await _spill_executor().execute_many([(tool, {})], context=context) ref = execution.tool_results[0] assert isinstance(ref, str) @@ -143,7 +145,7 @@ async def test_spill_configuration_preserves_results_that_should_not_be_spilled( async with root.fork_tape() as tape: context = _spill_context(tape=tape, run_id="run-1") small_results = ["tiny", {"value": "tiny"}, ["tiny"]] - small = await _spill_executor().execute_async( + small = await _spill_executor().execute_many( [ (Tool(name=f"small-{index}", handler=lambda result=result: result), {}) for index, result in enumerate(small_results) @@ -156,13 +158,13 @@ async def test_spill_configuration_preserves_results_that_should_not_be_spilled( def fail() -> None: raise RuntimeError("small failure") - small_error = await _spill_executor().execute_async([(Tool(name="failing", handler=fail), {})], context=context) + small_error = await _spill_executor().execute_many([(Tool(name="failing", handler=fail), {})], context=context) assert small_error.error is not None assert small_error.tool_results == [small_error.error.as_dict()] disabled = _root_tape(tmp_path, parent, threshold=0).scoped("disabled") async with disabled.fork_tape() as tape: - execution = await _spill_executor().execute_async( + execution = await _spill_executor().execute_many( [(Tool(name="large", handler=lambda: "x" * 20_000), {})], context=_spill_context(tape=tape, run_id="run-2"), ) @@ -176,7 +178,7 @@ async def test_oversized_structured_result_is_spilled_as_json(tmp_path: Path, ou async with root.fork_tape() as tape: context = _spill_context(tape=tape, run_id="run-1") - execution = await _spill_executor().execute_async( + execution = await _spill_executor().execute_many( [(Tool(name="structured", handler=lambda: output), {})], context=context ) @@ -200,11 +202,11 @@ def after_tool_call(self, result: ToolCallResult) -> None: plugin_manager.add_hookspecs(BubHookSpecs) plugin_manager.register(BuiltinImpl(None), name="builtin") # type: ignore[arg-type] plugin_manager.register(ExpandResult(), name="expand-result") - executor = ToolExecutor(hooks=AgentHooks(HookRuntime(plugin_manager))) + executor = ToolExecutor(PluginAgentHooks(HookRuntime(plugin_manager)).subscribe()) root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) async with root.fork_tape() as tape: - execution = await executor.execute_async( + execution = await executor.execute_many( [(Tool(name="expanded", handler=lambda: "small"), {})], context=_spill_context(tape=tape, run_id="run-1"), ) @@ -236,7 +238,7 @@ def after_tool_call(self, result: ToolCallResult) -> None: plugin_manager.add_hookspecs(BubHookSpecs) plugin_manager.register(BuiltinImpl(None), name="builtin") # type: ignore[arg-type] plugin_manager.register(ReplaceFailure(), name="replace-failure") - executor = ToolExecutor(hooks=AgentHooks(HookRuntime(plugin_manager))) + executor = ToolExecutor(PluginAgentHooks(HookRuntime(plugin_manager)).subscribe()) root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) def fail() -> None: @@ -244,7 +246,8 @@ def fail() -> None: async with root.fork_tape() as tape: context = _spill_context(tape=tape, run_id="run-1") - execution = await executor.execute_async([(Tool(name="bash", handler=fail), {})], context=context) + context.state["_runtime_tool_names"] = ("bash", SPILL_READ_MODEL_NAME) + execution = await executor.execute_many([(Tool(name="bash", handler=fail), {})], context=context) assert execution.error is not None result = execution.tool_results[0] @@ -267,7 +270,7 @@ def fail() -> None: async with root.fork_tape() as tape: context = _spill_context(tape=tape, run_id="run-1") - execution = await _spill_executor().execute_async([(Tool(name="failing", handler=fail), {})], context=context) + execution = await _spill_executor().execute_many([(Tool(name="failing", handler=fail), {})], context=context) assert execution.error is not None ref = execution.tool_results[0] @@ -287,7 +290,7 @@ async def test_temporary_fork_discards_spilled_content(tmp_path: Path) -> None: async with root.fork_tape(merge_back=False) as tape: context = _spill_context(tape=tape, run_id="run-1") - execution = await _spill_executor().execute_async( + execution = await _spill_executor().execute_many( [(Tool(name="large", handler=lambda: "x" * 20_000), {})], context=context ) handle = _handle_from_ref(execution.tool_results[0]) @@ -322,7 +325,7 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp await root.ensure_bootstrap_anchor() async with root.fork_tape() as tape: - execution = await _spill_executor().execute_async( + execution = await _spill_executor().execute_many( [(Tool(name="large", handler=lambda: "archived output\n" * 5000), {})], context=_spill_context(tape=tape, run_id="run-1"), ) @@ -338,7 +341,7 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp tool_results=execution.tool_results, ) - result = await root.reset(archive=True) + result = await reset_tape(root, archive_to=tmp_path) main_archive = Path(result.removeprefix("Archived: ")) spill_archives = list(tmp_path.glob(f"{sidecar}.jsonl.*.bak")) @@ -347,6 +350,11 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp assert handle in main_archive.read_text(encoding="utf-8") assert handle in spill_archives[0].read_text(encoding="utf-8") assert "no spilled tool result" in await _read_page(_spill_context(tape=root), handle) + events = [entry.payload for entry in await root.store.fetch_all(root.query().kinds("event"))] + assert [(event["name"], event["data"]["status"]) for event in events if event["name"].startswith("sidecar.")] == [ + ("sidecar.reset", "ok"), + ("sidecar.archive", "ok"), + ] @pytest.mark.asyncio @@ -356,8 +364,8 @@ async def test_code_mode_results_stay_structured_and_are_not_spilled(tmp_path: P async with root.fork_tape() as tape: context = _spill_context(tape=tape, run_id="run-1", code_mode=True) - executor = ToolExecutor(hooks=_SpillHooks(), render=False) # type: ignore[arg-type] - execution = await executor.execute_async( + executor = ToolExecutor(_spill_events(), render=False) + execution = await executor.execute_many( [(Tool(name="structured", handler=lambda: output), {})], context=context ) @@ -377,9 +385,9 @@ async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: d plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) plugin_manager.add_hookspecs(BubHookSpecs) plugin_manager.register(SpillPlugin()) - runner = ModelRunner( + runner = StepRunner( AgentSettings(model="openai:test", client_args={"api_format": "chat"}), - hooks=AgentHooks(HookRuntime(plugin_manager)), + hooks=PluginAgentHooks(HookRuntime(plugin_manager)), ) output = "large output " * 2000 @@ -418,7 +426,7 @@ def large() -> str: async def test_result_spill_without_tool_availability_keeps_full_output(tmp_path: Path) -> None: root = _root_tape(tmp_path, InMemoryTapeStore()) output = "x" * 20_000 - execution = await _spill_executor().execute_async( + execution = await _spill_executor().execute_many( [(Tool(name="large", handler=lambda: output), {})], context=ToolContext(tape=root) ) @@ -436,7 +444,7 @@ async def test_spill_bounds_text_of_content_results_and_keeps_media(tmp_path: Pa root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) async with root.fork_tape() as tape: - execution = await _spill_executor().execute_async( + execution = await _spill_executor().execute_many( [(Tool(name="run_code", handler=lambda: content_result(["x" * 20_000, image])), {})], context=_spill_context(tape=tape, run_id="run-1"), ) diff --git a/tests/test_tape.py b/tests/test_tape.py index ef4f5db8..21fa8be2 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -5,7 +5,7 @@ import pytest import republic -from bub.builtin.context import default_tape_context +from bub.core.context import default_tape_context from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore from bub.tape import Tape, TapeContext, TapeEntry, to_messages @@ -32,7 +32,7 @@ def test_tape_reexports_legacy_store_objects() -> None: @pytest.mark.asyncio async def test_legacy_tool_call_without_content_replays_with_its_result(tmp_path: Path) -> None: store = InMemoryTapeStore() - tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape") await tape.ensure_bootstrap_anchor() calls = [{"id": "call-1", "name": "inspect", "arguments": "{}"}] store.append("test-tape", TapeEntry(id=0, kind="tool_call", payload={"calls": calls})) @@ -48,7 +48,7 @@ async def test_legacy_tool_call_without_content_replays_with_its_result(tmp_path @pytest.mark.asyncio async def test_chat_completions_tool_calls_from_older_tapes_replay_with_their_results(tmp_path: Path) -> None: store = InMemoryTapeStore() - tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape") await tape.ensure_bootstrap_anchor() calls = [ {"id": "call-1", "type": "function", "function": {"name": "fs_read", "arguments": '{"path": "a.txt"}'}}, @@ -69,7 +69,7 @@ async def test_chat_completions_tool_calls_from_older_tapes_replay_with_their_re @pytest.mark.asyncio @pytest.mark.parametrize("content", ["", "done"]) async def test_text_only_response_remains_a_standalone_assistant_message(tmp_path: Path, content: str) -> None: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape") await tape.ensure_bootstrap_anchor() await tape.record_chat(run_id="run-1", system_prompt=None, new_messages=[], response_text=content) @@ -79,7 +79,7 @@ async def test_text_only_response_remains_a_standalone_assistant_message(tmp_pat @pytest.mark.asyncio async def test_tape_fork_binds_temporary_fork_store_to_scoped_tape(tmp_path: Path) -> None: parent = InMemoryTapeStore() - root = Tape(tmp_path, AsyncTapeStoreAdapter(parent), TapeContext()).scoped("test-tape") + root = Tape(AsyncTapeStoreAdapter(parent), TapeContext()).scoped("test-tape") async with root.fork_tape(merge_back=True) as forked: first_store = forked.store @@ -103,7 +103,7 @@ async def test_tape_fork_binds_temporary_fork_store_to_scoped_tape(tmp_path: Pat @pytest.mark.asyncio async def test_tape_info_reports_last_token_cache_hit_rate(tmp_path: Path) -> None: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") await tape.record_chat( run_id="run-1", system_prompt=None, @@ -125,7 +125,7 @@ async def test_tape_info_reports_last_token_cache_hit_rate(tmp_path: Path) -> No @pytest.mark.asyncio async def test_tape_info_omits_cache_hit_rate_when_usage_has_no_cache_details(tmp_path: Path) -> None: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") await tape.record_chat( run_id="run-1", system_prompt=None, @@ -149,7 +149,6 @@ def select_events(entries, _context): ] tape = Tape( - tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None, select=select_events), ).scoped("test-tape") @@ -161,7 +160,7 @@ def select_events(entries, _context): @pytest.mark.asyncio async def test_new_messages_round_trip_through_the_tape(tmp_path: Path) -> None: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape") await tape.ensure_bootstrap_anchor() prompt = republic.user("Describe this.", republic.image(b"png-bytes", media_type="image/png")) await tape.record_chat(run_id="run-1", system_prompt=None, new_messages=[prompt], response_text="A picture.") diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py index 19adac98..b84b0819 100644 --- a/tests/test_tool_loading.py +++ b/tests/test_tool_loading.py @@ -6,7 +6,7 @@ import pytest -from bub.builtin.agent import Agent +from bub.builtin.agent import BubAgent from bub.framework import BubFramework from bub.tools import Tool from tests.model_fakes import ProviderService @@ -65,7 +65,7 @@ def lookup(name: str) -> str: {"role": "assistant", "content": "Again"}, ], ) - agent = Agent(framework, tools=[direct, supplied, other], skill_dirs=[]) + agent = BubAgent(framework, tools=[direct, supplied, other], skill_dirs=[]) stream = await agent.run_stream(session_id="loading", prompt="Greet Ada.", model="openrouter:test-model") events = [event async for event in stream] @@ -101,7 +101,7 @@ async def test_allowed_tools_filter_covers_direct_and_deferred_tools( {"role": "assistant", "content": "done"}, ], ) - agent = Agent(framework, tools=[direct, denied, allowed_deferred, denied_deferred], skill_dirs=[]) + agent = BubAgent(framework, tools=[direct, denied, allowed_deferred, denied_deferred], skill_dirs=[]) stream = await agent.run_stream( session_id="allowed", @@ -128,7 +128,7 @@ async def test_tool_describe_is_hidden_without_deferred_tools( direct = Tool.from_callable(lambda: "direct", name="direct") requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) - agent = Agent(framework, tools=[direct, tool_describe], skill_dirs=[]) + agent = BubAgent(framework, tools=[direct, tool_describe], skill_dirs=[]) stream = await agent.run_stream(session_id="plain", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] @@ -147,7 +147,7 @@ async def test_calling_unloaded_deferred_tool_returns_load_guidance( provider_transport, [_tool_call("lookup", "provider_lookup", {}), {"role": "assistant", "content": "done"}], ) - agent = Agent(framework, tools=[supplied], skill_dirs=[]) + agent = BubAgent(framework, tools=[supplied], skill_dirs=[]) stream = await agent.run_stream(session_id="unloaded", prompt="Look up.", model="openrouter:test-model") _ = [event async for event in stream] @@ -166,7 +166,7 @@ async def test_code_mode_exposes_deferred_tools_to_code_without_loading( direct = Tool.from_callable(lambda: "direct", name="direct") supplied = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True) requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) - agent = Agent(framework, tools=[direct, supplied, run_code], skill_dirs=[]) + agent = BubAgent(framework, tools=[direct, supplied, run_code], skill_dirs=[]) state: dict[str, Any] = {"code_mode": True, "_runtime_workspace": str(tmp_path)} stream = await agent.run_stream(session_id="code", prompt="Hi.", model="openrouter:test-model", state=state) @@ -190,7 +190,7 @@ async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode( provider_transport, [_tool_call("secret", "secret", {}), {"role": "assistant", "content": "done"}], ) - agent = Agent(framework, tools=[direct, code_tool, deferred_code_tool, tool_describe], skill_dirs=[]) + agent = BubAgent(framework, tools=[direct, code_tool, deferred_code_tool, tool_describe], skill_dirs=[]) stream = await agent.run_stream(session_id="plain", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] @@ -210,7 +210,7 @@ async def test_code_exposure_tools_are_exposed_to_code_in_code_mode( direct = Tool.from_callable(lambda: "direct", name="direct") code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code") requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) - agent = Agent(framework, tools=[direct, code_tool, run_code], skill_dirs=[]) + agent = BubAgent(framework, tools=[direct, code_tool, run_code], skill_dirs=[]) state: dict[str, Any] = {"code_mode": True, "_runtime_workspace": str(tmp_path)} stream = await agent.run_stream(session_id="code", prompt="Hi.", model="openrouter:test-model", state=state) @@ -232,7 +232,7 @@ async def test_tool_describe_does_not_describe_code_exposure_tools( provider_transport, [_tool_call("describe", "tool_describe", {"names": ["secret"]}), {"role": "assistant", "content": "done"}], ) - agent = Agent(framework, tools=[deferred, code_tool, tool_describe], skill_dirs=[]) + agent = BubAgent(framework, tools=[deferred, code_tool, tool_describe], skill_dirs=[]) stream = await agent.run_stream(session_id="describe", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] diff --git a/tests/test_tools.py b/tests/test_tools.py index d7cb900f..99d07c5e 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,15 +1,17 @@ from __future__ import annotations +from dataclasses import replace from typing import Any import pytest from loguru import logger from pydantic import BaseModel -from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult +from bub.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, tool_call_reporter +from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool class EchoInput(BaseModel): @@ -93,87 +95,68 @@ def internal_tool(value: str) -> str: @pytest.mark.asyncio -async def test_tool_wrapper_logs_and_omits_context_from_log_payload(monkeypatch: pytest.MonkeyPatch) -> None: - tool_name = "tests.async_tool" - REGISTRY.pop(tool_name, None) +async def test_tool_executor_logs_calls_without_context(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: messages: list[str] = [] def record(message: str, *args: Any, **kwargs: Any) -> None: messages.append(message.format(*args, **kwargs)) monkeypatch.setattr(logger, "info", record) + echo = Tool.from_callable(lambda value, context: value, name="tests.logged_tool", context=True) - @tool(name=tool_name, description="Async test tool", context=True) - async def async_tool(value: str, context: object) -> str: - return f"{value}:{context}" - - result = await async_tool.run("hello", context="ctx") + execution = await ToolExecutor().execute_many([(echo, {"value": "hello"})], context=_context(tmp_path)) - assert result == "hello:ctx" - assert REGISTRY[tool_name] is async_tool + assert execution.tool_results == ["hello"] assert len(messages) == 2 - assert messages[0] == 'tool.call.start name=tests.async_tool { "hello" }' - assert messages[1].startswith("tool.call.success name=tests.async_tool elapsed_time=") + assert messages[0] == 'tool.call.start name=tests.logged_tool { value="hello" }' + assert messages[1].startswith("tool.call.success name=tests.logged_tool elapsed_time=") @pytest.mark.asyncio -async def test_tool_wrapper_logs_failures_before_reraising(monkeypatch: pytest.MonkeyPatch) -> None: - tool_name = "tests.failing_tool" - REGISTRY.pop(tool_name, None) - errors: list[str] = [] +async def test_tool_executor_logs_failures(monkeypatch: pytest.MonkeyPatch) -> None: + errors: list[tuple[str, BaseException | None]] = [] - def record_exception(message: str, *args: Any, **kwargs: Any) -> None: - errors.append(message.format(*args, **kwargs)) + class Recorder: + def __init__(self, exception: BaseException | None = None) -> None: + self.exception = exception - monkeypatch.setattr(logger, "exception", record_exception) + def opt(self, *, exception: BaseException | None = None) -> Recorder: + return Recorder(exception) - @tool(name=tool_name) - def failing_tool() -> str: - raise RuntimeError("boom") - - with pytest.raises(RuntimeError, match="boom"): - await failing_tool.run() + def info(self, message: str, *args: Any) -> None: + pass - assert len(errors) == 1 - assert errors[0].startswith("tool.call.error name=tests.failing_tool elapsed_time=") + def error(self, message: str, *args: Any) -> None: + errors.append((message.format(*args), self.exception)) + monkeypatch.setattr("bub.core.tools.logger", Recorder()) -@pytest.mark.asyncio -async def test_tool_wrapper_uses_reporter_instead_of_logs(monkeypatch: pytest.MonkeyPatch) -> None: - tool_name = "tests.reported_tool" - REGISTRY.pop(tool_name, None) - logged: list[str] = [] - reported: list[tuple[str, str, Any]] = [] - - def record_log(message: str, *args: Any, **kwargs: Any) -> None: - logged.append(message.format(*args, **kwargs)) + def failing_tool() -> str: + raise RuntimeError("boom") - class Reporter: - def start(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: - reported.append(("start", name, {"args": args, "kwargs": kwargs})) + execution = await ToolExecutor().execute_many([(Tool.from_callable(failing_tool), {})]) - def success(self, name: str, result: Any, elapsed_ms: float) -> None: - reported.append(("success", name, {"result": result, "elapsed_ms": elapsed_ms})) + assert execution.error is not None + ((message, exception),) = errors + assert message.startswith("tool.call.error name=failing_tool elapsed_time=") + assert exception is execution.error + assert isinstance(exception.__cause__, RuntimeError) - def error(self, name: str, error: BaseException, elapsed_ms: float) -> None: - reported.append(("error", name, {"error": error, "elapsed_ms": elapsed_ms})) - monkeypatch.setattr(logger, "info", record_log) - monkeypatch.setattr(logger, "exception", record_log) +@pytest.mark.asyncio +async def test_tool_executor_logs_arguments_changed_by_handlers(monkeypatch: pytest.MonkeyPatch) -> None: + messages: list[str] = [] + monkeypatch.setattr(logger, "info", lambda message, *args: messages.append(message.format(*args))) + events = EventBus() - @tool(name=tool_name) - def reported_tool(value: str) -> str: - return value.upper() + @events.on(BeforeToolCall) + def rewrite(event: BeforeToolCall) -> None: + event.call = replace(event.call, arguments={"value": "rewritten"}) - with tool_call_reporter(Reporter()): - result = await reported_tool.run("hello") + echo = Tool.from_callable(lambda value: value, name="tests.echo") + await ToolExecutor(events).execute_many([(echo, {"value": "original"})]) - assert result == "HELLO" - assert logged == [] - assert reported[0] == ("start", tool_name, {"args": ("hello",), "kwargs": {}}) - assert reported[1][0] == "success" - assert reported[1][1] == tool_name - assert reported[1][2]["result"] == "HELLO" + assert messages[0] == 'tool.call.start name=tests.echo { value="rewritten" }' @pytest.mark.asyncio @@ -191,7 +174,7 @@ def direct_call(value: str) -> str: def _context(tmp_path, *, code_mode: bool = False) -> ToolContext: - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") + tape = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext()).scoped("test-tape") return ToolContext(tape=tape, run_id="run-1", code_mode=code_mode) @@ -237,7 +220,7 @@ async def test_executor_render_option_decides_result_format( renderer=lambda result: f"count={result['count']}", ) - execution = await ToolExecutor(render=render).execute_async( + execution = await ToolExecutor(render=render).execute_many( [(structured, {})], context=_context(tmp_path, code_mode=code_mode) ) @@ -249,19 +232,15 @@ async def test_executor_render_option_decides_result_format( async def test_executor_passes_tool_context_to_context_tools_and_hooks(tmp_path) -> None: seen: dict[str, Any] = {} - class RecordingHooks: - async def before_tool_call(self, call: ToolCall, state: dict[str, Any]) -> tuple[ToolCall, ToolCallDecision]: - seen["before"] = call.context - return call, ToolCallDecision.proceed() - - async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: dict[str, Any]) -> None: - seen["after"] = (call.context, result.result) + events = EventBus() + events.on(BeforeToolCall, lambda event: seen.update(before=event.call.context)) + events.on(AfterToolCall, lambda event: seen.update(after=(event.call.context, event.result.result))) def mode(*, context: ToolContext) -> dict[str, bool]: return {"code_mode": context.code_mode} context = _context(tmp_path, code_mode=True) - execution = await ToolExecutor(hooks=RecordingHooks(), render=False).execute_async( # type: ignore[arg-type] + execution = await ToolExecutor(events, render=False).execute_many( [(Tool.from_callable(mode, context=True), {})], context=context ) @@ -270,6 +249,26 @@ def mode(*, context: ToolContext) -> dict[str, bool]: assert seen["after"] == (context, {"code_mode": True}) +@pytest.mark.asyncio +async def test_executor_execute_returns_the_result_or_raises_the_tool_error() -> None: + events = EventBus() + finished: list[AfterToolCall] = [] + events.on(AfterToolCall, finished.append) + executor = ToolExecutor(events, render=False) + + def failing_tool() -> str: + raise RuntimeError("boom") + + assert await executor.execute(Tool.from_callable(lambda value: {"value": value}, name="echo"), {"value": 1}) == { + "value": 1 + } + with pytest.raises(BubError, match="Tool 'failing_tool' execution failed") as raised: + await executor.execute(Tool.from_callable(failing_tool), {}) + + assert isinstance(raised.value.__cause__, RuntimeError) + assert [event.call.tool for event in finished] == ["echo", "failing_tool"] + + @pytest.mark.asyncio async def test_executor_reports_renderer_failures_as_tool_errors(tmp_path) -> None: def broken_renderer(_result: object) -> str: @@ -277,7 +276,7 @@ def broken_renderer(_result: object) -> str: broken = Tool(name="tests.broken_renderer", handler=lambda: {}, renderer=broken_renderer) - execution = await ToolExecutor().execute_async([(broken, {})], context=_context(tmp_path)) + execution = await ToolExecutor().execute_many([(broken, {})], context=_context(tmp_path)) assert execution.error is not None assert execution.tool_results[0]["message"] == "Tool 'tests.broken_renderer' execution failed." diff --git a/tests/test_tracing.py b/tests/test_tracing.py index eb842fb0..a23352d6 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -12,10 +12,10 @@ import pytest from bub import tracing -from bub.builtin.agent import Agent -from bub.builtin.context import default_tape_context -from bub.builtin.model_runner import ModelRunner +from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings +from bub.core.context import default_tape_context +from bub.core.tape import session_tape_name from bub.framework import BubFramework from bub.hooks import hookimpl from bub.hooks.interception import LlmCallDecision, ToolCallDecision, ToolCallResult @@ -42,13 +42,12 @@ def spans(monkeypatch: pytest.MonkeyPatch) -> Iterator[Any]: @pytest.fixture -def agent(tmp_path: Path, provider_transport: ProviderService) -> Agent: +def agent(tmp_path: Path, provider_transport: ProviderService) -> BubAgent: framework = BubFramework(config_file=tmp_path / "config.yml") framework.load_builtin_hooks() - agent = Agent(framework) + agent = BubAgent(framework) agent.settings = AgentSettings(model="openai:test", api_key="unused", client_args={"api_format": "chat"}) - agent.model_runner = ModelRunner(agent.settings, hooks=framework.get_agent_hooks()) - agent.__dict__["tape"] = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()) + agent.__dict__["_tapes"] = Tape(AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()) return agent @@ -59,7 +58,7 @@ def call(name: str, call_id: str, arguments: dict[str, Any] | None = None) -> di @pytest.mark.asyncio async def test_agent_trajectory_has_parallel_tools_messages_and_tape_links( spans: Any, - agent: Agent, + agent: BubAgent, monkeypatch: pytest.MonkeyPatch, provider_transport: ProviderService, ) -> None: @@ -107,12 +106,16 @@ async def handler() -> dict[str, str]: assert [m["role"] for m in messages] == ["assistant", "tool", "tool", "assistant"] assert messages[-1]["parts"] == [{"type": "text", "content": "done"}] assert any(e.name == "bub.loop.step" for e in root.events) - entries = await agent.tape.store.fetch_all(agent.tape.session_tape("trace-test", workspace_from_state({})).query()) + entries = await agent.tape(session_tape_name("trace-test", workspace_from_state({}))).store.fetch_all( + agent.tape(session_tape_name("trace-test", workspace_from_state({}))).query() + ) assert any(e.meta.get("trace_id") == f"{root.context.trace_id:032x}" for e in entries) @pytest.mark.asyncio -async def test_subagent_is_nested_under_its_tool(spans: Any, agent: Agent, provider_transport: ProviderService) -> None: +async def test_subagent_is_nested_under_its_tool( + spans: Any, agent: BubAgent, provider_transport: ProviderService +) -> None: provider_transport.reply_chat(calls=[call("subagent", "child", {"prompt": "child task"})]) provider_transport.reply_chat("child done") provider_transport.reply_chat() @@ -136,7 +139,7 @@ async def test_subagent_is_nested_under_its_tool(spans: Any, agent: Agent, provi @pytest.mark.parametrize("cancel", [False, True]) async def test_stream_close_and_cancel_finish_spans_and_provider( spans: Any, - agent: Agent, + agent: BubAgent, provider_transport: ProviderService, cancel: bool, ) -> None: @@ -177,50 +180,50 @@ async def aclose(self): @pytest.mark.asyncio -async def test_close_before_first_iteration_releases_tape(spans: Any, agent: Agent) -> None: +async def test_close_before_first_iteration_releases_tape(spans: Any, agent: BubAgent) -> None: events = await agent.run_stream(session_id="unused", prompt="hello", state={}, allowed_tools=[]) await events.aclose() assert len(spans.get_finished_spans()) == 1 # Fork contents were merged by the close callback even though the generator never started. - entries = await agent.tape.store.fetch_all(agent.tape.session_tape("unused", workspace_from_state({})).query()) + entries = await agent.tape(session_tape_name("unused", workspace_from_state({}))).store.fetch_all( + agent.tape(session_tape_name("unused", workspace_from_state({}))).query() + ) assert any(e.payload.get("name") == "loop.start" for e in entries) @pytest.mark.asyncio async def test_setup_failure_releases_tape_and_finishes_agent_span( - spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch + spans: Any, agent: BubAgent, monkeypatch: pytest.MonkeyPatch ) -> None: - async def fail_setup(*, tape: Tape, **kwargs: Any) -> AsyncStreamEvents: + async def fail_setup(tape: Tape, prompt: Any, **kwargs: Any) -> AsyncStreamEvents: await tape.append_event("setup.started", {}) raise ValueError("setup failed") - monkeypatch.setattr(agent, "_agent_loop", fail_setup) + monkeypatch.setattr(agent, "run_on", fail_setup) with pytest.raises(ValueError, match="setup failed"): await agent.run_stream(session_id="setup-failure", prompt="hello", state={}) (span,) = spans.get_finished_spans() assert span.status.status_code.name == "ERROR" assert span.attributes["error.type"] == "ValueError" - entries = await agent.tape.store.fetch_all( - agent.tape.session_tape("setup-failure", workspace_from_state({})).query() + entries = await agent.tape(session_tape_name("setup-failure", workspace_from_state({}))).store.fetch_all( + agent.tape(session_tape_name("setup-failure", workspace_from_state({}))).query() ) assert any(e.payload.get("name") == "setup.started" for e in entries) assert tracing.current_span() is None @pytest.mark.asyncio -async def test_state_loading_failure_finishes_agent_span( - spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch +async def test_state_loading_failure_happens_before_the_agent_span( + spans: Any, agent: BubAgent, monkeypatch: pytest.MonkeyPatch ) -> None: async def fail_state(*args: Any, **kwargs: Any) -> None: - assert tracing.current_span() is not None + assert tracing.current_span() is None raise ValueError("state loading failed") monkeypatch.setattr(agent.framework, "build_state", fail_state) with pytest.raises(ValueError, match="state loading failed"): await agent.run_stream(session_id="state-failure", prompt="hello") - (span,) = spans.get_finished_spans() - assert span.status.status_code.name == "ERROR" - assert span.attributes["error.type"] == "ValueError" + assert spans.get_finished_spans() == () assert tracing.current_span() is None assert not tracing.otel.get_current_span().get_span_context().is_valid @@ -251,7 +254,7 @@ async def cleanup() -> None: @pytest.mark.asyncio -async def test_llm_short_circuit_has_no_phantom_model_span(spans: Any, agent: Agent) -> None: +async def test_llm_short_circuit_has_no_phantom_model_span(spans: Any, agent: BubAgent) -> None: class Finish: @hookimpl def before_llm_call(self) -> LlmCallDecision: @@ -264,7 +267,7 @@ def before_llm_call(self) -> LlmCallDecision: @pytest.mark.asyncio -async def test_failed_tool_records_effective_result_and_original_failure(spans: Any, agent: Agent) -> None: +async def test_failed_tool_records_effective_result_and_original_failure(spans: Any, agent: BubAgent) -> None: class Policy: @hookimpl(tryfirst=True) def before_tool_call(self) -> ToolCallDecision: @@ -275,9 +278,9 @@ def after_tool_call(self, result: ToolCallResult) -> None: result.result = "bounded failure" agent.framework.plugin_manager.register(Policy()) - execution = await ToolExecutor(agent.framework.get_agent_hooks()).execute_async( + execution = await ToolExecutor(agent.framework.get_agent_hooks().subscribe()).execute_many( [(Tool(name="denied", handler=lambda: pytest.fail("must not run")), {})], - context=ToolContext(agent.tape, "run-1"), + context=ToolContext(agent.tape("tools"), "run-1"), call_ids=["denied-1"], ) assert execution.error is not None @@ -289,16 +292,16 @@ def after_tool_call(self, result: ToolCallResult) -> None: @pytest.mark.asyncio async def test_provider_fallback_records_actual_model( - spans: Any, agent: Agent, provider_service: ProviderService + spans: Any, agent: BubAgent, provider_service: ProviderService ) -> None: import httpx2 provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}})) provider_service.reply(sse(chat_events())) async with provider_service.client() as client: - agent.model_runner.settings.fallback_models = ["openai:fallback"] - agent.model_runner.settings.model = "openai:unavailable" - agent.model_runner.settings.client_args = {"http_client": client, "api_format": "chat", "max_retries": 0} + agent.settings.fallback_models = ["openai:fallback"] + agent.settings.model = "openai:unavailable" + agent.settings.client_args = {"http_client": client, "api_format": "chat", "max_retries": 0} events = await agent.run_stream(session_id="fallback", prompt="hello", state={}, allowed_tools=[]) async for _ in events: pass @@ -319,14 +322,14 @@ def find_spec(self, fullname, path=None, target=None): raise ModuleNotFoundError(fullname) sys.meta_path.insert(0, BlockTelemetry()) from bub import tracing -from bub.builtin.agent import Agent +from bub.builtin.agent import BubAgent from bub.tools import Tool, ToolExecutor assert tracing.otel is None async def main(): span = tracing.Span('noop') assert not span.recording with span.activate(): - result = await ToolExecutor().execute_async([(Tool(name='echo', handler=lambda: 'ok'), {})]) + result = await ToolExecutor().execute_many([(Tool(name='echo', handler=lambda: 'ok'), {})]) assert result.tool_results == ['ok'] span.end() assert tracing.correlation() == {} @@ -338,7 +341,7 @@ async def main(): @pytest.mark.asyncio async def test_streaming_usage_arrives_on_last_chunk( - spans: Any, agent: Agent, provider_transport: ProviderService + spans: Any, agent: BubAgent, provider_transport: ProviderService ) -> None: provider_transport.reply_chat(model="stream-model", input_tokens=19, output_tokens=3) events = await agent.run_stream(session_id="stream", prompt="hello", state={}, allowed_tools=[]) @@ -355,7 +358,7 @@ async def test_streaming_usage_arrives_on_last_chunk( @pytest.mark.asyncio async def test_timeout_marks_model_and_agent_as_failed( - spans: Any, agent: Agent, provider_transport: ProviderService + spans: Any, agent: BubAgent, provider_transport: ProviderService ) -> None: import httpx2 @@ -378,7 +381,7 @@ async def __aiter__(self): @pytest.mark.asyncio async def test_concurrent_sessions_have_independent_traces( - spans: Any, agent: Agent, provider_transport: ProviderService + spans: Any, agent: BubAgent, provider_transport: ProviderService ) -> None: provider_transport.reply_chat() provider_transport.reply_chat() diff --git a/tests/test_types.py b/tests/test_types.py deleted file mode 100644 index 84554a74..00000000 --- a/tests/test_types.py +++ /dev/null @@ -1,20 +0,0 @@ -import importlib -import sys - -import pytest - -from bub.channels.contracts import MessageHandler -from bub.envelope import Envelope -from bub.turn import TurnState - - -def test_deprecated_types_reexports_compatibility_aliases() -> None: - sys.modules.pop("bub.types", None) - - with pytest.warns(DeprecationWarning, match=r"`bub\.types` is deprecated"): - legacy_types = importlib.import_module("bub.types") - - assert legacy_types.__all__ == ["Envelope", "MessageHandler", "State"] - assert legacy_types.Envelope is Envelope - assert legacy_types.MessageHandler is MessageHandler - assert legacy_types.State is TurnState diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index 8b27e0db..a1a7936a 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -1,14 +1,18 @@ --- title: Python SDK -description: Embed Bub in a Python application with custom tools, prompts, skills, and session storage. +description: Embed Bub's agent loop in a Python application with your own model, tools, prompts, and session storage. sidebar: order: 0 --- -Use `bub.builtin.Agent` to run the builtin agent loop inside your application. -`BubFramework` supplies configuration and hooks; `Agent` owns execution with your tools and store. +`bub.core` is Bub's agent kernel. Its `Agent` runs the agent loop (call the model, execute the tools it asks for, +repeat until it answers) over components you supply: tools, system prompt sections, and a tape store, plus the +Republic model it calls, given to the agent or to each run. It reads no Bub settings and loads no plugins, so it embeds in any application. -## Install and configure +Bub's own runtime is built on the same kernel: `bub.builtin.BubAgent` adds settings, plugin hooks, skills, +commands, and steering. + +## Install Install Bub in your Python 3.12+ project: @@ -16,8 +20,6 @@ Install Bub in your Python 3.12+ project: uv add bub ``` -Configure a model and its credentials through `BUB_MODEL`, `BUB_API_KEY`, and optionally `BUB_API_BASE`, -or through a configuration file. See [Configuration](/docs/reference/settings/). The API below describes this source checkout; use a release containing these interfaces or install the checkout. ## Create an agent @@ -28,21 +30,10 @@ Save the following as `sdk_example.py`: import asyncio from pathlib import Path -from bub import BubFramework, hookimpl -from bub.builtin import Agent -from bub.builtin.hook_impl import BuiltinImpl -from bub.builtin.tools import skill_describe -from bub.store import FileTapeStore -from bub.tools import Tool - +import republic -class SystemPrompts: - def __init__(self, prompts: list[str]) -> None: - self.prompts = tuple(prompts) - - @hookimpl - def system_prompt(self, prompt, state) -> str: - return "\n\n".join(self.prompts) +from bub.core import Agent, Tool +from bub.core.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: @@ -51,38 +42,30 @@ async def lookup_order(order_id: str) -> dict[str, str]: return {"order_id": order_id, "status": "shipped"} -def create_agent() -> tuple[BubFramework, Agent]: +def create_agent() -> Agent: root = Path(__file__).resolve().parent - framework = BubFramework(config_file=root / "config.yml") - framework.workspace = root - framework.plugin_manager.register( - SystemPrompts([ + return Agent( + store=FileTapeStore(root / "sessions"), + tools=[Tool.from_callable(lookup_order)], + system_prompt=[ "You are an order assistant. Reply directly to the user.", "Use lookup_order to check order status before answering.", - ]), - name="system_prompts", - ) - agent = Agent( - framework, - tools=[Tool.from_callable(lookup_order), skill_describe], - skill_dirs=[root / "skills"], - tape_store=FileTapeStore(root / "sessions"), + ], ) - return framework, agent async def main() -> None: - framework, agent = create_agent() - async with framework.running(): - stream = await agent.run_stream( - session_id="customer-42", - prompt="Where is order A123?", - ) - async for event in stream: - if event.kind == "text": - print(event.data.get("delta", ""), end="", flush=True) - elif event.kind == "error": - raise RuntimeError(str(event.data.get("message", "Agent failed"))) + agent = create_agent() + # Any Republic "provider:model" spec. The key comes from REPUBLIC_OPENAI_API_KEY. + async with republic.get_model("openai:gpt-5-mini") as model: + async with agent.run( + "Where is order A123?", model=model, options={"max_tokens": 4096}, tape="customer-42" + ) as stream: + async for event in stream: + if event.kind == "text": + print(event.data.get("delta", ""), end="", flush=True) + elif event.kind == "error": + raise RuntimeError(str(event.data.get("message", "Agent failed"))) print() @@ -90,165 +73,293 @@ if __name__ == "__main__": asyncio.run(main()) ``` -Run it with `uv run python sdk_example.py` after configuring your model. -An absent `config.yml` is allowed; environment settings still apply. +Run it with `REPUBLIC_OPENAI_API_KEY=... uv run python sdk_example.py`. Running it again with the same `tape` +continues the conversation stored under `sessions/`. -`SystemPrompts` replaces the builtin system-prompt method while keeping the other builtin hooks. -Register this instance once: do not also call `load_builtin_hooks()` or `load_hooks()` in this example. -System-prompt hooks are additive, so registering an extra prompt hook alongside the standard builtin implementation -would retain its channel instructions and workspace `AGENTS.md` content. +| `Agent(...)` parameter | Behavior | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `model` | Default model of runs not given one; see [Models](#models). `None` (the default) requires each run to give one. | +| `store` | A `TapeStore` or `AsyncTapeStore`. Defaults to `InMemoryTapeStore()`. | +| `tools` | `Tool` objects the model may call, kept by name in `agent.tools`. | +| `system_prompt` | One system prompt section or a list of them; see [System prompt](#system-prompt). | +| `context` | A `TapeContext` choosing which tape entries become model context. Defaults to `default_tape_context()`. | +| `sidecars` | `TapeSidecar` capabilities mounted on every tape. | +| `max_steps` | Maximum model calls in one run. | +| `name` | Agent name reported in `invoke_agent` traces. Defaults to `"agent"`. | +| `command_prefix` | Prefix marking command prompts, such as `","`; see [Commands](#commands). `None` (the default) disables commands. | -For standard Bub defaults instead, create a framework and call `framework.load_builtin_hooks()`. -Use `load_hooks()` when you also want installed plugins from the `bub` entry-point group. +## Models -## Tools, skills, and sessions +A run calls the model it is given, or else the agent's `model`, so one agent can serve runs with different models; +a run that calls a model without either raises `ValueError`. Pass a Republic chat model from `republic.get_model()`; +its lifecycle belongs to you, so open it with `async with` (or close it) around the runs that use it. Credentials and +endpoints are Republic client options: + +```python +model = republic.get_model("openai:gpt-5-mini", api_key="...", api_base="https://example.test/v1") +async with agent.run(prompt, model=model, options={"max_tokens": 4096, "temperature": 0.2}, tape="t") as stream: + ... + +agent = Agent(model=model) # runs not given a model call this one +async with agent.run(prompt, tape="t") as stream: + ... +``` + +`options` are `republic.ChatOptions` sent with every model call of the run; the loop adds the step's tools. +A `BeforeLlmCall` 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.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.core` 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 -| Parameter | Behavior | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -| `tools=[...]` | Accepts `Tool` objects. `Tool.from_callable()` derives a schema from annotations and a description from the docstring. | -| `tools=None` | Copies the global tool registry at construction time. `tools=[]` disables tools. | -| `skill_dirs=[Path(...)]` | Searches only these roots, in order; the first skill of a given name wins. | -| `skill_dirs=None` | Searches project, user, and builtin roots. `skill_dirs=[]` disables discovery. | -| `tape_store=...` | Uses the supplied `TapeStore` or `AsyncTapeStore`. `FileTapeStore(path)` persists session tapes in that directory. | -| `tape_store=None` | Uses the framework's active store, or an instance-local memory store if no store is active. | -| `command_prefix="::"` | Overrides the command prefix for this Agent. `None` uses `BUB_COMMAND_PREFIX` / YAML `command_prefix`, defaulting to `,`. | -Unlike `@tool`, `Tool.from_callable()` does not register the tool globally. -Include `skill_describe` in the tool set when the model needs to load skill bodies on demand. -Skills supply instructions; they do not automatically grant shell or filesystem tools. +def remember(note: str, *, context: ToolContext) -> str: + """Remember a note for this conversation.""" + context.state.setdefault("notes", []).append(note) + return "noted" -Each skill lives in its own directory: -```text -skills/ -└── order-policy/ - └── SKILL.md +agent.tools["remember"] = Tool.from_callable(remember, context=True) ``` -```markdown ---- -name: order-policy -description: Rules for answering order-status questions. ---- -Check the order service before giving a delivery status. +Tool errors are returned to the model as tool results rather than ending the run. Tool names containing `.` are +offered to the model with `_` instead (for example, `fs.read` becomes `fs_read`). + +## System prompt + +The system prompt is an ordered list of sections, rendered for every model step and joined by blank lines. +A section is text, or a sync or async function of a `PromptContext` returning text; an empty result or `None` +skips the section. `agent.system_prompt.add(section)` appends a section and returns it, so it also works as a +decorator; `agent.system_prompt.remove(section)` removes it. + +```python +from bub.core import PromptContext + + +@agent.system_prompt.add +def tool_list(context: PromptContext) -> str: + names = ", ".join(tool.name for tool in context.tools) + return f"Tools available in this step: {names}." + + +@agent.system_prompt.add +async def customer(context: PromptContext) -> str | None: + if (user := context.state.get("user")) is None: + return None + return f"You are talking to {user}." ``` -Reuse a `session_id` with the same workspace and store to continue a conversation. -Choose a new ID for a new conversation. IDs starting with `temp/` run on a fork that is not merged back. -Archive and builtin sidecar paths still follow Bub configuration, independently of `FileTapeStore`'s directory. +The context carries the step's `prompt` (empty when the step continues after tool calls), the model-facing `tools` +of the step, the `provider:model` spec of the step's `model`, the run's `tape` and `state`, and the `agent` (with an +`available_tools` shortcut). + +## Sessions and tapes + +Each run works on the tape named by `tape=` in the agent's store. Reuse a name to continue a conversation; choose +a new one for a new conversation. The run writes to a fork of the tape that merges back when the run's stream +closes; pass `merge_back=False` to discard it. -Per-turn options narrow the instance's capabilities and override persisted model settings: +`state=` is the run's mutable turn state: a dictionary shared with system prompt sections, tools, and event +handlers. The agent does not persist it; keep anything that must survive in your application or on the tape. ```python -stream = await agent.run_stream( - session_id="customer-42", - prompt="Use $order-policy to check order A123.", - allowed_tools=["lookup_order", "skill"], - allowed_skills=["order-policy"], - model="provider:model-id", # Replace with your configured provider/model. - reasoning_effort="high", # Use a value supported by that model. -) +async with agent.run("Any news on A123?", model=model, tape="customer-42", state={"user": "Ada"}) as stream: + ... + +tape = agent.tape("customer-42") +messages = await tape.read_messages() # The conversation as Republic messages. +await tape.handoff(name="summary", state={"summary": "Ada asked about order A123."}) ``` -Always consume the returned stream. `allowed_tools` accepts runtime names and model aliases -(for example, `fs.read` and `fs_read`). Explicit `model` and `reasoning_effort` take precedence over saved settings. -Normally omit `state` to load session state automatically; passing a state dictionary skips that loading and mutates it. +`agent.tape(name)` returns a conversation's tape for use outside runs; it writes to the store directly. By default the +model context is the chat recorded since the tape's last anchor, so a `handoff()` starts a shorter context. When a +call fails because the context is too long, the run hands off automatically once and retries the prompt. -String prompts starting with the agent's command prefix (`,` by default) after trimming whitespace are commands. -Prefixes must be non-empty and contain no whitespace. Exactly one prefix is removed; a bare prefix raises `ValueError`. -After overriding the prefix, text using the old prefix is ordinary input. Multimodal prompt lists are not commands. -Known commands call instance tools directly; unknown commands run through the instance's `bash` tool when available. -Direct commands bypass model/tool interception hooks and loop-level `allowed_tools` filtering. +## Commands -## Streams and lifecycle +With `command_prefix` set, a text prompt starting with the prefix runs as a command instead of the agent loop: no +model is called. `name args...` calls the tool `name`, with positional arguments, which take the tool's parameters +in order, followed by `key=value` arguments; any other command line runs through the agent's `bash` tool when it has one. The output is the run's +text and `final` event, and a `CommandFinished` event reports the command, which the tape records as a `command` +event. + +```python +agent = Agent(tools=[Tool.from_callable(lookup_order)], command_prefix=",") + +async with agent.run(",lookup_order order_id=A123", model=model, tape="customer-42") as stream: + async for event in stream: + print(event.kind, event.data) # text, then final, with the tool's rendered result. +``` + +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 +`command` set. `agent.run_command(tape, line)` runs a command on an open tape directly. -`run_stream()` is awaited first, then its result is iterated. Events include `text`, `reasoning`, `tool_call`, -`tool_result`, `usage`, `error`, and `final`. A `final` event finishes a model step; exhaust the iterator to finish -the whole turn. Check error events and handle exceptions from iteration. The stream also exposes `error` and `usage`. +## Events -For early exit from an iteration that has started, close its iterator explicitly: +The loop reports its progress as events from `bub.core.events`; register handlers to observe or steer it. ```python -from contextlib import aclosing +from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision + + +@agent.events.on(BeforeToolCall) +def guard(event: BeforeToolCall) -> 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: + print("tool", event.call.tool, event.result.duration_ms, "ms") -stream = await agent.run_stream(session_id="customer-42", prompt="Check order A123") -async with aclosing(stream.__aiter__()) as events: - async for event in events: - print(event.kind, event.data) + +agent.events.on(StepFinished, lambda event: print("step", event.step, event.status)) ``` -Keep `framework.running()` open until all turns finish. Accessing the agent's cached `tape` before entering that -lifespan can bind it to an in-memory fallback. An explicitly injected store's lifecycle belongs to your application. -`Agent` itself is not an async context manager and has no `run()` convenience method. +| Event | When | Handlers may | +| ------------------------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `RunStarted` | A run begins | observe the model spec, prompt, and run metadata | +| `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 | +| `CommandFinished` | A command prompt ran (see [Commands](#commands)) | observe the command, status, and output | + +`agent.events.on(EventType, handler)` registers a sync or async handler and returns it; `@agent.events.on(EventType)` +does the same as a decorator, and `agent.events.off(EventType, handler)` removes it. A handler for `AgentEvent` receives every event. +Handlers run in registration order. Setting a decision on a `Before*` event stops delivery to later handlers. +Handler exceptions propagate to the run. -## Embed in FastAPI +Every event carries the run's `state`; loop events also carry its `tape`. Recording is itself a handler: a +`TapeRecorder`, registered first, writes the conversation and `loop.*` events to the run's tape. -Install the server dependencies with `uv add fastapi uvicorn`, then save this as `app.py` next to `sdk_example.py`: +### Custom events + +Define your own events to connect tools, handlers, and your application through the same bus. An event is a +keyword-only dataclass subclassing `AgentEvent` (or `TapeEvent`, which also carries the run's `tape`). Emit it with +`await events.emit(event)`, which delivers it to the handlers registered for its type or a base type and returns it. +Inside a tool, the run's bus is `context.events`: ```python -import asyncio -from contextlib import asynccontextmanager +from dataclasses import dataclass + +from bub.core import AgentEvent, ToolContext + + +@dataclass(kw_only=True) +class OrderLookedUp(AgentEvent): + order_id: str + status: str -from fastapi import FastAPI, HTTPException, Request -from pydantic import BaseModel -from sdk_example import create_agent +@dataclass(kw_only=True) +class RefundRequested(AgentEvent): + order_id: str + approved: bool | None = None + @property + def stopped(self) -> bool: + # Once a handler decides, later handlers are skipped. + return self.approved is not None -@asynccontextmanager -async def lifespan(app: FastAPI): - framework, agent = create_agent() - async with framework.running(): - app.state.agent = agent - app.state.turn_lock = asyncio.Lock() - yield +async def lookup_order(order_id: str, *, context: ToolContext) -> dict[str, str]: + """Look up the status of an order and whether it can be refunded.""" + result = {"order_id": order_id, "status": "shipped"} + if context.events is not None: # None when the tool runs outside an agent. + await context.events.emit(OrderLookedUp(state=context.state, **result)) + request = await context.events.emit(RefundRequested(state=context.state, order_id=order_id)) + result["refundable"] = str(bool(request.approved)) + return result -app = FastAPI(lifespan=lifespan) +agent.tools["lookup_order"] = Tool.from_callable(lookup_order, context=True) -class Task(BaseModel): - session_id: str - prompt: str +@agent.events.on(OrderLookedUp) +async def notify(event: OrderLookedUp) -> None: + print("order", event.order_id, "is", event.status, "for", event.state.get("user")) -@app.post("/tasks") -async def run_task(task: Task, request: Request): - async with request.app.state.turn_lock: - stream = await request.app.state.agent.run_stream( - session_id=task.session_id, - prompt=task.prompt, - ) - parts: list[str] = [] - error: str | None = None - async for event in stream: - if event.kind == "text": - parts.append(str(event.data.get("delta", ""))) - elif event.kind == "error": - error = str(event.data.get("message", "Agent failed")) - if error is not None: - raise HTTPException(status_code=502, detail=error) - return {"session_id": task.session_id, "output": "".join(parts)} + +@agent.events.on(RefundRequested) +def approve(event: RefundRequested) -> None: + event.approved = event.state.get("user") == "Ada" ``` -Start with `uv run uvicorn app:app`. This example serializes all turns in one process. -For concurrent independent sessions, use application-owned per-session locking with a cleanup policy. -Multiple worker processes sharing a store need coordination across workers; a file write lock does not serialize a turn. +Every event carries the `state` of the run that emits it, so pass `context.state` (or `event.state` from another +handler). Handlers may modify a mutable event, and the emitter reads the result from the returned event; override +`stopped` to end delivery once a handler has decided. Your application can also emit events directly with +`await agent.events.emit(...)`. Custom events are not written to the tape; to record one, append it from a handler, +for example `await context.tape.append_event("order.looked_up", {...})` in the tool, or use a `TapeEvent` and call +`event.tape.append_event(...)` in its handler. + +## Streams and lifecycle + +`agent.run()` is an async context manager: entering it starts the run, and the stream it yields drives the loop as you +iterate. Events include `text`, `reasoning`, `tool_call`, `tool_result`, `usage`, `error`, and `final`. A `final` +event finishes a model step; the run ends when the stream is exhausted. Check error events and handle exceptions from +iteration. The stream also exposes `error` and `usage`. + +Leaving the block closes the stream, even after an early `break` or an exception: the model stream stops, the tape +fork merges back, and the trace span ends. + +```python +async with agent.run("Check order A123", model=model, tape="customer-42") as stream: + async for event in stream: + if event.kind == "tool_call": + break # The run stops here; what it recorded so far is kept. +``` -## Agent versus the full message pipeline +To hand the stream to other code, such as a framework that consumes it later, use `await agent.start(...)` with the +same arguments. It returns the stream without closing it; whoever consumes it must exhaust it or call `aclose()`. -For ordinary prompts, `Agent.run_stream()` uses the builtin loop and its model/tool interception hooks. -It does not run `build_prompt`, `save_state`, outbound rendering, or channel dispatch, and does not select a plugin's -replacement `run_model` implementation. Your application consumes the result. +An empty prompt yields an `error: empty prompt` result without opening the tape. Runs are traced as +`invoke_agent {name}` spans when tracing is configured; see [Observability](/docs/tutorials/observability/). -Use `BubFramework.process_inbound()` for the complete message pipeline and model-runner plugins. -It returns a `TurnResult` with `model_output`, `state`, and `outbounds`; `stream_output=True` routes streaming output -through a bound channel router but still returns the completed result. -See [Hooks](/docs/build/hooks/) for those extension points. +To drive the loop on a tape you manage yourself, open it with `agent.session(name, state=...)` and call +`agent.run_on(tape, prompt, model=model)`, or `agent.run_once(prompt, model=model, tape=tape)` for a single model +step. Both also take `options`. -Custom channels must set `ChannelMessage.kind="command"` before passing commands to the channel manager to bypass -buffering and active-window filtering. The buffer no longer infers commands from a leading comma. -When using the builtin agent, preserve the configured prefix in the message content for execution. +## Customize the loop + +Subclass `Agent` to change how each step is prepared. Each method receives the run's tape, whose `context.state` is +the turn state: + +| Method | Default | Use | +| -------------------------------------- | --------- | --------------------------------------------------------- | +| `prepare_tools(tape, prompt)` | all tools | Choose the model-facing tools for the next step. | +| `pending_messages(tape)` | none | Return user messages that arrived during the run. | +| `has_pending_messages(tape)` | `False` | Continue the run for another step while messages wait. | +| `continue_prompt(tape, prompt, state)` | `None` | Return a user message for the next step after tool calls. | + +```python +from bub.core import Tape + + +class TriageAgent(Agent): + async def prepare_tools(self, tape: Tape, prompt): + tools = await super().prepare_tools(tape, prompt) + if tape.context.state.get("read_only"): + tools = [tool for tool in tools if tool.name == "lookup_order"] + return tools +``` -Configuration loading remains process-wide. Configure once at application startup; -separate tool/skill/store instances do not imply isolated configuration files or environment variables. +To serve an agent over HTTP, follow [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/). diff --git a/website/src/content/docs/docs/build/tools.mdx b/website/src/content/docs/docs/build/tools.mdx index 891c7de4..1cf8b08d 100644 --- a/website/src/content/docs/docs/build/tools.mdx +++ b/website/src/content/docs/docs/build/tools.mdx @@ -108,7 +108,7 @@ The `REGISTRY` lives in [`bub.tools`](https://github.com/bubbuild/bub/blob/main/ REGISTRY: dict[str, Tool] = {} ``` -Every `@tool` call mutates this dict at **import time**. `Agent(tools=None)` snapshots it when created; later imports do not update existing agents. Add tools to an instance's `Agent.tools`, and mark them `deferred=True` to [load them on demand](#load-tools-on-demand). +Every `@tool` call mutates this dict at **import time**. `BubAgent(tools=None)` snapshots it when created; later imports do not update existing agents. Add tools to an instance's `BubAgent.tools`, and mark them `deferred=True` to [load them on demand](#load-tools-on-demand). ## Import the tools module from your plugin 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 5e1638d3..55ecedaf 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -54,7 +54,7 @@ The builtin spill plugin mounts a `SpillStore` through `provide_tape_sidecar`, r The main tape keeps a bounded preview and an opaque handle instead of the complete result. The `spill.read` tool reads a bounded page by handle and cursor, including pages counted from the end. Content explicitly returned by `spill.read` is recorded as a normal bounded tool result. `spill.read` has `exposure="direct"`, so it returns plain text and stays directly callable in code mode instead of being routed through `run_code`. -The sidecar follows the session tape through forks, merges, archive, and reset, but remains a separate tape and is never scanned while constructing the main context. It can also be archived or reset independently with `Tape.archive_sidecar("spill")` and `Tape.reset_sidecar("spill")`. When reset requests an archive, Bub preserves the sidecar if that archive fails. +The sidecar follows the session tape through forks, merges, archive, and reset, but remains a separate tape and is never scanned while constructing the main context. It can also be reset independently with `Tape.reset_sidecar("spill")`, or archived with `bub.builtin.archive.archive_sidecar(tape, "spill", directory)`. When `tape.reset` requests an archive, Bub preserves the sidecar if that archive fails. Spill configuration belongs to the sidecar plugin: use `spill.threshold` in `config.yml` or `BUB_SPILL_THRESHOLD` in the environment. Setting it to `0` stops new writes without unmounting the sidecar, so existing handles remain readable and tape lifecycle operations still include it. diff --git a/website/src/content/docs/docs/concepts/turn-pipeline.mdx b/website/src/content/docs/docs/concepts/turn-pipeline.mdx index 80f10f82..1c14cd82 100644 --- a/website/src/content/docs/docs/concepts/turn-pipeline.mdx +++ b/website/src/content/docs/docs/concepts/turn-pipeline.mdx @@ -84,9 +84,9 @@ The default observer (`BuiltinImpl.on_error`) sends an error envelope through `d `BuiltinImpl` (registered as `builtin`) implements every hook above with sensible defaults: - `resolve_session` — uses the envelope's `session_id` if present, otherwise `f"{channel}:{chat_id}"`. -- `load_state` — opens the inbound's optional `lifespan`, seeds `session_id` and an internal `_runtime_agent`. -- `build_prompt` — handles comma commands (`,help`, `,skill`, ...), wraps text with timestamp and context, and emits multimodal parts when media is attached. -- `run_model` / `run_model_stream` — delegate to `Agent.run` / `Agent.run_stream`, the [Republic](https://github.com/bubbuild/republic)-backed loop with tool use and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)). +- `load_state` — opens the inbound's optional `lifespan`, seeds `session_id` and an internal `_runtime_agent`, and restores session settings (model, reasoning effort, code mode) recorded on the session tape. +- `build_prompt` — marks command prompts (`,help`, `,skill`, ...) and passes them through unchanged for the agent to run, wraps other text with timestamp and context, and emits multimodal parts when media is attached. +- `run_model_stream` — runs the turn with `BubAgent.run_stream()`, built on the `bub.core` agent loop with [Republic](https://github.com/bubbuild/republic) models, tool use, commands, and auto-handoff (see [Tape and context](/docs/concepts/tape-and-context/)). The builtin plugin does not implement `run_model`; non-streaming callers get the collected text through the adapter described above. - `save_state` — closes the per-message lifespan with the captured exception info. - `render_outbound` — wraps `model_output` into one `ChannelMessage` carrying the inbound's routing fields. - `dispatch_outbound` — forwards through the bound `ChannelRouter`. diff --git a/website/src/content/docs/docs/reference/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx index abd744f0..5c6f6dc0 100644 --- a/website/src/content/docs/docs/reference/hooks.mdx +++ b/website/src/content/docs/docs/reference/hooks.mdx @@ -36,10 +36,10 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi | `provide_channels` | sync-only consumer (deduped) | `(message_handler: MessageHandler) -> list[Channel]` | channels | `BubFramework.get_channels` (`call_many_sync`) | Channels are deduplicated by `Channel.name`; the first channel seen in hook priority order wins. | | `build_tape_context` | firstresult | `() -> TapeContext` | tape context | `BubFramework.build_tape_context` (`call_first_sync`) | Sync-only; awaitable returns are skipped. | | `admit_message` | firstresult | `(session_id, message, turn) -> AdmitDecision \| None` | turn admission decision | `ChannelManager` | Runs before channel scheduling. `None` keeps default concurrent scheduling; decision types are listed in [Core contracts](/docs/reference/types/). | -| `before_llm_call` | chained | `(request: LlmCallRequest, state: TurnState) -> LlmCallRequest \| LlmCallDecision \| None` | modified request or finish decision | `ModelRunner.run` via `AgentHooks` | Impls chain in LIFO order; each sees the previous impl's request. `LlmCallDecision.finish(text)` skips the provider call. Raising impls are logged and skipped. | -| `after_llm_call` | observer | `(request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None` | none | `ModelRunner.run` via `AgentHooks` | Fires exactly once per completed call: success or `Exception` failure. Cancellation / consumer `aclose()` is not observed. `result.error` is the original exception. | -| `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` via `AgentHooks` | Per tool invocation. `proceed(arguments=…)` folds argument changes; `replace(result)` / `deny(message)` short-circuit. Veto only via the decision object — exceptions are logged and skipped. | -| `after_tool_call` | terminal handler | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | none | `ToolExecutor` via `AgentHooks` | Fires for success, failure and deny/replace. Cancellation is not observed. Assigning `result.result` replaces the model-facing value; `result.error` remains the original `BubError` for failures. | +| `before_llm_call` | chained | `(request: LlmCallRequest, state: TurnState) -> LlmCallRequest \| LlmCallDecision \| None` | modified request or finish decision | core `Agent` via `PluginAgentHooks` | Impls chain in LIFO order; each sees the previous impl's request. `LlmCallDecision.finish(text)` skips the provider call. Raising impls are logged and skipped. | +| `after_llm_call` | observer | `(request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None` | none | core `Agent` via `PluginAgentHooks` | Fires exactly once per completed call: success or `Exception` failure. Cancellation / consumer `aclose()` is not observed. `result.error` is the original exception. | +| `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` via `PluginAgentHooks` | Per tool invocation. `proceed(arguments=…)` folds argument changes; `replace(result)` / `deny(message)` short-circuit. Veto only via the decision object — exceptions are logged and skipped. | +| `after_tool_call` | terminal handler | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | none | `ToolExecutor` via `PluginAgentHooks` | Fires for success, failure and deny/replace. Cancellation is not observed. Assigning `result.result` replaces the model-facing value; `result.error` remains the original `BubError` for failures. | ### Prompt content @@ -126,15 +126,15 @@ Each `provide_tape_sidecar` implementation contributes one named capability back `notify_error` and `notify_error_sync` wrap each impl in a `try`/`except`; observer failures are logged (`hook.on_error_failed stage=… adapter=…`) but never propagate. This guarantees one broken observer does not block the others, and prevents an `on_error` from masking the original exception. -### Agent-loop hooks (`AgentHooks`) +### Agent-loop hooks (`PluginAgentHooks`) -The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `AgentHooks` facade (`src/bub/hooks/interception.py`), not `call_first`/`call_many`, with three deliberate differences: +The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` 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. - **Fault isolation** — a raising implementation is logged (`hook.agent_hook_failed`) and skipped, never fatal to the turn. Blocking is only expressible through decision objects, so a broken policy plugin cannot veto by crashing. - **Exactly-once terminal handling** — `after_llm_call` and `after_tool_call` fire exactly once per call for real completions: success or `Exception` failure. Cancellation and consumer close (`BaseException`) intentionally bypass after hooks. `result.error` carries the **original exception object** (a `BubError` for tool failures with `kind`/`details` intact). `after_tool_call` implementations share one outcome, so assigning `result.result` changes the model-facing value seen by later implementations while leaving any failure in `result.error` intact. -Payload dataclasses (`LlmCallRequest`, `LlmCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LlmCallDecision`) live beside those semantics in `src/bub/hooks/interception.py`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. +Payload dataclasses (`LlmCallRequest`, `LlmCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LlmCallDecision`) live in `src/bub/core/events.py` and are re-exported by `bub.hooks.interception`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. A continuation-style `wrap_tool_call` (retry / human-in-the-loop / caching) was prototyped in the original PR and removed by review direction; if those use cases materialize it will return as a separate proposal. diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index 11fca1b4..e783ebf8 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -6,7 +6,7 @@ description: Bub's subsystem-owned contracts for turns, models, streaming, hooks Bub keeps contracts beside the subsystem that owns their behavior. Import a contract from its defining module; the top-level `bub` package intentionally exposes only the common framework entry points. :::caution -`bub.types` temporarily re-exports only `Envelope`, `State`, and `MessageHandler` for compatibility and emits a `DeprecationWarning` when imported. New code should use `bub.envelope.Envelope`, `bub.turn.TurnState`, and `bub.channels.contracts.MessageHandler` directly. +The former `bub.types` compatibility module has been removed. Use `bub.envelope.Envelope`, `bub.turn.TurnState`, and `bub.channels.contracts.MessageHandler` directly. ::: ## Kernel vocabulary @@ -86,10 +86,10 @@ Hook declarations and markers live in `bub.hooks`; agent-loop interception paylo ```python from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl, hookspec from bub.hooks.interception import ( - AgentHooks, LlmCallDecision, LlmCallRequest, LlmCallResult, + PluginAgentHooks, ToolCall, ToolCallDecision, ToolCallResult, diff --git a/website/src/content/docs/docs/tutorials/fastapi.mdx b/website/src/content/docs/docs/tutorials/fastapi.mdx new file mode 100644 index 00000000..738fe60d --- /dev/null +++ b/website/src/content/docs/docs/tutorials/fastapi.mdx @@ -0,0 +1,126 @@ +--- +title: Serve a Bub agent with FastAPI +description: Expose a bub.core agent over HTTP with FastAPI, with one conversation per session. +sidebar: + order: 7 +--- + +This tutorial serves an agent built from `bub.core` as an HTTP API. Each request names a session; requests with +the same session continue one conversation, stored as a tape on disk. + +You need Python 3.12+ and an API key for a model provider. The example uses OpenAI through +[Republic](https://github.com/bubbuild/republic); any Republic `provider:model` spec works. + +## Install + +```bash +uv init bub-api && cd bub-api +uv add bub fastapi uvicorn +``` + +## Write the app + +Save this as `app.py`: + +```python +import asyncio +from collections import defaultdict +from contextlib import asynccontextmanager +from pathlib import Path + +import republic +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel + +from bub.core import Agent, Tool +from bub.core.store import FileTapeStore + + +async def lookup_order(order_id: str) -> dict[str, str]: + """Look up the status of an order.""" + # Replace this demonstration response with your application's order service. + return {"order_id": order_id, "status": "shipped"} + + +def create_agent() -> Agent: + return Agent( + store=FileTapeStore(Path(__file__).resolve().parent / "sessions"), + tools=[Tool.from_callable(lookup_order)], + system_prompt=[ + "You are an order assistant. Reply directly to the user.", + "Use lookup_order to check order status before answering.", + ], + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # The key comes from REPUBLIC_OPENAI_API_KEY. + async with republic.get_model("openai:gpt-5-mini") as model: + app.state.agent = create_agent() + app.state.model = model + app.state.session_locks = defaultdict(asyncio.Lock) + yield + + +app = FastAPI(lifespan=lifespan) + + +class Task(BaseModel): + session_id: str + prompt: str + + +@app.post("/tasks") +async def run_task(task: Task, request: Request): + state = request.app.state + # Runs on one session's tape must not overlap; different sessions run concurrently. + async with state.session_locks[task.session_id]: + parts: list[str] = [] + error: str | None = None + async with state.agent.run([task.prompt], model=state.model, tape=task.session_id) as stream: + async for event in stream: + if event.kind == "text": + parts.append(str(event.data.get("delta", ""))) + elif event.kind == "error": + error = str(event.data.get("message", "Agent failed")) + if error is not None: + raise HTTPException(status_code=502, detail=error) + return {"session_id": task.session_id, "output": "".join(parts)} +``` + +The lifespan opens the Republic model once and closes it on shutdown. The agent keeps no per-run state, so one +instance serves every request; each run works on a fork of the session's tape that merges back when the +`async with agent.run(...)` block exits, even if the run fails. + +## Run it + +```bash +REPUBLIC_OPENAI_API_KEY=... uv run uvicorn app:app +``` + +In another terminal, start a conversation and continue it with the same `session_id`: + +```bash +curl -s localhost:8000/tasks -H 'content-type: application/json' \ + -d '{"session_id": "customer-42", "prompt": "Where is order A123?"}' + +curl -s localhost:8000/tasks -H 'content-type: application/json' \ + -d '{"session_id": "customer-42", "prompt": "And when did it ship?"}' +``` + +The second answer can refer to the first because both runs read the `customer-42` tape under `sessions/`. + +## Before deploying + +- `session_locks` grows with every session; evict idle locks in a long-running service. +- The locks only serialize runs inside one process. Multiple worker processes sharing a store need coordination + across workers, such as a lock in your database; a file write lock does not serialize a run. +- `FileTapeStore` suits one host. Use a database-backed store, as in + [Persist tapes in SQLite with SQLAlchemy](/docs/tutorials/tapestore-sqlalchemy/), when several hosts share + sessions. + +## Next steps + +- Add tools, system prompt sections, and event handlers: read the [Python SDK](/docs/build/sdk/) guide. +- Trace requests end to end: read [Observe Bub with tapes, Phoenix, and Logfire](/docs/tutorials/observability/). diff --git a/website/src/content/docs/docs/tutorials/index.mdx b/website/src/content/docs/docs/tutorials/index.mdx index 0f8ba7f2..d06fdd4a 100644 --- a/website/src/content/docs/docs/tutorials/index.mdx +++ b/website/src/content/docs/docs/tutorials/index.mdx @@ -16,6 +16,7 @@ Tutorials are hands-on lessons. Use this section when you want to learn a workfl 5. [Load Agent Plugins](/docs/tutorials/agent-plugins/) — load a portable skill and MCP server alongside existing Bub integrations. 6. [Experimental: Extend Bub with Other Languages](/docs/tutorials/language-extensions/) — keep Bub in Python, move selected hooks into Go, Rust, or other languages, and wire them back through pluggy, WebAssembly, and Extism. 7. [Use Bub as an ACP agent](/docs/tutorials/acp-server/) — connect Bub to ACP-compatible graphical clients such as Zed. +8. [Serve a Bub agent with FastAPI](/docs/tutorials/fastapi/) — expose a `bub.core` agent over HTTP, with one conversation per session. ## Next steps diff --git a/website/src/content/docs/zh-cn/docs/build/sdk.md b/website/src/content/docs/zh-cn/docs/build/sdk.md index 6a8f062b..7c532c97 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -1,88 +1,70 @@ --- title: Python SDK -description: 在 Python 应用中嵌入 Bub,自定义工具、提示词、skills 和会话存储。 +description: 在 Python 应用中嵌入 Bub 的 agent loop,使用你自己的模型、工具、提示词和会话存储。 sidebar: order: 0 --- -使用 `bub.builtin.Agent` 在应用内运行 builtin agent loop。 -`BubFramework` 提供配置和 hooks,`Agent` 使用指定的工具与存储执行任务。 +`bub.core` 是 Bub 的 agent 内核。它的 `Agent` 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答), +所用的组件都由你提供:工具、system prompt section 和 tape store,以及传给 agent 或每次运行的 Republic 模型。 +它不读取 Bub 的 settings,也不加载插件,因此可以嵌入任何应用。 -## 安装与配置 +Bub 自身的运行时也构建在这个内核之上:`bub.builtin.BubAgent` 在其上加入 settings、插件钩子、skills、命令和 steering。 -在 Python 3.12+ 项目中安装: +## 安装 + +在 Python 3.12+ 项目中安装 Bub: ```bash uv add bub ``` -通过 `BUB_MODEL`、`BUB_API_KEY` 和可选的 `BUB_API_BASE`,或配置文件设置模型与凭据。 -详见[配置参考](/zh-cn/docs/reference/settings/)。本文描述当前源码接口; -请使用包含这些接口的版本,或直接安装当前源码。 +下文描述的是当前源码中的 API;请使用包含这些接口的发布版本,或安装当前源码。 ## 创建 Agent -将下面的示例保存为 `sdk_example.py`: +将以下内容保存为 `sdk_example.py`: ```python import asyncio from pathlib import Path -from bub import BubFramework, hookimpl -from bub.builtin import Agent -from bub.builtin.hook_impl import BuiltinImpl -from bub.builtin.tools import skill_describe -from bub.store import FileTapeStore -from bub.tools import Tool - +import republic -class SystemPrompts: - def __init__(self, prompts: list[str]) -> None: - self.prompts = tuple(prompts) - - @hookimpl - def system_prompt(self, prompt, state) -> str: - return "\n\n".join(self.prompts) +from bub.core import Agent, Tool +from bub.core.store import FileTapeStore async def lookup_order(order_id: str) -> dict[str, str]: """Look up the status of an order.""" - # Replace this demonstration response with your application's order service. + # 将这个演示返回值替换为你应用中的订单服务。 return {"order_id": order_id, "status": "shipped"} -def create_agent() -> tuple[BubFramework, Agent]: +def create_agent() -> Agent: root = Path(__file__).resolve().parent - framework = BubFramework(config_file=root / "config.yml") - framework.workspace = root - framework.plugin_manager.register( - SystemPrompts([ + return Agent( + store=FileTapeStore(root / "sessions"), + tools=[Tool.from_callable(lookup_order)], + system_prompt=[ "You are an order assistant. Reply directly to the user.", "Use lookup_order to check order status before answering.", - ]), - name="system_prompts", - ) - agent = Agent( - framework, - tools=[Tool.from_callable(lookup_order), skill_describe], - skill_dirs=[root / "skills"], - tape_store=FileTapeStore(root / "sessions"), + ], ) - return framework, agent async def main() -> None: - framework, agent = create_agent() - async with framework.running(): - stream = await agent.run_stream( - session_id="customer-42", - prompt="Where is order A123?", - ) - async for event in stream: - if event.kind == "text": - print(event.data.get("delta", ""), end="", flush=True) - elif event.kind == "error": - raise RuntimeError(str(event.data.get("message", "Agent failed"))) + agent = create_agent() + # 任意 Republic "provider:model"。API key 从 REPUBLIC_OPENAI_API_KEY 读取。 + async with republic.get_model("openai:gpt-5-mini") as model: + async with agent.run( + "Where is order A123?", model=model, options={"max_tokens": 4096}, tape="customer-42" + ) as stream: + async for event in stream: + if event.kind == "text": + print(event.data.get("delta", ""), end="", flush=True) + elif event.kind == "error": + raise RuntimeError(str(event.data.get("message", "Agent failed"))) print() @@ -90,168 +72,278 @@ if __name__ == "__main__": asyncio.run(main()) ``` -配置好模型后,运行 `uv run python sdk_example.py`。 -`config.yml` 不存在时也可以运行,环境变量配置仍然生效。 -示例中的订单查询返回演示数据,接入应用时请替换为实际服务。 +用 `REPUBLIC_OPENAI_API_KEY=... uv run python sdk_example.py` 运行。使用同一个 `tape` 再次运行,会继续保存在 `sessions/` 下的对话。 -`SystemPrompts` 覆盖 builtin 的系统提示方法,并继承其他 builtin hooks。 -只注册这一个实例即可;这个示例中不要再调用 `load_builtin_hooks()` 或 `load_hooks()`。 -系统提示 hooks 会累加,单独新增一个 prompt hook 会保留默认 builtin 的 channel 指令和工作区 `AGENTS.md` 内容。 +| `Agent(...)` 参数 | 行为 | +| ----------------- | --------------------------------------------------------------------------------- | +| `model` | 未传入模型的运行所用的默认模型;见 [模型](#模型)。默认 `None`,即每次运行都需传入。 | +| `store` | `TapeStore` 或 `AsyncTapeStore`。默认 `InMemoryTapeStore()`。 | +| `tools` | 模型可调用的 `Tool` 对象,按名字保存在 `agent.tools` 中。 | +| `system_prompt` | 一个或一组 system prompt section;见 [System prompt](#system-prompt)。 | +| `context` | 决定哪些 tape 条目进入模型上下文的 `TapeContext`。默认 `default_tape_context()`。 | +| `sidecars` | 挂载到每条 tape 上的 `TapeSidecar` 能力。 | +| `max_steps` | 一次运行中最多的模型调用次数。 | +| `name` | 在 `invoke_agent` trace 中上报的 agent 名称。默认 `"agent"`。 | +| `command_prefix` | 标记命令 prompt 的前缀,例如 `","`;见 [命令](#命令)。默认 `None`,即不启用命令。 | -如需 Bub 标准默认行为,创建 framework 后调用 `framework.load_builtin_hooks()`。 -需要自动加载 `bub` entry-point group 下已安装的插件时,使用 `load_hooks()`。 +## 模型 -## 工具、Skills 与会话 +每次运行调用传入的模型,未传入时调用 agent 的 `model`,因此同一个 agent 可以用不同模型服务不同的运行;两者都没有时, +需要调用模型的运行会抛出 `ValueError`。传入 `republic.get_model()` 得到的 Republic +chat model 即可;它的生命周期归你管理,请在使用它的运行外层用 `async with` 打开(或自行关闭)。凭据和端点属于 Republic +的客户端参数: + +```python +model = republic.get_model("openai:gpt-5-mini", api_key="...", api_base="https://example.test/v1") +async with agent.run(prompt, model=model, options={"max_tokens": 4096, "temperature": 0.2}, tape="t") as stream: + ... + +agent = Agent(model=model) # 未传入模型的运行调用它 +async with agent.run(prompt, tape="t") as stream: + ... +``` + +`options` 是随本次运行每次模型调用发送的 `republic.ChatOptions`;循环会加入本步的工具。`BeforeLlmCall` +处理器可以把一次调用重定向到其他 `provider:model`:同一 provider 的模型复用运行模型的客户端,其他 provider +使用从 Republic 环境变量配置的客户端。 + +如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 `bub.core.ModelClient` +的对象:提供 `spec`(默认的 `provider:model`)、默认 `max_tokens`(或 `None`),以及 +`open_stream(messages, spec=..., options=...)`——一个产出 provider 名称和 `republic.Stream` 的异步上下文管理器。 +Bub 自己的 `bub.builtin.model.BubModel` 就是这样的客户端。 + +## 工具 + +`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core` 中的 +`@tool` 装饰器生成同样的 `Tool`,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。 + +以 `context=True` 创建的工具会收到 `ToolContext`,其中包含本次运行的 `tape`、turn `state`、`run_id` 和事件总线: + +```python +from bub.core import ToolContext -| 参数 | 行为 | -| ------------------------ | ------------------------------------------------------------------------------------------------ | -| `tools=[...]` | 接受 `Tool` 对象。`Tool.from_callable()` 从类型注解生成 schema,从 docstring 获取描述。 | -| `tools=None` | 构造时复制全局工具注册表。`tools=[]` 禁用工具。 | -| `skill_dirs=[Path(...)]` | 仅搜索这些目录,按顺序处理,同名 skill 使用第一个。 | -| `skill_dirs=None` | 搜索项目、用户和 builtin 目录。`skill_dirs=[]` 禁用发现。 | -| `tape_store=...` | 使用传入的 `TapeStore` 或 `AsyncTapeStore`。`FileTapeStore(path)` 将会话 tape 持久化到指定目录。 | -| `tape_store=None` | 使用 framework 当前的 store;没有活动 store 时使用实例独立的内存存储。 | -| `command_prefix="::"` | 覆盖当前 Agent 的命令前缀。`None` 使用 `BUB_COMMAND_PREFIX` / YAML `command_prefix`,默认 `,`。 | -与 `@tool` 不同,`Tool.from_callable()` 不会把工具注册到全局表。 -模型需要按需读取 skill 正文时,将 `skill_describe` 加入工具集合。 -Skills 提供指令,不会自动授予 shell 或文件系统工具。 +def remember(note: str, *, context: ToolContext) -> str: + """Remember a note for this conversation.""" + context.state.setdefault("notes", []).append(note) + return "noted" -每个 skill 单独放在一个目录中: -```text -skills/ -└── order-policy/ - └── SKILL.md +agent.tools["remember"] = Tool.from_callable(remember, context=True) ``` -```markdown ---- -name: order-policy -description: Rules for answering order-status questions. ---- -Check the order service before giving a delivery status. +工具出错时,错误会作为工具结果返回给模型,而不会结束运行。名字中带 `.` 的工具会以 `_` 替换后提供给模型 +(例如 `fs.read` 变为 `fs_read`)。 + +## System prompt + +system prompt 是有序的 section 列表,每个模型步骤渲染一次,以空行连接。section 可以是文本,也可以是接收 +`PromptContext` 并返回文本的同步或异步函数;返回空串或 `None` 时跳过该 section。`agent.system_prompt.add(section)` +追加一个 section 并返回它,因此也可以用作装饰器;`agent.system_prompt.remove(section)` 将其移除。 + +```python +from bub.core import PromptContext + + +@agent.system_prompt.add +def tool_list(context: PromptContext) -> str: + names = ", ".join(tool.name for tool in context.tools) + return f"Tools available in this step: {names}." + + +@agent.system_prompt.add +async def customer(context: PromptContext) -> str | None: + if (user := context.state.get("user")) is None: + return None + return f"You are talking to {user}." ``` -在相同 workspace 和 store 中复用 `session_id` 可以继续对话,新对话使用新 ID。 -以 `temp/` 开头的会话运行在 fork 中,不会合并回父 tape。 -归档和 builtin sidecar 的路径仍由 Bub 配置决定,独立于 `FileTapeStore` 的目录。 +context 包含本步的 `prompt`(工具调用后继续时为空)、本步模型可见的 `tools`、本步 `model` 的 `provider:model`、 +本次运行的 `tape` 与 `state`,以及 `agent`(并提供 `available_tools` 快捷属性)。 + +## 会话与 tape + +每次运行使用 agent store 中以 `tape=` 命名的 tape。复用同一个名字即可继续对话;新对话使用新名字。运行写入的是该 tape 的 +fork,运行的流关闭时合并回去;传入 `merge_back=False` 则丢弃。 -每轮调用可以缩小实例能力范围,并覆盖已保存的模型设置: +`state=` 是本次运行可修改的 turn state:一个由 system prompt section、工具和事件处理器共享的字典。agent 不会持久化它; +需要保留的内容请放在你的应用中或写到 tape 上。 ```python -stream = await agent.run_stream( - session_id="customer-42", - prompt="Use $order-policy to check order A123.", - allowed_tools=["lookup_order", "skill"], - allowed_skills=["order-policy"], - model="provider:model-id", # Replace with your configured provider/model. - reasoning_effort="high", # Use a value supported by that model. -) +async with agent.run("Any news on A123?", model=model, tape="customer-42", state={"user": "Ada"}) as stream: + ... + +tape = agent.tape("customer-42") +messages = await tape.read_messages() # 以 Republic 消息表示的对话。 +await tape.handoff(name="summary", state={"summary": "Ada asked about order A123."}) ``` -必须消费返回的 stream。`allowed_tools` 支持工具原名和模型别名,例如 `fs.read` 与 `fs_read`。 -显式 `model` 和 `reasoning_effort` 优先于已保存设置;示例中的模型标识需要替换成实际模型, -reasoning effort 也需使用该模型支持的值。 -通常省略 `state`,由 framework 自动加载;传入字典会跳过加载,并在执行中修改该字典。 +`agent.tape(name)` 返回某个对话的 tape,供运行之外使用;它直接写入 store。默认情况下模型上下文是该 tape +最近一个 anchor 之后记录的对话,因此 `handoff()` 会开始一个更短的上下文。如果某次调用因上下文过长而失败,运行会自动 +handoff 一次并重试该 prompt。 -字符串 prompt 去掉首尾空白后,以 Agent 命令前缀(默认 `,`)开头时会被解释为命令。 -前缀不能为空或包含空白字符。执行时只去掉一次前缀;只有前缀时抛出 `ValueError`。 -覆盖前缀后,旧前缀开头的文本按普通输入处理;多模态 prompt 列表不会被识别为命令。 -已知命令直接调用实例工具,未知命令交给实例的 `bash` 工具执行(需有该工具)。 -直接命令不经过模型和工具拦截 hooks,也不受 agent loop 的 `allowed_tools` 过滤限制。 +## 命令 -## 流式事件与生命周期 +设置了 `command_prefix` 后,以该前缀开头的文本 prompt 会作为命令执行,而不走 agent loop,也不会调用模型。`name args...` +调用名为 `name` 的工具,先传位置参数(按顺序对应工具的参数),再传 `key=value` 参数;其他命令在 agent 有 `bash` 工具时交给它执行。输出作为 +本次运行的 text 和 `final` 事件返回;同时发出 `CommandFinished` 事件报告这次命令,tape 会将其记录为 `command` 事件。 + +```python +agent = Agent(tools=[Tool.from_callable(lookup_order)], command_prefix=",") + +async with agent.run(",lookup_order order_id=A123", model=model, tape="customer-42") as stream: + async for event in stream: + print(event.kind, event.data) # 依次是 text 和 final,内容为工具渲染后的结果。 +``` + +首尾空白会被忽略,且只去掉一个前缀;只有前缀时会抛出 `ValueError`。前缀必须非空且不含空白。带媒体的 prompt 不会作为命令。 +命令调用工具时和模型调用工具一样发出 `BeforeToolCall` 和 `AfterToolCall`,因此工具处理器同样作用于它们;其 +`ToolContext` 的 `command` 为真。 +`agent.run_command(tape, line)` 可以在已打开的 tape 上直接执行命令。 -先 `await run_stream()`,再迭代其返回值。事件包括 `text`、`reasoning`、`tool_call`、 -`tool_result`、`usage`、`error` 和 `final`。 -`final` 表示一次模型步骤结束,整个 turn 需要等到迭代完成。 -调用方应检查 error 事件并处理迭代抛出的异常。stream 对象还提供 `error` 和 `usage` 属性。 +## 事件 -如果要提前退出已经开始的迭代,显式关闭迭代器: +循环通过 `bub.core.events` 中的事件报告进度;注册处理器即可观察或干预它。 ```python -from contextlib import aclosing +from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision + + +@agent.events.on(BeforeToolCall) +def guard(event: BeforeToolCall) -> 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: + print("tool", event.call.tool, event.result.duration_ms, "ms") -stream = await agent.run_stream(session_id="customer-42", prompt="Check order A123") -async with aclosing(stream.__aiter__()) as events: - async for event in events: - print(event.kind, event.data) + +agent.events.on(StepFinished, lambda event: print("step", event.step, event.status)) ``` -所有 turn 完成之前,保持 `framework.running()` 开启。 -在进入生命周期前访问 Agent 缓存的 `tape`,可能使其绑定到内存 fallback。 -显式注入的 store 由应用管理生命周期。 -`Agent` 本身不是异步上下文管理器,当前也没有 `run()` 便捷方法。 +| 事件 | 时机 | 处理器可以 | +| ------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `RunStarted` | 一次运行开始 | 观察模型 spec、prompt 和运行 metadata | +| `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` | 一个步骤的对话已可记录 | 观察 | +| `CommandFinished` | 一个命令 prompt 执行完毕(见 [命令](#命令)) | 观察命令、状态和输出 | + +`agent.events.on(EventType, handler)` 注册同步或异步处理器并返回它;`@agent.events.on(EventType)` 以装饰器形式完成同样的注册, +`agent.events.off(EventType, handler)` 将其移除。为 `AgentEvent` 注册的处理器接收所有事件。处理器按注册顺序执行; +在 `Before*` 事件上设置 decision 会停止向后续处理器分发。处理器抛出的异常会传给本次运行。 -## 接入 FastAPI +每个事件都带有本次运行的 `state`;循环事件还带有它的 `tape`。记录本身也是一个处理器:最先注册的 `TapeRecorder` +把对话和 `loop.*` 事件写入本次运行的 tape。 -运行 `uv add fastapi uvicorn` 安装服务依赖,然后在 `sdk_example.py` 旁保存 `app.py`: +### 自定义事件 + +可以定义自己的事件,让工具、处理器和应用通过同一条事件总线协作。事件是继承 `AgentEvent` 的 keyword-only dataclass +(继承 `TapeEvent` 则还会携带本次运行的 `tape`)。用 `await events.emit(event)` 发出事件:它会把事件分发给为该类型或其 +父类型注册的处理器,并返回该事件。在工具中,本次运行的事件总线是 `context.events`: ```python -import asyncio -from contextlib import asynccontextmanager +from dataclasses import dataclass + +from bub.core import AgentEvent, ToolContext + + +@dataclass(kw_only=True) +class OrderLookedUp(AgentEvent): + order_id: str + status: str -from fastapi import FastAPI, HTTPException, Request -from pydantic import BaseModel -from sdk_example import create_agent +@dataclass(kw_only=True) +class RefundRequested(AgentEvent): + order_id: str + approved: bool | None = None + @property + def stopped(self) -> bool: + # 一旦有处理器做出决定,后续处理器就不再执行。 + return self.approved is not None -@asynccontextmanager -async def lifespan(app: FastAPI): - framework, agent = create_agent() - async with framework.running(): - app.state.agent = agent - app.state.turn_lock = asyncio.Lock() - yield +async def lookup_order(order_id: str, *, context: ToolContext) -> dict[str, str]: + """Look up the status of an order and whether it can be refunded.""" + result = {"order_id": order_id, "status": "shipped"} + if context.events is not None: # 工具在 agent 之外运行时为 None。 + await context.events.emit(OrderLookedUp(state=context.state, **result)) + request = await context.events.emit(RefundRequested(state=context.state, order_id=order_id)) + result["refundable"] = str(bool(request.approved)) + return result -app = FastAPI(lifespan=lifespan) +agent.tools["lookup_order"] = Tool.from_callable(lookup_order, context=True) -class Task(BaseModel): - session_id: str - prompt: str +@agent.events.on(OrderLookedUp) +async def notify(event: OrderLookedUp) -> None: + print("order", event.order_id, "is", event.status, "for", event.state.get("user")) -@app.post("/tasks") -async def run_task(task: Task, request: Request): - async with request.app.state.turn_lock: - stream = await request.app.state.agent.run_stream( - session_id=task.session_id, - prompt=task.prompt, - ) - parts: list[str] = [] - error: str | None = None - async for event in stream: - if event.kind == "text": - parts.append(str(event.data.get("delta", ""))) - elif event.kind == "error": - error = str(event.data.get("message", "Agent failed")) - if error is not None: - raise HTTPException(status_code=502, detail=error) - return {"session_id": task.session_id, "output": "".join(parts)} + +@agent.events.on(RefundRequested) +def approve(event: RefundRequested) -> None: + event.approved = event.state.get("user") == "Ada" ``` -使用 `uv run uvicorn app:app` 启动。这个示例在单进程内串行执行所有 turn。 -需要不同会话并行时,可以由应用维护带清理机制的按会话锁。 -多个 worker 共享 store 时需要跨进程协调,文件写锁并不能保证整个 turn 串行执行。 +每个事件都带有发出它的那次运行的 `state`,因此请传入 `context.state`(在其他处理器中则传 `event.state`)。处理器可以修改 +可变事件,发出方从返回的事件中读取结果;覆盖 `stopped` 可以在某个处理器做出决定后停止分发。应用也可以直接用 +`await agent.events.emit(...)` 发出事件。自定义事件不会写入 tape;需要记录时,请在处理器中自行追加,例如在工具里调用 +`await context.tape.append_event("order.looked_up", {...})`,或使用 `TapeEvent` 并在处理器中调用 `event.tape.append_event(...)`。 + +## 流式事件与生命周期 + +`agent.run()` 是一个异步上下文管理器:进入时开始运行,产出的流在迭代时驱动循环。事件包括 `text`、`reasoning`、`tool_call`、 +`tool_result`、`usage`、`error` 和 `final`。`final` 事件结束的是一个模型步骤;流被消费完时整个运行结束。请检查 error +事件并处理迭代中抛出的异常。流对象还提供 `error` 和 `usage`。 + +离开 `async with` 块时会关闭流,提前 `break` 或抛出异常时也一样:模型流停止、tape fork 合并回去、trace span 结束。 + +```python +async with agent.run("Check order A123", model=model, tape="customer-42") as stream: + async for event in stream: + if event.kind == "tool_call": + break # 运行在这里停止,已记录的内容会保留。 +``` -## Agent 与完整消息管线 +如果要把流交给其他代码(例如稍后才消费它的框架),用相同参数调用 `await agent.start(...)`。它返回流但不负责关闭; +消费方必须把它消费完或调用 `aclose()`。 -对于普通 prompt,`Agent.run_stream()` 会运行 builtin loop,以及模型和工具拦截 hooks。 -它不会调用 `build_prompt`、`save_state`、outbound 渲染或 channel 分发, -也不会选择插件替换的 `run_model` 实现。输出由应用消费。 +空 prompt 会直接得到 `error: empty prompt`,不会打开 tape。配置了 tracing 时,运行会记录为 `invoke_agent {name}` span; +见 [Observability](/zh-cn/docs/tutorials/observability/)。 -需要完整消息管线或模型执行插件时,使用 `BubFramework.process_inbound()`。 -它返回包含 `model_output`、`state` 和 `outbounds` 的 `TurnResult`; -`stream_output=True` 会通过绑定的 channel router 路由流式输出,但最终仍返回完整结果。 -扩展点详见 [Hooks](/zh-cn/docs/build/hooks/)。 +如果要在自己管理的 tape 上驱动循环,用 `agent.session(name, state=...)` 打开它,再调用 +`agent.run_on(tape, prompt, model=model)`;只跑一个模型步骤时用 `agent.run_once(prompt, model=model, tape=tape)`。 +两者都接受 `options`。 -自定义 channel 必须在把命令交给 channel manager 前设置 `ChannelMessage.kind="command"`, -才能跳过缓冲和活跃窗口过滤。缓冲层不再根据开头的逗号推断命令。 -使用 builtin Agent 时,消息内容仍需保留配置的前缀,供 Agent 执行命令。 +## 定制循环 + +继承 `Agent` 可以改变每一步的准备方式。每个方法都会收到本次运行的 tape,其 `context.state` 就是 turn state: + +| 方法 | 默认行为 | 用途 | +| -------------------------------------- | -------- | ---------------------------------- | +| `prepare_tools(tape, prompt)` | 全部工具 | 选择下一步模型可见的工具。 | +| `pending_messages(tape)` | 无 | 返回运行期间到达的用户消息。 | +| `has_pending_messages(tape)` | `False` | 有消息等待时让运行再继续一步。 | +| `continue_prompt(tape, prompt, state)` | `None` | 在工具调用后返回下一步的用户消息。 | + +```python +from bub.core import Tape + + +class TriageAgent(Agent): + async def prepare_tools(self, tape: Tape, prompt): + tools = await super().prepare_tools(tape, prompt) + if tape.context.state.get("read_only"): + tools = [tool for tool in tools if tool.name == "lookup_order"] + return tools +``` -配置加载仍是进程级的,应在应用启动时统一配置。 -工具、skills 和 store 按实例隔离,不代表配置文件和环境变量也按实例隔离。 +要通过 HTTP 提供 agent 服务,请参阅 [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/)。 diff --git a/website/src/content/docs/zh-cn/docs/build/tools.mdx b/website/src/content/docs/zh-cn/docs/build/tools.mdx index 23c61ebf..e7b82f38 100644 --- a/website/src/content/docs/zh-cn/docs/build/tools.mdx +++ b/website/src/content/docs/zh-cn/docs/build/tools.mdx @@ -109,7 +109,7 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 REGISTRY: dict[str, Tool] = {} ``` -每一次 `@tool` 调用都会在**导入时**修改这个字典。`Agent(tools=None)` 在创建时取得其快照,之后导入的工具不会自动更新已有 Agent。可直接向实例的 `Agent.tools` 添加工具,标记 `deferred=True` 即可[按需加载](#按需加载工具)。 +每一次 `@tool` 调用都会在**导入时**修改这个字典。`BubAgent(tools=None)` 在创建时取得其快照,之后导入的工具不会自动更新已有 Agent。可直接向实例的 `BubAgent.tools` 添加工具,标记 `deferred=True` 即可[按需加载](#按需加载工具)。 ## 在插件里导入工具模块 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 be6c9b64..c0d730cc 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 @@ -54,7 +54,7 @@ builtin spill 插件通过 `provide_tape_sidecar` 挂载 `SpillStore`、注册 ` 主 tape 只保留有界预览和 opaque handle,不保存完整结果。`spill.read` 工具按 handle 与 cursor 有界读取,也支持从末尾开始读取。由 `spill.read` 明确返回的内容会作为普通的有界 tool result 记录。`spill.read` 的 `exposure` 是 `"direct"`,因此返回纯文本,并且在 code mode 下仍然直接可调用,而不必经过 `run_code`。 -sidecar 与 session tape 一起参与 fork、merge、archive 和 reset,但始终是独立 tape,构造主 context 时不会扫描它。也可以通过 `Tape.archive_sidecar("spill")` 和 `Tape.reset_sidecar("spill")` 单独 archive 或 reset sidecar。当 reset 要求先 archive 时,如果 archive 失败,Bub 会保留 sidecar。 +sidecar 与 session tape 一起参与 fork、merge、archive 和 reset,但始终是独立 tape,构造主 context 时不会扫描它。也可以通过 `Tape.reset_sidecar("spill")` 单独 reset sidecar,或通过 `bub.builtin.archive.archive_sidecar(tape, "spill", directory)` 单独 archive。当 `tape.reset` 要求先 archive 时,如果 archive 失败,Bub 会保留 sidecar。 spill 配置归 sidecar 插件自己所有:在 `config.yml` 使用 `spill.threshold`,或设置环境变量 `BUB_SPILL_THRESHOLD`。设为 `0` 只停止新写入,不会卸载 sidecar,因此已有 handle 仍可读取,tape 生命周期操作也仍会包含它。 diff --git a/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx b/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx index 12ed1f50..ccaae096 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/turn-pipeline.mdx @@ -84,9 +84,9 @@ hook 运行时会在两者间互相适配:若插件只实现 `run_model_stream `BuiltinImpl`(注册名 `builtin`)以合理默认实现上述每个 hook: - `resolve_session` — 优先使用 envelope 的 `session_id`,否则 `f"{channel}:{chat_id}"`。 -- `load_state` — 打开 inbound 的可选 `lifespan`,初始化 `session_id` 与内部 `_runtime_agent`。 -- `build_prompt` — 处理 comma 命令(`,help`、`,skill` 等),用时间戳与上下文包裹文本,并在附带媒体时输出多模态片段。 -- `run_model` / `run_model_stream` — 分别委托给 `Agent.run` / `Agent.run_stream`,即由 [Republic](https://github.com/bubbuild/republic) 支撑、带 tool 使用与 auto-handoff 的循环(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。 +- `load_state` — 打开 inbound 的可选 `lifespan`,初始化 `session_id` 与内部 `_runtime_agent`,并恢复 session tape 上记录的会话设置(模型、reasoning effort、code mode)。 +- `build_prompt` — 标记命令 prompt(`,help`、`,skill` 等)并原样传给 agent 执行,用时间戳与上下文包裹其他文本,并在附带媒体时输出多模态片段。 +- `run_model_stream` — 通过 `BubAgent.run_stream()` 运行 turn,它构建在 `bub.core` 的 agent loop 之上,使用 [Republic](https://github.com/bubbuild/republic) 模型,支持工具调用、命令和 auto-handoff(见 [Tape and context](/zh-cn/docs/concepts/tape-and-context/))。builtin 插件不实现 `run_model`;非流式调用方通过上文描述的适配获得拼接后的文本。 - `save_state` — 用捕获的异常信息关闭 per-message lifespan。 - `render_outbound` — 将 `model_output` 包装为携带 inbound 路由字段的 `ChannelMessage`。 - `dispatch_outbound` — 转发到绑定的 `ChannelRouter`。 diff --git a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx index 8f64f12f..fbace925 100644 --- a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx @@ -36,10 +36,10 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用 | `provide_channels` | sync-only consumer (deduped) | `(message_handler: MessageHandler) -> list[Channel]` | channels | `BubFramework.get_channels` (`call_many_sync`) | 按 `Channel.name` 去重;在钩子优先级顺序中最先出现的 channel 胜出。 | | `build_tape_context` | firstresult | `() -> TapeContext` | tape context | `BubFramework.build_tape_context` (`call_first_sync`) | 仅同步;awaitable 返回会被跳过。 | | `admit_message` | firstresult | `(session_id, message, turn) -> AdmitDecision \| None` | turn admission decision | `ChannelManager` | 调度 channel message 前调用。返回 `None` 保持默认并发调度;decision 类型见 [核心契约](/zh-cn/docs/reference/types/)。 | -| `before_llm_call` | chained | `(request: LlmCallRequest, state: TurnState) -> LlmCallRequest \| LlmCallDecision \| None` | 修改后的 request 或 finish 决定 | `ModelRunner.run` 经 `AgentHooks` | 实现按 LIFO 链式执行,每个实现看到的是前一个实现修改后的 request。返回 `LlmCallDecision.finish(text)` 跳过 provider 调用。实现抛异常仅记日志并跳过。 | -| `after_llm_call` | observer | `(request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None` | 无 | `ModelRunner.run` 经 `AgentHooks` | 每次完成的调用恰好触发一次:成功或 `Exception` 失败。取消 / 消费方 `aclose()` 不观察。`result.error` 是原始异常。 | -| `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` 经 `AgentHooks` | 逐次工具调用。`proceed(arguments=…)` 折叠参数修改;`replace(result)` / `deny(message)` 短路。否决只能通过 decision 对象——异常仅记日志并跳过。 | -| `after_tool_call` | 终态处理 | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | 无 | `ToolExecutor` 经 `AgentHooks` | 成功、失败、deny/replace 会触发。取消不观察。赋值 `result.result` 会替换模型可见结果;失败时 `result.error` 仍是原始 `BubError`。 | +| `before_llm_call` | chained | `(request: LlmCallRequest, state: TurnState) -> LlmCallRequest \| LlmCallDecision \| None` | 修改后的 request 或 finish 决定 | core `Agent` 经 `PluginAgentHooks` | 实现按 LIFO 链式执行,每个实现看到的是前一个实现修改后的 request。返回 `LlmCallDecision.finish(text)` 跳过 provider 调用。实现抛异常仅记日志并跳过。 | +| `after_llm_call` | observer | `(request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None` | 无 | core `Agent` 经 `PluginAgentHooks` | 每次完成的调用恰好触发一次:成功或 `Exception` 失败。取消 / 消费方 `aclose()` 不观察。`result.error` 是原始异常。 | +| `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` 经 `PluginAgentHooks` | 逐次工具调用。`proceed(arguments=…)` 折叠参数修改;`replace(result)` / `deny(message)` 短路。否决只能通过 decision 对象——异常仅记日志并跳过。 | +| `after_tool_call` | 终态处理 | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | 无 | `ToolExecutor` 经 `PluginAgentHooks` | 成功、失败、deny/replace 会触发。取消不观察。赋值 `result.result` 会替换模型可见结果;失败时 `result.error` 仍是原始 `BubError`。 | ### Prompt 内容 @@ -125,15 +125,15 @@ Windows 当前只清理直接 shell 进程。 `notify_error` 与 `notify_error_sync` 把每个实现包在 `try`/`except` 中;观察者失败会写入日志 (`hook.on_error_failed stage=… adapter=…`) 但不会向上传递。这样可以保证某个观察者出错不会阻塞其他观察者,也不会让 `on_error` 掩盖原始异常。 -### Agent-loop 钩子(`AgentHooks`) +### Agent-loop 钩子(`PluginAgentHooks`) -四个 `*_llm_call` / `*_tool_call` 钩子经由 `AgentHooks` 门面(`src/bub/hooks/interception.py`)分发,而不是 `call_first`/`call_many`,有三处刻意差异: +四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` 事件,见 `bub.core.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/hooks/interception.py`。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 +载荷数据类(`LlmCallRequest`、`LlmCallResult`、`ToolCall`、`ToolCallDecision`、`ToolCallResult`、`LlmCallDecision`)位于 `src/bub/core/events.py`,并由 `bub.hooks.interception` 重新导出。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 continuation 风格的 `wrap_tool_call`(重试 / human-in-the-loop / 缓存)曾在原 PR 中实现,按 review 方向移除;若相关用例成熟,将作为独立提案回归。 diff --git a/website/src/content/docs/zh-cn/docs/reference/types.mdx b/website/src/content/docs/zh-cn/docs/reference/types.mdx index e02ed259..edd76252 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -6,7 +6,7 @@ description: Bub 中由 turn、model、stream、hook 与 channel 子系统分别 Bub 把契约放在拥有其行为的子系统旁边。请从定义它的模块导入;顶层 `bub` 包只暴露常用框架入口。 :::caution -为兼容旧代码,`bub.types` 暂时只重导出 `Envelope`、`State` 和 `MessageHandler`,并在导入时发出 `DeprecationWarning`。新代码应直接使用 `bub.envelope.Envelope`、`bub.turn.TurnState` 和 `bub.channels.contracts.MessageHandler`。 +原先的兼容模块 `bub.types` 已移除。请直接使用 `bub.envelope.Envelope`、`bub.turn.TurnState` 和 `bub.channels.contracts.MessageHandler`。 ::: ## 内核词汇 @@ -86,10 +86,10 @@ Hook 声明与 marker 位于 `bub.hooks`;agent-loop 拦截 payload 与它们 ```python from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl, hookspec from bub.hooks.interception import ( - AgentHooks, LlmCallDecision, LlmCallRequest, LlmCallResult, + PluginAgentHooks, ToolCall, ToolCallDecision, ToolCallResult, diff --git a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx new file mode 100644 index 00000000..b425630f --- /dev/null +++ b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx @@ -0,0 +1,124 @@ +--- +title: 用 FastAPI 提供 Bub agent 服务 +description: 用 FastAPI 把 bub.core agent 暴露为 HTTP API,每个会话对应一段对话。 +sidebar: + order: 7 +--- + +本教程把基于 `bub.core` 构建的 agent 作为 HTTP API 提供服务。每个请求都指定一个会话;同一会话的请求会延续同一段对话, +对话以 tape 的形式保存在磁盘上。 + +你需要 Python 3.12+ 和某个模型提供方的 API key。示例通过 [Republic](https://github.com/bubbuild/republic) 使用 OpenAI; +任何 Republic `provider:model` 都可以。 + +## 安装 + +```bash +uv init bub-api && cd bub-api +uv add bub fastapi uvicorn +``` + +## 编写应用 + +将以下内容保存为 `app.py`: + +```python +import asyncio +from collections import defaultdict +from contextlib import asynccontextmanager +from pathlib import Path + +import republic +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel + +from bub.core import Agent, Tool +from bub.core.store import FileTapeStore + + +async def lookup_order(order_id: str) -> dict[str, str]: + """Look up the status of an order.""" + # 将这个演示返回值替换为你应用中的订单服务。 + return {"order_id": order_id, "status": "shipped"} + + +def create_agent() -> Agent: + return Agent( + store=FileTapeStore(Path(__file__).resolve().parent / "sessions"), + tools=[Tool.from_callable(lookup_order)], + system_prompt=[ + "You are an order assistant. Reply directly to the user.", + "Use lookup_order to check order status before answering.", + ], + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # API key 从 REPUBLIC_OPENAI_API_KEY 读取。 + async with republic.get_model("openai:gpt-5-mini") as model: + app.state.agent = create_agent() + app.state.model = model + app.state.session_locks = defaultdict(asyncio.Lock) + yield + + +app = FastAPI(lifespan=lifespan) + + +class Task(BaseModel): + session_id: str + prompt: str + + +@app.post("/tasks") +async def run_task(task: Task, request: Request): + state = request.app.state + # 同一会话 tape 上的运行不能重叠;不同会话可以并发运行。 + async with state.session_locks[task.session_id]: + parts: list[str] = [] + error: str | None = None + async with state.agent.run([task.prompt], model=state.model, tape=task.session_id) as stream: + async for event in stream: + if event.kind == "text": + parts.append(str(event.data.get("delta", ""))) + elif event.kind == "error": + error = str(event.data.get("message", "Agent failed")) + if error is not None: + raise HTTPException(status_code=502, detail=error) + return {"session_id": task.session_id, "output": "".join(parts)} +``` + +lifespan 只打开一次 Republic 模型,并在服务关闭时关闭它。agent 不保存任何运行级状态,因此一个实例即可服务所有请求; +每次运行使用该会话 tape 的 fork,离开 `async with agent.run(...)` 块时合并回去,运行失败时也是如此。 + +## 运行 + +```bash +REPUBLIC_OPENAI_API_KEY=... uv run uvicorn app:app +``` + +在另一个终端中开始一段对话,再用同一个 `session_id` 继续: + +```bash +curl -s localhost:8000/tasks -H 'content-type: application/json' \ + -d '{"session_id": "customer-42", "prompt": "Where is order A123?"}' + +curl -s localhost:8000/tasks -H 'content-type: application/json' \ + -d '{"session_id": "customer-42", "prompt": "And when did it ship?"}' +``` + +第二次回答可以引用第一次的内容,因为两次运行读取的都是 `sessions/` 下的 `customer-42` tape。 + +## 部署之前 + +- `session_locks` 会随会话数量增长;长期运行的服务需要清理空闲的锁。 +- 这些锁只能让同一进程内的运行串行。多个 worker 进程共享同一个 store 时需要跨进程协调,例如使用数据库中的锁; + 文件写锁并不能让一次运行串行化。 +- `FileTapeStore` 适合单机。多台主机共享会话时,请使用基于数据库的 store,参见 + [使用 SQLAlchemy 将 tape 持久化到 SQLite](/zh-cn/docs/tutorials/tapestore-sqlalchemy/)。 + +## 下一步 + +- 添加工具、system prompt section 和事件处理器:阅读 [Python SDK](/zh-cn/docs/build/sdk/)。 +- 端到端追踪请求:阅读 [使用 tape、Phoenix 与 Logfire 观察 Bub](/zh-cn/docs/tutorials/observability/)。 diff --git a/website/src/content/docs/zh-cn/docs/tutorials/index.mdx b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx index 2b3a29f3..12058ca2 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/index.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/index.mdx @@ -16,6 +16,7 @@ sidebar: 5. [加载 Agent Plugins](/zh-cn/docs/tutorials/agent-plugins/) — 加载可移植的 skill 和 MCP 服务器,与现有 Bub 扩展一起使用。 6. [实验性:使用其他语言扩展 Bub](/zh-cn/docs/tutorials/language-extensions/) — 保持 Bub 作为 Python 宿主,把部分 hooks 放到 Go、Rust 或其他语言中实现,并通过 pluggy、WebAssembly 和 Extism 接回 Bub。 7. [将 Bub 作为 ACP agent 使用](/zh-cn/docs/tutorials/acp-server/) — 把 Bub 接入 Zed 等支持 ACP 的图形客户端。 +8. [用 FastAPI 提供 Bub agent 服务](/zh-cn/docs/tutorials/fastapi/) — 把 `bub.core` agent 暴露为 HTTP API,每个会话对应一段对话。 ## 下一步 From bf284ec65dc4a1388b9d437cb5726c26ade85653 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 14 Oct 2026 09:29:26 +0800 Subject: [PATCH 11/16] docs: update README to improve clarity and organization of content Signed-off-by: Frost Ming --- README.md | 125 +++++++----------------------------------------------- 1 file changed, 16 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index c1699ce0..be728b6e 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@

A tiny agent runtime, composable with plugins.

+[Website](https://bub.build) · [Docs](https://bub.build/docs/getting-started/) · [Hub](https://hub.bub.build) + Bub is a small Python runtime for building agents in shared environments. It started in group chats, where multiple humans and agents had to work in the same conversation without hidden state, hand-wavy memory, or framework-specific magic. -Built on [agents.md](https://agents.md/) and [Agent Skills](https://agentskills.io/) , Bub stays intentionally small. Every turn stage is a [pluggy](https://pluggy.readthedocs.io/) hook. Builtins are included but replaceable. The same runtime drives CLI, Telegram, and any channel you add. - -[Website](https://bub.build) · [GitHub](https://github.com/bubbuild/bub) +Built on [agents.md](https://agents.md/) and [Agent Skills](https://agentskills.io/), Bub stays intentionally small. Every turn stage is a [pluggy](https://pluggy.readthedocs.io/) hook, builtins are replaceable, and the same runtime drives CLI, Telegram, and any channel you add. ## Quick Start @@ -31,25 +31,7 @@ Windows PowerShell: powershell -ExecutionPolicy ByPass -c "irm https://bub.build/install.ps1 | iex" ``` -The interactive installer uses a colored preset picker, accepts additional plugin dependencies, and runs `bub onboard` after installation. For automation, select a preset explicitly; non-interactive installs skip onboarding: - -```bash -curl -fsSL https://bub.build/install.sh | bash -s -- --preset recommended --dependency extra-plugin -``` - -```powershell -powershell -ExecutionPolicy ByPass -c "& ([scriptblock]::Create((irm https://bub.build/install.ps1))) --preset recommended --dependency extra-plugin" -``` - -Or from source: - -```bash -git clone https://github.com/bubbuild/bub.git -cd bub -uv sync # enough to run Bub from source -``` - -For local development, use `make install` instead so the website toolchain and `prek` hooks are installed too. +Then: ```bash bub chat # interactive session @@ -57,13 +39,16 @@ bub run "summarize this repo" # one-shot task bub gateway # channel listener mode ``` +See [Getting Started](https://bub.build/docs/getting-started/) for installer presets and source installs, and [Configure](https://bub.build/docs/operate/configure/) for model and runtime settings. + ## Why Bub - **Composable by design.** Every turn stage is a plugin hook. Override one stage or replace the whole flow without forking the runtime. - **Tape context.** Context is rebuilt from append-only records, not carried around as mutable session state. Easier to inspect, replay, and hand off. - **One runtime across surfaces.** The same inbound pipeline runs across CLI, Telegram, and custom channels. Adapters change the surface, not the runtime model. - **Batteries included.** CLI, Telegram, tools, skills, and model execution ship with the core runtime. Use the defaults first, replace them later. -- **Operator equivalence.** Humans and agents work inside the same runtime boundaries, with the same evidence trail and handoff model. No hidden operator class. +- **Operator equivalence.** Humans and agents work inside the same runtime boundaries, with the same evidence trail and handoff model. +- **Embeddable Python SDK.** `bub.core` exposes the agent loop on its own — bring your own model, tools, prompts, and tape store, with no settings or plugins required. ## How It Works @@ -75,101 +60,23 @@ resolve_session → load_state → build_prompt → run_model dispatch_outbound ← render_outbound ← save_state ``` -Builtins are registered first. External plugins load after them. At runtime, later plugins take precedence. There are no framework-only shortcuts. - -Key source files: - -- Turn orchestrator: [`src/bub/framework.py`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py) -- Hook contract: [`src/bub/hooks/specs.py`](https://github.com/bubbuild/bub/blob/main/src/bub/hooks/specs.py) -- Builtin hooks: [`src/bub/builtin/hook_impl.py`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/hook_impl.py) -- Skill discovery: [`src/bub/skills.py`](https://github.com/bubbuild/bub/blob/main/src/bub/skills.py) - -## Extend It - -```python -from bub import hookimpl -from bub.envelope import content_of - - -class EchoPlugin: - @hookimpl - def build_prompt(self, message, session_id, state): - return f"[echo] {content_of(message)}" - - @hookimpl - async def run_model(self, prompt, session_id, state): - return prompt - - -echo_plugin = EchoPlugin() -``` - -```toml -[project.entry-points."bub"] -echo = "my_package.plugin:echo_plugin" -``` - -See the [Build docs](https://bub.build/docs/build/) for hook guides, packaging, and plugin structure. - -## CLI - -| Command | Description | -| ------------------ | --------------------------------- | -| `bub chat` | Interactive REPL | -| `bub run MESSAGE` | One-shot turn | -| `bub gateway` | Channel listener (Telegram, etc.) | -| `bub install` | Install or sync Bub plugin deps | -| `bub update` | Upgrade Bub plugin deps | -| `bub login codex` | ChatGPT plan login | - -Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`). - -`bub hooks` still exists for diagnostics, but it is hidden from top-level help. `bub install` and `bub update` manage a separate uv project for Bub plugins, defaulting to `~/.bub/bub-project` or `BUB_PROJECT`. - -## Configuration - -| Variable | Default | Description | -| --------------------------- | ---------------------------- | ---------------------------------------------------- | -| `BUB_MODEL` | `openrouter:openrouter/free` | Model identifier | -| `BUB_API_KEY` | — | Provider key (optional with `bub login codex`) | -| `BUB_API_BASE` | — | Custom provider endpoint | -| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client | -| `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call | -| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer | -| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call | -| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) | -| `BUB_SPILL_THRESHOLD` | `4096` | Estimated tokens before tool output spills; `0` disables | - -## Background - -Bub is shaped by one constraint: real collaboration is messier than a solo demo. In shared environments, operators need visible boundaries, auditable history, and extension points that do not collapse into framework sprawl. - -Read more: - -- [Why We Rewrote Bub](https://bub.build/posts/why-rewrite-bub/) -- [Socialized Evaluation and Agent Partnership](https://bub.build/posts/socialized-evaluation/) -- [Context from Tape](https://tape.systems) +Builtins are registered first and external plugins load after them, so later plugins take precedence. There are no framework-only shortcuts. ## Docs - [Getting Started](https://bub.build/docs/getting-started/) — install Bub and run the first turn - [Concepts](https://bub.build/docs/concepts/) — the mental model behind the runtime -- [Channels](https://bub.build/docs/operate/channels/) — run Bub in CLI, Telegram, or your own channel -- [Skills](https://bub.build/docs/build/skills/) — discover, inspect, and author Agent Skills in Bub +- [Python SDK](https://bub.build/docs/build/sdk/) — embed Bub's agent loop in your own application - [Build](https://bub.build/docs/build/) — write plugins, override hooks, ship tools and skills +- [Bub Hub](https://hub.bub.build) — browse community plugins, skills, and distributions +- [Channels](https://bub.build/docs/operate/channels/) — run Bub in CLI, Telegram, or your own channel - [Deployment](https://bub.build/docs/operate/deploy/) — Docker, environment, upgrades -## Development - -```bash -make install -make check -make test -make docs -make docs-test -``` +## Background -See [CONTRIBUTING.md](https://github.com/bubbuild/bub/blob/main/CONTRIBUTING.md). +- [Why We Rewrote Bub](https://bub.build/posts/why-rewrite-bub/) +- [Socialized Evaluation and Agent Partnership](https://bub.build/posts/socialized-evaluation/) +- [Context from Tape](https://tape.systems) ## License From 48a4b7c00c44d9f82ddb24ebc6055f96d8832917 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 14 Oct 2026 10:39:09 +0800 Subject: [PATCH 12/16] feat: accept republic built-in tools in core Agent (#358) * refactor: use Republic for builtin models and authentication * refactor: consume native model outputs * refactor: delegate request extras to Republic * refactor: simplify builtin options and naming * chore: use Republic 0.6 preview * Refactor prompt handling and improve compatibility with legacy content - Introduced `republic` module to manage media types and user content. - Updated tests to validate new prompt structure and legacy content conversion. - Modified `build_prompt` to return a list of `UserContent` instead of legacy formats. - Enhanced documentation to reflect changes in prompt handling and hook signatures. - Adjusted various tests to ensure compatibility with new prompt formats and legacy support. Signed-off-by: Frost Ming * fix: replay Chat Completions tool calls from older tapes Older Bub versions recorded tool calls as {"id", "type": "function", "function": {"name", "arguments"}}, which republic.Message.from_dict rejects with KeyError: 'name', so sessions with such tapes could not build their context. Convert them to Republic's form in to_message, next to the other legacy conversions. Co-Authored-By: Claude Opus 5.5 * feat: identify Republic model clients as bub/{version} Send User-Agent: bub/{version} instead of Republic's default through AgentSettings.model_client_kwargs, used by the model runner, code mode and onboarding. A User-Agent in client_args.headers still wins. Co-Authored-By: Claude Opus 5.5 * feat: expose republic models and media attachments in code mode (#351) - Add a stdlib `republic` module to run_code: media classes, messages, get_model() (chat and stream), get_embedding_model() and get_decision_model(). Model calls are proxied to Bub, which uses its provider settings and credentials; they never reach the code. - Add code-only tools attach_image/attach_audio/attach_video that accept republic media or a path/URL/data URL and attach it to the run_code result, sent to the model as a multimodal tool message. - Add content results (bub.tools.content_result) for tool results with media; spilling bounds only their text. - Add Environment.read_bytes with a default that runs the environment's python. - Document the republic module in the code mode stub and prompt. Co-authored-by: Claude Opus 5.5 * refactor!: extract an event-driven core agent into bub.core (#356) * docs: update README to improve clarity and organization of content Signed-off-by: Frost Ming * feat: accept republic built-in tools in core Agent Co-Authored-By: Claude Opus 5.5 --- src/bub/core/agent.py | 20 +++++++++++++++---- tests/test_core_agent.py | 20 +++++++++++++++++++ website/src/content/docs/docs/build/sdk.md | 2 +- .../src/content/docs/zh-cn/docs/build/sdk.md | 2 +- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py index 429495bd..318e6e88 100644 --- a/src/bub/core/agent.py +++ b/src/bub/core/agent.py @@ -14,6 +14,7 @@ import republic from loguru import logger from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady +from republic.tools import BuiltinTool from bub.core.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix from bub.core.context import default_tape_context @@ -61,7 +62,9 @@ class Agent: model: Default model of runs not given one; None requires each run to give one. store: Tape store holding conversations; each run works on one named tape in it. Defaults to an in-memory store. - tools: Tools the model may call, kept by name in ``tools``. + tools: Tools the model may call. Bub tools are kept by name in ``tools``; + ``republic.tools`` built-in tools, which the provider runs itself, are kept in + ``builtin_tools`` and sent with every model call. system_prompt: System prompt sections: text, or functions of the step's :class:`PromptContext` (prompt, tools, tape, state, agent) returning text. Add more with ``agent.system_prompt.add``. @@ -88,7 +91,7 @@ def __init__( *, model: Model | None = None, store: TapeStore | AsyncTapeStore | None = None, - tools: Iterable[Tool] = (), + tools: Iterable[Tool | BuiltinTool] = (), system_prompt: SystemPrompt | PromptSection | Iterable[PromptSection] = (), context: TapeContext | None = None, sidecars: Sequence[TapeSidecar] = (), @@ -97,7 +100,13 @@ def __init__( command_prefix: str | None = None, ) -> None: self.model = model - self.tools = {tool.name: tool for tool in tools} + self.tools: dict[str, Tool] = {} + self.builtin_tools: list[BuiltinTool] = [] + for tool in tools: + if isinstance(tool, Tool): + self.tools[tool.name] = tool + else: + self.builtin_tools.append(tool) self.system_prompt = SystemPrompt.of(system_prompt) if store is None: store = InMemoryTapeStore() @@ -561,7 +570,10 @@ async def _traced_completion( try: call_options = { **options, - "tools": [republic.Tool(item.name, item.description, item.parameters) for item in tools], + "tools": [ + *(republic.Tool(item.name, item.description, item.parameters) for item in tools), + *self.builtin_tools, + ], } if request.max_tokens is not None: call_options["max_tokens"] = request.max_tokens diff --git a/tests/test_core_agent.py b/tests/test_core_agent.py index f998dc4e..087a7d07 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -264,6 +264,26 @@ async def test_runs_call_the_agent_model_unless_given_one(provider_service: Prov assert [provider_service.body(index)["model"] for index in (0, 1)] == ["default", "override"] +@pytest.mark.asyncio +async def test_builtin_tools_are_sent_with_function_tools(provider_service: ProviderService) -> None: + provider_service.reply(sse(chat_events("searched"))) + ping = Tool.from_callable(lambda: "pong", name="ping") + native = republic.tools.NativeTool("chat", {"type": "custom_search"}) + + async with provider_service.client() as client: + agent = Agent(model=_model(client), tools=[ping, republic.tools.WebSearch(), native]) + async with agent.run("search", tape="builtin") as stream: + events = [event async for event in stream] + + assert events[-1].data == {"ok": True, "text": "searched"} + assert list(agent.tools) == ["ping"] + assert agent.builtin_tools == [republic.tools.WebSearch(), native] + body = provider_service.body() + assert body["tools"][0]["function"]["name"] == "ping" + assert body["tools"][1] == {"type": "custom_search"} + assert "web_search_options" in body + + @pytest.mark.asyncio async def test_runs_without_a_model_raise_unless_they_are_commands() -> None: agent = Agent(tools=[Tool.from_callable(lambda: "pong", name="ping")], command_prefix="/") diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index a1a7936a..c7dfe9e1 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -80,7 +80,7 @@ continues the conversation stored under `sessions/`. | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | `model` | Default model of runs not given one; see [Models](#models). `None` (the default) requires each run to give one. | | `store` | A `TapeStore` or `AsyncTapeStore`. Defaults to `InMemoryTapeStore()`. | -| `tools` | `Tool` objects the model may call, kept by name in `agent.tools`. | +| `tools` | `Tool` objects the model may call, kept by name in `agent.tools`, and Republic built-in tools such as `republic.tools.WebSearch()` that the provider runs itself, kept in `agent.builtin_tools` and sent with every model call. | | `system_prompt` | One system prompt section or a list of them; see [System prompt](#system-prompt). | | `context` | A `TapeContext` choosing which tape entries become model context. Defaults to `default_tape_context()`. | | `sidecars` | `TapeSidecar` capabilities mounted on every tape. | 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 7c532c97..1b67ed00 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -78,7 +78,7 @@ if __name__ == "__main__": | ----------------- | --------------------------------------------------------------------------------- | | `model` | 未传入模型的运行所用的默认模型;见 [模型](#模型)。默认 `None`,即每次运行都需传入。 | | `store` | `TapeStore` 或 `AsyncTapeStore`。默认 `InMemoryTapeStore()`。 | -| `tools` | 模型可调用的 `Tool` 对象,按名字保存在 `agent.tools` 中。 | +| `tools` | 模型可调用的 `Tool` 对象,按名字保存在 `agent.tools` 中;也可传入由 provider 自行执行的 Republic 内置工具(如 `republic.tools.WebSearch()`),保存在 `agent.builtin_tools` 中,随每次模型调用发送。 | | `system_prompt` | 一个或一组 system prompt section;见 [System prompt](#system-prompt)。 | | `context` | 决定哪些 tape 条目进入模型上下文的 `TapeContext`。默认 `default_tape_context()`。 | | `sidecars` | 挂载到每条 tape 上的 `TapeSidecar` 能力。 | From c705b2465192dbd3adb5dc1a907b8e2f443c91be Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 16 Oct 2026 09:29:20 +0800 Subject: [PATCH 13/16] refactor!: install only bub.core by default and streamline the agent kernel (#361) * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 | 35 ++ AGENTS.md | 10 +- Dockerfile | 2 +- README.md | 2 +- pyproject.toml | 32 +- src/bub/__init__.py | 41 +- src/bub/__main__.py | 52 +- src/bub/builtin/cli.py | 2 +- src/bub/builtin/hook_impl.py | 4 +- src/bub/builtin/store.py | 87 ++++ src/bub/channels/cli/__init__.py | 10 +- src/bub/configure.py | 5 + src/bub/core/__init__.py | 34 +- src/bub/core/agent.py | 449 ++++++++++-------- src/bub/core/events.py | 44 +- src/bub/core/model.py | 2 +- src/bub/core/recorder.py | 22 +- src/bub/core/store.py | 74 --- src/bub/core/tools.py | 10 +- src/bub/framework.py | 4 +- src/bub/hooks/interception.py | 50 +- src/bub/hooks/specs.py | 12 +- src/bub/sidecars.py | 8 - src/bub/store.py | 8 - src/bub/tape.py | 8 - src/bub/tools.py | 8 - tests/conftest.py | 2 +- tests/model_fakes.py | 10 +- tests/test_agent_hooks.py | 60 +-- tests/test_builtin_agent.py | 24 +- tests/test_builtin_cli.py | 2 +- tests/test_builtin_codex.py | 6 +- tests/test_builtin_hook_impl.py | 10 +- tests/test_builtin_tools.py | 6 +- tests/test_channels.py | 4 +- tests/test_cli_main.py | 72 +++ tests/test_codemode.py | 22 +- tests/test_core_agent.py | 100 ++-- tests/test_core_agent_step.py | 6 +- tests/test_core_boundary.py | 43 ++ tests/test_environment.py | 6 +- tests/test_file_tape_store_entry_ids.py | 6 +- tests/test_fork_store_merge_back.py | 4 +- tests/test_install_scripts.py | 2 +- tests/test_otlp.py | 2 +- 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 | 14 +- 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 | 20 +- tests/test_tracing.py | 15 +- uv.lock | 62 ++- website/public/install.ps1 | 2 +- website/public/install.sh | 2 +- .../content/docs/docs/build/distribution.mdx | 6 +- website/src/content/docs/docs/build/hooks.mdx | 6 +- .../src/content/docs/docs/build/plugins.mdx | 2 +- website/src/content/docs/docs/build/sdk.md | 39 +- website/src/content/docs/docs/build/tools.mdx | 10 +- .../docs/docs/concepts/tape-and-context.mdx | 6 +- .../docs/getting-started/first-plugin.mdx | 2 +- .../docs/docs/getting-started/install.mdx | 6 +- .../src/content/docs/docs/operate/deploy.mdx | 2 +- .../src/content/docs/docs/reference/hooks.mdx | 10 +- .../src/content/docs/docs/reference/types.mdx | 8 +- .../content/docs/docs/tutorials/fastapi.mdx | 3 +- .../docs/docs/tutorials/observability.mdx | 4 +- .../docs/zh-cn/docs/build/distribution.mdx | 6 +- .../content/docs/zh-cn/docs/build/hooks.mdx | 6 +- .../content/docs/zh-cn/docs/build/plugins.mdx | 2 +- .../src/content/docs/zh-cn/docs/build/sdk.md | 39 +- .../content/docs/zh-cn/docs/build/tools.mdx | 10 +- .../zh-cn/docs/concepts/tape-and-context.mdx | 6 +- .../docs/getting-started/first-plugin.mdx | 2 +- .../zh-cn/docs/getting-started/install.mdx | 6 +- .../docs/zh-cn/docs/operate/deploy.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 | 3 +- .../zh-cn/docs/tutorials/observability.mdx | 4 +- 85 files changed, 1055 insertions(+), 709 deletions(-) create mode 100644 src/bub/builtin/store.py 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 create mode 100644 tests/test_cli_main.py create mode 100644 tests/test_core_boundary.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a59f1540..94fbf5b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,6 +42,41 @@ jobs: + + core-only-install: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + + - name: Build the wheel + run: uv build --wheel -o dist + + # `pip install bub` without the app extra installs only the agent kernel. + - name: Use bub.core without the app extra + run: | + uv run --isolated --no-project --with dist/bub-*.whl python - <<'EOF' + import importlib.util + from bub.core import Agent + from bub.core.store import InMemoryTapeStore + import bub.errors, bub.core.store, bub.core.tape, bub.core.tools + + Agent(store=InMemoryTapeStore()) + app_modules = ("typer", "pluggy", "pydantic_settings", "yaml", "rapidfuzz", "telegram", "rich") + assert not [m for m in app_modules if importlib.util.find_spec(m)], "app dependencies were installed" + EOF + + - name: The bub command asks for the app extra + run: | + if output=$(uv run --isolated --no-project --with dist/bub-*.whl bub 2>&1); then + echo "bub ran without the app extra"; exit 1 + fi + echo "$output" + grep -q "bub\[app\]" <<<"$output" + check-docs: runs-on: ubuntu-latest steps: diff --git a/AGENTS.md b/AGENTS.md index 32bb991f..498b4382 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,22 +4,24 @@ Core code lives under `src/`: -- `src/bub/__main__.py`: Typer CLI entrypoint. +- `src/bub/__main__.py`: Typer CLI entrypoint; `main` (the `bub` script) checks the `app` extra before importing the CLI. - `src/bub/core/`: agent kernel with no settings or plugin dependencies: agent loop over a tape store (`agent.py`), model protocol and Republic chat model adapter (`model.py`), agent events and their bus (`events.py`), the tape recorder handler (`recorder.py`), system prompt sections (`system_prompt.py`), command prompts (`commands.py`), tools, tape, store, and tape context. - `src/bub/framework.py`: turn orchestration and outbound routing. - `src/bub/hooks/`: hook specifications, execution helpers, and interception contracts. - `src/bub/channels/`: channel bases, routing contracts, admission policy, and lifecycle manager. - `src/bub/{envelope,turn,streaming,errors,model_selection}.py`: small kernel vocabulary owned by each concern. -- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.core.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, and tape services. +- `src/bub/builtin/`: builtin runtime: `BubAgent` (a `bub.core.Agent` subclass adding settings, skills, commands, steering), `BubModel`, tape archiving, CLI wiring, tools, tape services, and the fuzzy-searching file tape store. - `src/bub/channels/`: channel abstractions plus CLI and Telegram adapters. -- `src/bub/skills.py` / `src/bub/tools.py`: skill discovery and tool registry. +- `src/bub/skills.py` / `src/bub/core/tools.py`: skill discovery and tool registry. - `src/skills/`: bundled skills shipped with Bub. Tests live in `tests/`. The website and docs runtime lives in `website/`. +Packaging: `pip install bub` installs only the kernel's dependencies (republic, pydantic, loguru, typing-extensions). Everything outside `bub.core` (framework, CLI, channels, hooks, skills, builtin) needs the `app` extra, and the `bub` script (`bub.__main__:main`) asks for it when it is missing. Add new non-kernel dependencies to `app`, not to `dependencies`. + ## Build, Test, and Development Commands -- `uv sync`: install or update the Python environment; enough to run Bub from source or for deployment-only hosts. +- `uv sync`: install or update the Python environment, including the `app` extra through the `dev` group; enough to run Bub from source. Deployment-only hosts use `uv sync --no-dev --extra app`. - `make install`: full local development bootstrap; sync Python deps, install website deps, and install `prek` hooks. - `uv run bub chat`: run the interactive CLI. - `uv run bub gateway`: start channel listener mode. diff --git a/Dockerfile b/Dockerfile index bf6f90c3..eb274532 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md LICENSE entrypoint.sh ./ # Copy the full source and install COPY src ./src -RUN uv sync --locked --no-dev --no-editable && \ +RUN uv sync --locked --no-dev --no-editable --extra app && \ chmod +x /app/entrypoint.sh WORKDIR /workspace diff --git a/README.md b/README.md index be728b6e..5884dbb2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ See [Getting Started](https://bub.build/docs/getting-started/) for installer pre - **One runtime across surfaces.** The same inbound pipeline runs across CLI, Telegram, and custom channels. Adapters change the surface, not the runtime model. - **Batteries included.** CLI, Telegram, tools, skills, and model execution ship with the core runtime. Use the defaults first, replace them later. - **Operator equivalence.** Humans and agents work inside the same runtime boundaries, with the same evidence trail and handoff model. -- **Embeddable Python SDK.** `bub.core` exposes the agent loop on its own — bring your own model, tools, prompts, and tape store, with no settings or plugins required. +- **Embeddable Python SDK.** `pip install bub` installs only `bub.core`, which exposes the agent loop on its own (the CLI, channels and plugins come with `bub[app]`) — bring your own model, tools, prompts, and tape store, with no settings or plugins required. ## How It Works diff --git a/pyproject.toml b/pyproject.toml index 6235fc2c..a215b902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,21 +20,11 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] +# The agent kernel, bub.core. Everything else (framework, CLI, channels, builtin agent) is in the `app` extra. dependencies = [ - "pydantic>=2.12.5", - "pydantic-settings>=2.14.2", - "pyyaml>=6.0.3", - "pluggy>=1.6.0", - "inquirer-textual>=0.8.0", - "typer>=0.24.1", "republic>=0.6.0a1", - "rich>=14.3.4", - "prompt-toolkit>=3.0.52", - "python-telegram-bot>=22.7", + "pydantic>=2.12.5", "loguru>=0.7.3", - "rapidfuzz>=3.14.5", - "aiohttp>=3.14.3", - "httpx[socks]>=0.28.1", "typing-extensions>=4.15.0", ] @@ -44,21 +34,35 @@ Repository = "https://github.com/bubbuild/bub" Documentation = "https://bub.build" [project.scripts] -bub = "bub.__main__:app" +bub = "bub.__main__:main" [project.optional-dependencies] +app = [ + "pydantic-settings>=2.14.2", + "pyyaml>=6.0.3", + "pluggy>=1.6.0", + "inquirer-textual>=0.8.0", + "typer>=0.24.1", + "rich>=14.3.4", + "prompt-toolkit>=3.0.52", + "python-telegram-bot>=22.7", + "rapidfuzz>=3.14.5", + "aiohttp>=3.14.3", + "httpx[socks]>=0.28.1", +] trace = [ "opentelemetry-api>=1.39.0", "opentelemetry-sdk>=1.39.0", "opentelemetry-exporter-otlp-proto-http>=1.39.0", ] logfire = [ - "bub[trace]", + "bub[app,trace]", "logfire>=4.32.0", ] [dependency-groups] dev = [ + "bub[app]", "pytest>=9.0.3", "tox-uv>=1.35.1", "types-PyYAML>=6.0.12.20260408", diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 28666a72..58dbf274 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,4 +1,8 @@ -"""Bub framework package.""" +"""Bub framework package. + +The top-level names are imported on first access, so ``import bub.core`` does not load +the framework, its settings or the CLI. +""" from __future__ import annotations @@ -7,12 +11,15 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as metadata_version from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from bub.configure import Settings, config, ensure_config -from bub.core.tools import tool -from bub.framework import DEFAULT_HOME, BubFramework -from bub.hooks import hookimpl +if TYPE_CHECKING: + from bub.configure import Settings, config, ensure_config + from bub.core.tools import tool + from bub.framework import BubFramework + from bub.hooks import hookimpl + + home: Path __all__ = [ "BubFramework", @@ -24,6 +31,15 @@ "tool", ] +_EXPORTS = { + "BubFramework": "bub.framework", + "Settings": "bub.configure", + "config": "bub.configure", + "ensure_config": "bub.configure", + "hookimpl": "bub.hooks", + "tool": "bub.core.tools", +} + try: __version__ = import_module("bub._version").version except ModuleNotFoundError: @@ -33,13 +49,14 @@ __version__ = "0.0.0" -if TYPE_CHECKING: - home: Path - - -def __getattr__(name: str): +def __getattr__(name: str) -> Any: if name == "home": + # Importing bub.configure loads ``.env``, which may set ``BUB_HOME``. + from bub.configure import DEFAULT_HOME + if "BUB_HOME" in os.environ: return Path(os.path.expanduser(os.environ["BUB_HOME"])) return DEFAULT_HOME - raise AttributeError(f"module {__name__} has no attribute {name}") + if name not in _EXPORTS: + raise AttributeError(f"module {__name__} has no attribute {name}") + return getattr(import_module(_EXPORTS[name]), name) diff --git a/src/bub/__main__.py b/src/bub/__main__.py index 5494d737..e2942be4 100644 --- a/src/bub/__main__.py +++ b/src/bub/__main__.py @@ -1,14 +1,19 @@ -"""Bub framework CLI bootstrap.""" +"""Bub framework CLI bootstrap. + +The CLI needs the ``app`` extra, so this module imports it only when the command runs: +``main`` reports missing requirements instead of failing on an import. +""" from __future__ import annotations import os +import re import sys +from importlib.metadata import PackageNotFoundError, distribution, requires +from typing import TYPE_CHECKING -import typer - -from bub.builtin.settings import load_settings -from bub.framework import BubFramework +if TYPE_CHECKING: + import typer LOG_LEVEL = { 0: "INFO", @@ -16,6 +21,25 @@ 2: "TRACE", } +_APP_REQUIREMENT = re.compile(r"""^([A-Za-z0-9][A-Za-z0-9._-]*).*;.*\bextra\s*==\s*["']app["']""") + + +def missing_app_requirements() -> list[str]: + """Return the distributions required by ``bub[app]`` that are not installed.""" + try: + requirements = requires("bub") or [] + except PackageNotFoundError: # Running from a source tree that is not installed. + return [] + missing: list[str] = [] + for requirement in requirements: + if (match := _APP_REQUIREMENT.match(requirement)) is None: + continue + try: + distribution(match.group(1)) + except PackageNotFoundError: + missing.append(match.group(1)) + return missing + def _instrument_bub(level: str) -> None: from loguru import logger @@ -43,6 +67,11 @@ def _instrument_bub(level: str) -> None: def create_cli_app() -> typer.Typer: + import typer + + from bub.builtin.settings import load_settings + from bub.framework import BubFramework + framework = BubFramework() _instrument_bub(LOG_LEVEL[load_settings().verbose]) framework.load_hooks() @@ -57,7 +86,16 @@ def _help() -> None: return app -app = create_cli_app() +def main() -> None: + """Run the ``bub`` command.""" + if missing := missing_app_requirements(): + sys.exit( + "The bub command needs the 'app' extra, which is not installed " + f"(missing: {', '.join(missing)}).\n" + "Install it with: uv tool install 'bub[app]' or pip install 'bub[app]'" + ) + create_cli_app()() + if __name__ == "__main__": - app() + main() diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 5ab0058e..3b832fd9 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -309,7 +309,7 @@ def _ensure_project(project: Path) -> None: return _uv("init", "--bare", "--name", "bub-project", "--app", cwd=project) bub_requirement = _build_bub_requirement() - _uv("add", "--active", "--no-sync", *bub_requirement, cwd=project) + _uv("add", "--active", "--no-sync", "--extra", "app", *bub_requirement, cwd=project) def install( diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 77615b0d..cfc302b2 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -295,9 +295,9 @@ def provide_environment(self, session_id: str, workspace: Path) -> Environment: @hookimpl def provide_tape_store(self) -> TapeStore: import bub - from bub.core.store import FileTapeStore + from bub.builtin.store import FuzzyFileTapeStore - return FileTapeStore(directory=bub.home / "tapes") + return FuzzyFileTapeStore(directory=bub.home / "tapes") @hookimpl def provide_tape_sidecar(self) -> TapeSidecar: diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py new file mode 100644 index 00000000..2da18e2c --- /dev/null +++ b/src/bub/builtin/store.py @@ -0,0 +1,87 @@ +"""File tape store with the newest-first, deduplicated and fuzzy text search used by the builtin tools.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import replace + +from bub.core.store import FileTapeStore, TapeQuery +from bub.core.tape import TapeEntry +from bub.utils import get_entry_text + +WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") +MIN_FUZZY_QUERY_LENGTH = 3 +MIN_FUZZY_SCORE = 80 +MAX_FUZZY_CANDIDATES = 128 + + +class FuzzyFileTapeStore(FileTapeStore): + """:class:`~bub.core.store.FileTapeStore` whose text queries also match approximately.""" + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: + if not query._query: + result: Iterable[TapeEntry] = super().fetch_all(query) + return result + unlimited_query = replace(query, _limit=None) + entries: Iterable[TapeEntry] = super().fetch_all(unlimited_query) + return self._filter_entries(list(entries), query._query, query._limit or 20) + + def _filter_entries(self, entries: list[TapeEntry], query: str, limit: int) -> list[TapeEntry]: + normalized_query = query.strip().lower() + if not normalized_query: + return [] + results: list[TapeEntry] = [] + seen: set[str] = set() + + count = 0 + for entry in reversed(entries): + payload_text = get_entry_text(entry).lower() + if payload_text in seen: + continue + seen.add(payload_text) + + if normalized_query in payload_text or self._is_fuzzy_match(normalized_query, payload_text): + results.append(entry) + count += 1 + if count >= limit: + break + return results + + @staticmethod + def _is_fuzzy_match(normalized_query: str, payload_text: str) -> bool: + from rapidfuzz import fuzz, process + + if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH: + return False + + query_tokens = WORD_PATTERN.findall(normalized_query) + if not query_tokens: + return False + query_phrase = " ".join(query_tokens) + window_size = len(query_tokens) + + source_tokens = WORD_PATTERN.findall(payload_text) + if not source_tokens: + return False + + candidates: list[str] = [] + for token in source_tokens: + candidates.append(token) + if len(candidates) >= MAX_FUZZY_CANDIDATES: + break + + if window_size > 1: + max_window_start = len(source_tokens) - window_size + 1 + for idx in range(max(0, max_window_start)): + candidates.append(" ".join(source_tokens[idx : idx + window_size])) + if len(candidates) >= MAX_FUZZY_CANDIDATES: + break + + best_match = process.extractOne( + query_phrase, + candidates, + scorer=fuzz.WRatio, + score_cutoff=MIN_FUZZY_SCORE, + ) + return best_match is not None diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 96a5adac..c4caca2a 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -39,7 +39,7 @@ from bub.channels.contracts import MessageHandler from bub.channels.message import ChannelMessage from bub.core.commands import strip_command_prefix -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus +from bub.core.events import EventBus, ToolCallFinished, ToolCallStarted from bub.core.tape import TapeInfo, session_tape_name from bub.core.tools import REGISTRY from bub.envelope import Envelope, field_of @@ -191,14 +191,14 @@ def __init__(self, renderer: CliRenderer, presenter: TerminalPresenter) -> None: self._presenter = presenter def subscribe(self, events: EventBus) -> None: - events.on(BeforeToolCall, self.on_before_tool_call) - events.on(AfterToolCall, self.on_after_tool_call) + events.on(ToolCallStarted, self.on_before_tool_call) + events.on(ToolCallFinished, self.on_after_tool_call) - async def on_before_tool_call(self, event: BeforeToolCall) -> None: + async def on_before_tool_call(self, event: ToolCallStarted) -> None: call = event.call await self._presenter.write(lambda: self._renderer.tool_call_start(name=call.tool, kwargs=call.arguments)) - async def on_after_tool_call(self, event: AfterToolCall) -> None: + async def on_after_tool_call(self, event: ToolCallFinished) -> None: result = event.result if result.error is None: await self._presenter.write( diff --git a/src/bub/configure.py b/src/bub/configure.py index d6a1b2ac..e44c12e1 100644 --- a/src/bub/configure.py +++ b/src/bub/configure.py @@ -4,8 +4,13 @@ from pathlib import Path from typing import Any +from dotenv import find_dotenv, load_dotenv from pydantic_settings import BaseSettings, PydanticBaseSettingsSource +# Settings read the environment, so load ``.env`` before any of them is created. +load_dotenv(find_dotenv(usecwd=True)) +DEFAULT_HOME = Path.home() / ".bub" + CONFIG_MAP: dict[str, list[type[BaseSettings]]] = {} ROOT = "" MISSING = object() diff --git a/src/bub/core/__init__.py b/src/bub/core/__init__.py index 10b135db..b4d39dea 100644 --- a/src/bub/core/__init__.py +++ b/src/bub/core/__init__.py @@ -7,25 +7,26 @@ from bub.core.agent import Agent, events_from from bub.core.context import default_tape_context from bub.core.events import ( - AfterLlmCall, - AfterToolCall, AgentEvent, - BeforeLlmCall, - BeforeToolCall, - ChatCompleted, + ChatFinished, CommandFinished, EventBus, EventHandler, - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallFinished, + LLMCallRequest, + LLMCallResult, + LLMCallStarted, + RunFinished, RunStarted, StepFinished, StepStarted, TapeEvent, ToolCall, ToolCallDecision, + ToolCallFinished, ToolCallResult, + ToolCallStarted, ) from bub.core.model import ChatModelClient, Model, ModelClient from bub.core.recorder import TapeRecorder @@ -34,24 +35,23 @@ from bub.core.tools import Tool, ToolContext, tool __all__ = [ - "AfterLlmCall", - "AfterToolCall", "Agent", "AgentEvent", - "BeforeLlmCall", - "BeforeToolCall", - "ChatCompleted", + "ChatFinished", "ChatModelClient", "CommandFinished", "EventBus", "EventHandler", - "LlmCallDecision", - "LlmCallRequest", - "LlmCallResult", + "LLMCallDecision", + "LLMCallFinished", + "LLMCallRequest", + "LLMCallResult", + "LLMCallStarted", "Model", "ModelClient", "PromptContext", "PromptSection", + "RunFinished", "RunStarted", "StepFinished", "StepStarted", @@ -63,7 +63,9 @@ "Tool", "ToolCall", "ToolCallDecision", + "ToolCallFinished", "ToolCallResult", + "ToolCallStarted", "ToolContext", "default_tape_context", "events_from", diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py index 318e6e88..78e8502a 100644 --- a/src/bub/core/agent.py +++ b/src/bub/core/agent.py @@ -4,7 +4,7 @@ import sys import time -from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Mapping, Sequence +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AsyncExitStack, aclosing, asynccontextmanager from dataclasses import asdict, replace from datetime import UTC, datetime @@ -19,14 +19,15 @@ from bub.core.commands import parse_command, parse_command_args, strip_command_prefix, validate_command_prefix from bub.core.context import default_tape_context from bub.core.events import ( - AfterLlmCall, AgentEvent, - BeforeLlmCall, - ChatCompleted, + ChatFinished, CommandFinished, EventBus, - LlmCallRequest, - LlmCallResult, + LLMCallFinished, + LLMCallRequest, + LLMCallResult, + LLMCallStarted, + RunFinished, RunStarted, StepFinished, StepStarted, @@ -78,7 +79,7 @@ class Agent: The loop reports its progress as events (:mod:`bub.core.events`) on ``events``; register handlers with ``agent.events.on``. A :class:`TapeRecorder`, registered first, - records them on the run's tape. ``BeforeLlmCall`` and ``BeforeToolCall`` handlers + records them on the run's tape. ``LLMCallStarted`` and ``ToolCallStarted`` handlers may change or short-circuit the call. Subclasses customize a run by overriding :meth:`prepare_tools`, @@ -216,14 +217,11 @@ async def start( with span.activate(): span.messages("gen_ai.input.messages", [republic.user(*prompt)]) if not any(prompt): - events = events_from([ - StreamEvent("text", {"delta": "error: empty prompt"}), - StreamEvent("final", {"text": "error: empty prompt", "ok": False}), - ]) - else: - session = await stack.enter_async_context(self.session(tape, state=state, merge_back=merge_back)) - events = await self.run_on(session, prompt, model=model, options=options, metadata=metadata) - stack.push_async_callback(events.aclose) + return _fixed_stream("error: empty prompt", ok=False, span=span, on_close=stack.aclose) + session = await stack.enter_async_context(self.session(tape, state=state, merge_back=merge_back)) + return await self.run_on( + session, prompt, model=model, options=options, metadata=metadata, span=span, on_close=stack.aclose + ) except BaseException as exc: span.fail(exc) try: @@ -232,7 +230,6 @@ async def start( finally: span.end() raise - return AsyncStreamEvents(_trace_events(events, span), state=events._state, on_close=stack.aclose, span=span) async def run_on( self, @@ -242,174 +239,63 @@ async def run_on( model: Model | None = None, options: Mapping[str, Any] | None = None, metadata: dict[str, Any] | None = None, + span: Span | None = None, + on_close: Callable[[], Awaitable[None]] | None = None, ) -> AsyncStreamEvents: """Start a run on an already open tape; the caller owns the tape's lifecycle. A command prompt (see :meth:`command_line`) runs as a command instead, without calling the model. + ``span`` traces the run's stream, and ``on_close`` runs after the stream closes. """ if (line := self.command_line(prompt)) is not None: - result = await self.run_command(tape, line) - return events_from([ - StreamEvent("text", {"delta": result}), - StreamEvent("final", {"text": result, "ok": True}), - ]) + return _fixed_stream(await self.run_command(tape, line), ok=True, span=span, on_close=on_close) client = self._model_client(model) + started = time.monotonic() await self._emit(RunStarted, tape, model=client.spec, prompt=prompt, metadata=dict(metadata or {})) state = StreamState() - return AsyncStreamEvents(self._run_steps(tape, client, dict(options or {}), prompt, state), state=state) + tracker = _RunTracker(span) - def run_once( + async def close() -> None: + try: + elapsed_ms = int((time.monotonic() - started) * 1000) + await self._emit( + RunFinished, + tape, + status=tracker.status, + steps=tracker.steps, + elapsed_ms=elapsed_ms, + error=tracker.error, + ) + finally: + if on_close is not None: + await on_close() + + return AsyncStreamEvents( + self._run_steps(tape, client, dict(options or {}), prompt, state, tracker), + state=state, + on_close=close, + span=span, + ) + + async def run_once( self, prompt: list[UserContent] | None, *, model: Model | None = None, options: Mapping[str, Any] | None = None, tape: Tape, - ) -> AsyncStreamEvents: + state: StreamState | None = None, + ) -> AsyncGenerator[StreamEvent, None]: """Make one call to ``model``, or the agent's model, on an open tape and execute the tool calls it returns. ``prompt`` is the new user message; ``None`` continues the existing conversation. - """ - state = StreamState() - return AsyncStreamEvents( - self._run_once(tape, self._model_client(model), dict(options or {}), prompt, state), state=state - ) - - def _model_client(self, model: Model | None) -> ModelClient: - if model is None: - model = self.model - if model is None: - raise ValueError("no model to run: pass model to the run or to the agent") - return model_client(model) - - def command_line(self, prompt: list[UserContent]) -> str | None: - """Return the command of a text prompt starting with the command prefix, or None.""" - if self.command_prefix is None or len(prompt) != 1 or not isinstance(prompt[0], str): - return None - return strip_command_prefix(prompt[0], self.command_prefix) - - async def run_command(self, tape: Tape, line: str) -> str: - """Run a command on ``tape`` and return its rendered output; record it as a ``command`` event. - ``name args...`` calls the tool ``name`` with positional and then ``key=value`` - arguments, positional ones taking the tool's parameters in order. Any other line runs - through the ``bash`` tool when the agent has one. Commands call tools without model - calls, emitting tool events; the outcome is reported as a ``CommandFinished`` event. + ``state``, when given, receives the call's usage. """ - if not line: - raise ValueError("empty command") - name, tokens = parse_command(line) - start = time.monotonic() - context = ToolContext( - tape=tape, run_id="run_command", state=tape.context.state, command=True, events=self.events - ) - output = "" - status = "ok" - try: - if name not in self.tools: - if "bash" not in self.tools: - raise ValueError("bash tool is not available") # noqa: TRY301 - command_tool = self.tools["bash"] - arguments = {"command": line} - else: - command_tool = self.tools[name] - arguments = parse_command_args(tokens).bind(list(command_tool.parameters.get("properties", {}))) - result = await ToolExecutor(self.events, render=False).execute(command_tool, arguments, context=context) - output = command_tool.render(result) - except Exception as exc: - status = "error" - output = f"{exc!s}" - raise - else: - return output - finally: - elapsed_ms = int((time.monotonic() - start) * 1000) - await self._emit( - CommandFinished, tape, line=line, name=name, status=status, elapsed_ms=elapsed_ms, output=output - ) - - async def prepare_tools(self, tape: Tape, prompt: list[UserContent] | None) -> list[Tool]: - """Return the model-facing tools for the next model call.""" - return model_tools(self.tools.values()) - - async def pending_messages(self, tape: Tape) -> list[list[UserContent]]: - """Return user messages that arrived during the run, sent before the next prompt.""" - return [] - - def has_pending_messages(self, tape: Tape) -> bool: - """Return whether messages are waiting, which continues the run for another step.""" - return False - - async def continue_prompt( - self, tape: Tape, prompt: list[UserContent] | None, state: StreamState - ) -> list[UserContent] | None: - """Return a user message for the next step, or None to continue the conversation as is.""" - return None - - async def _emit(self, event_type: type[T], tape: Tape, **fields: Any) -> T: - return await self.events.emit(event_type(state=tape.context.state, tape=tape, **fields)) - - async def _run_steps( - self, tape: Tape, client: ModelClient, options: dict[str, Any], prompt: list[UserContent], state: StreamState - ) -> AsyncGenerator[StreamEvent, None]: - auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES - next_prompt: list[UserContent] | None = prompt - for step in range(1, self.max_steps + 1): - start = time.monotonic() - should_continue = False - logger.info("loop.step step={} tape={} model={}", step, tape.name, client.spec) - await self._emit(StepStarted, tape, step=step, prompt=next_prompt) - try: - output = self.run_once(next_prompt, model=client, options=options, tape=tape) - async with aclosing(output): - async for event in output: - yield event - if event.kind == "error": - await self._record_step(tape, step, start, "error", error=event.data.get("message", "")) - elif event.kind == "final": - should_continue = bool(event.data.get("tool_calls") or event.data.get("tool_results")) - except Exception as exc: - error_message = f"{exc!s}" - if auto_handoff_remaining > 0 and is_context_length_error(error_message): - auto_handoff_remaining -= 1 - logger.warning( - "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}", - tape.name, - step, - ) - await tape.handoff( - name="auto_handoff/context_overflow", - state={"reason": "context_length_exceeded", "error": error_message}, - ) - await self._record_step(tape, step, start, "auto_handoff", error=error_message) - next_prompt = prompt - continue - await self._record_step(tape, step, start, "error", error=error_message) - raise - - state.error = output.error - state.usage = output.usage - if not (should_continue or self.has_pending_messages(tape)): - await self._record_step(tape, step, start, "ok") - return - - next_prompt = await self.continue_prompt(tape, next_prompt, state) - await self._record_step(tape, step, start, "continue") - - raise RuntimeError(f"max_steps_reached={self.max_steps}") - - async def _record_step(self, tape: Tape, step: int, start: float, status: str, *, error: str | None = None) -> None: - elapsed_ms = int((time.monotonic() - start) * 1000) - await self._emit(StepFinished, tape, step=step, status=status, elapsed_ms=elapsed_ms, error=error) - - async def _run_once( - self, - tape: Tape, - client: ModelClient, - options: dict[str, Any], - prompt: list[UserContent] | None, - state: StreamState, - ) -> AsyncGenerator[StreamEvent, None]: + client = self._model_client(model) + options = dict(options or {}) + if state is None: + state = StreamState() turn_state = tape.context.state tools = await self.prepare_tools(tape, prompt) prompt_context = PromptContext(agent=self, tape=tape, prompt=prompt or [], tools=tools, model=client.spec) @@ -422,18 +308,18 @@ async def _run_once( messages, new_messages = await self._build_messages( tape, model=client.spec, run_id=run_id, system_prompt=system_prompt, prompt=prompt, pending=pending ) - request = LlmCallRequest( + request = LLMCallRequest( run_id=run_id, model=client.spec, messages=messages, tool_names=tuple(tool_item.name for tool_item in tools), max_tokens=options.get("max_tokens", client.max_tokens), ) - before = await self._emit(BeforeLlmCall, tape, request=request) + before = await self._emit(LLMCallStarted, tape, request=request) request, decision = before.request, before.decision if decision is not None: await self._emit( - ChatCompleted, + ChatFinished, tape, run_id=run_id, model=request.model, @@ -485,7 +371,7 @@ async def _run_once( if value } await self._emit( - ChatCompleted, + ChatFinished, tape, run_id=run_id, model=request.model, @@ -504,6 +390,146 @@ async def _run_once( else: yield StreamEvent("final", {"ok": True, "text": text}) + def _model_client(self, model: Model | None) -> ModelClient: + if model is None: + model = self.model + if model is None: + raise ValueError("no model to run: pass model to the run or to the agent") + return model_client(model) + + def command_line(self, prompt: list[UserContent]) -> str | None: + """Return the command of a text prompt starting with the command prefix, or None.""" + if self.command_prefix is None or len(prompt) != 1 or not isinstance(prompt[0], str): + return None + return strip_command_prefix(prompt[0], self.command_prefix) + + async def run_command(self, tape: Tape, line: str) -> str: + """Run a command on ``tape`` and return its rendered output; record it as a ``command`` event. + + ``name args...`` calls the tool ``name`` with positional and then ``key=value`` + arguments, positional ones taking the tool's parameters in order. Any other line runs + through the ``bash`` tool when the agent has one. Commands call tools without model + calls, emitting tool events; the outcome is reported as a ``CommandFinished`` event. + """ + if not line: + raise ValueError("empty command") + name, tokens = parse_command(line) + start = time.monotonic() + context = ToolContext( + tape=tape, run_id="run_command", state=tape.context.state, command=True, events=self.events + ) + output = "" + status = "ok" + try: + if name not in self.tools: + if "bash" not in self.tools: + raise ValueError("bash tool is not available") # noqa: TRY301 + command_tool = self.tools["bash"] + arguments = {"command": line} + else: + command_tool = self.tools[name] + arguments = parse_command_args(tokens).bind(list(command_tool.parameters.get("properties", {}))) + result = await ToolExecutor(self.events, render=False).execute(command_tool, arguments, context=context) + output = command_tool.render(result) + except Exception as exc: + status = "error" + output = f"{exc!s}" + raise + else: + return output + finally: + elapsed_ms = int((time.monotonic() - start) * 1000) + await self._emit( + CommandFinished, tape, line=line, name=name, status=status, elapsed_ms=elapsed_ms, output=output + ) + + async def prepare_tools(self, tape: Tape, prompt: list[UserContent] | None) -> list[Tool]: + """Return the model-facing tools for the next model call.""" + return model_tools(self.tools.values()) + + async def pending_messages(self, tape: Tape) -> list[list[UserContent]]: + """Return user messages that arrived during the run, sent before the next prompt.""" + return [] + + def has_pending_messages(self, tape: Tape) -> bool: + """Return whether messages are waiting, which continues the run for another step.""" + return False + + async def continue_prompt( + self, tape: Tape, prompt: list[UserContent] | None, state: StreamState + ) -> list[UserContent] | None: + """Return a user message for the next step, or None to continue the conversation as is.""" + return None + + async def _emit(self, event_type: type[T], tape: Tape, **fields: Any) -> T: + return await self.events.emit(event_type(state=tape.context.state, tape=tape, **fields)) + + async def _run_steps( + self, + tape: Tape, + client: ModelClient, + options: dict[str, Any], + prompt: list[UserContent], + state: StreamState, + tracker: _RunTracker, + ) -> AsyncGenerator[StreamEvent, None]: + auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES + next_prompt: list[UserContent] | None = prompt + try: + for step in range(1, self.max_steps + 1): + tracker.steps = step + start = time.monotonic() + should_continue = False + logger.info("loop.step step={} tape={} model={}", step, tape.name, client.spec) + await self._emit(StepStarted, tape, step=step, prompt=next_prompt) + try: + output = self.run_once(next_prompt, model=client, options=options, tape=tape, state=state) + async with aclosing(output): + async for event in output: + tracker.observe(event) + yield event + if event.kind == "error": + await self._record_step(tape, step, start, "error", error=event.data.get("message", "")) + elif event.kind == "final": + should_continue = bool(event.data.get("tool_calls") or event.data.get("tool_results")) + except Exception as exc: + error_message = f"{exc!s}" + if auto_handoff_remaining > 0 and is_context_length_error(error_message): + auto_handoff_remaining -= 1 + logger.warning( + "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}", + tape.name, + step, + ) + await tape.handoff( + name="auto_handoff/context_overflow", + state={"reason": "context_length_exceeded", "error": error_message}, + ) + await self._record_step(tape, step, start, "auto_handoff", error=error_message) + next_prompt = prompt + continue + await self._record_step(tape, step, start, "error", error=error_message) + raise + + if not (should_continue or self.has_pending_messages(tape)): + await self._record_step(tape, step, start, "ok") + tracker.status = "ok" + return + + next_prompt = await self.continue_prompt(tape, next_prompt, state) + await self._record_step(tape, step, start, "continue") + + raise RuntimeError(f"max_steps_reached={self.max_steps}") # noqa: TRY301 + except Exception as exc: + tracker.status, tracker.error = "error", f"{exc!s}" + raise + finally: + tracker.end_trace() + + async def _record_step(self, tape: Tape, step: int, start: float, status: str, *, error: str | None = None) -> None: + elapsed_ms = int((time.monotonic() - start) * 1000) + await self._emit(StepFinished, tape, step=step, status=status, elapsed_ms=elapsed_ms, error=error) + async def _build_messages( self, tape: Tape, @@ -518,7 +544,7 @@ async def _build_messages( messages = await tape.read_messages() except BubError as exc: await self._emit( - ChatCompleted, + ChatFinished, tape, run_id=run_id, model=model, @@ -542,7 +568,7 @@ async def _traced_completion( tape: Tape, client: ModelClient, options: dict[str, Any], - request: LlmCallRequest, + request: LLMCallRequest, tools: list[Tool], state: StreamState, ) -> AsyncGenerator[tuple[str, republic.Stream[Any], AsyncIterator[StreamEvent]]]: @@ -587,11 +613,11 @@ async def _traced_completion( except BaseException as exc: span.fail(exc) if isinstance(exc, Exception): - await self._fire_after_llm_call(tape, request, "".join(text), calls, state, started, error=exc) + await self._fire_llm_call_finished(tape, request, "".join(text), calls, state, started, error=exc) raise else: completed = stream.response - await self._fire_after_llm_call( + await self._fire_llm_call_finished( tape, request, completed.text or completed.refusal or "", completed.tool_calls, state, started ) finally: @@ -606,10 +632,10 @@ async def _traced_completion( ) span.end() - async def _fire_after_llm_call( + async def _fire_llm_call_finished( self, tape: Tape, - request: LlmCallRequest, + request: LLMCallRequest, text: str, tool_calls: list[republic.ToolCall], state: StreamState, @@ -617,7 +643,7 @@ async def _fire_after_llm_call( error: Exception | None = None, ) -> None: duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) - result = LlmCallResult( + result = LLMCallResult( run_id=request.run_id, text=text or None, tool_calls=[asdict(call) for call in tool_calls], @@ -625,7 +651,7 @@ async def _fire_after_llm_call( error=error, duration_ms=duration_ms, ) - await self._emit(AfterLlmCall, tape, request=request, result=result) + await self._emit(LLMCallFinished, tape, request=request, result=result) def events_from(items: Iterable[StreamEvent]) -> AsyncStreamEvents: @@ -638,30 +664,53 @@ async def generator() -> AsyncIterator[StreamEvent]: return AsyncStreamEvents(generator()) -async def _trace_events(events: AsyncStreamEvents, span: Span) -> AsyncGenerator[StreamEvent, None]: - messages: list[dict[str, Any]] = [] - text: list[str] = [] - calls: list[dict[str, Any]] = [] - try: - async with aclosing(events): - async for event in events: - if span.recording: - if event.kind == "text": - text.append(str(event.data.get("delta", ""))) - elif event.kind == "tool_call": - calls = event.data.get("tool_calls", []) - messages.append({"role": "assistant", "content": "".join(text), "tool_calls": calls}) - text.clear() - elif event.kind == "tool_result": - for call, result in zip(calls, event.data.get("tool_results", []), strict=False): - messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) - elif event.kind == "error": - span.fail(RuntimeError(str(event.data.get("message", "agent failed")))) - yield event - finally: - if text: - messages.append({"role": "assistant", "content": "".join(text)}) - span.messages("gen_ai.output.messages", messages) +def _fixed_stream( + text: str, *, ok: bool, span: Span | None, on_close: Callable[[], Awaitable[None]] | None +) -> AsyncStreamEvents: + """Stream a run that answers ``text`` without calling the model, such as a command.""" + if span is not None: + span.messages("gen_ai.output.messages", [{"role": "assistant", "content": text}]) + + async def generator() -> AsyncIterator[StreamEvent]: + yield StreamEvent("text", {"delta": text}) + yield StreamEvent("final", {"text": text, "ok": ok}) + + return AsyncStreamEvents(generator(), on_close=on_close, span=span) + + +class _RunTracker: + """How a run ended, for ``RunFinished``, and its output messages, for its ``invoke_agent`` span.""" + + def __init__(self, span: Span | None) -> None: + self.status = "cancelled" + self.steps = 0 + self.error: str | None = None + self._span = span + self._messages: list[dict[str, Any]] = [] + self._text: list[str] = [] + self._calls: list[dict[str, Any]] = [] + + def observe(self, event: StreamEvent) -> None: + if self._span is None or not self._span.recording: + return + if event.kind == "text": + self._text.append(str(event.data.get("delta", ""))) + elif event.kind == "tool_call": + self._calls = event.data.get("tool_calls", []) + self._messages.append({"role": "assistant", "content": "".join(self._text), "tool_calls": self._calls}) + self._text.clear() + elif event.kind == "tool_result": + for call, result in zip(self._calls, event.data.get("tool_results", []), strict=False): + self._messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) + elif event.kind == "error": + self._span.fail(RuntimeError(str(event.data.get("message", "agent failed")))) + + def end_trace(self) -> None: + if self._span is None: + return + if self._text: + self._messages.append({"role": "assistant", "content": "".join(self._text)}) + self._span.messages("gen_ai.output.messages", self._messages) def generate_run_id() -> str: @@ -707,7 +756,7 @@ def _tool_invocation(tool_call: republic.ToolCall, tool_map: dict[str, Tool]) -> An unknown tool name is not treated as a fatal error: it is surfaced as a placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and - ``BeforeToolCall`` handlers can recover it into a guidance + ``ToolCallStarted`` handlers can recover it into a guidance ``tool_result`` instead of interrupting the turn. If no hook replaces the call, the placeholder raises a clear tool error rather than succeeding with an empty result. diff --git a/src/bub/core/events.py b/src/bub/core/events.py index 7eafebcb..b4a6cb38 100644 --- a/src/bub/core/events.py +++ b/src/bub/core/events.py @@ -20,7 +20,7 @@ @dataclass(frozen=True) -class LlmCallRequest: +class LLMCallRequest: """Outgoing agent-loop LLM request exposed to interception hooks. Hooks may return a modified copy (``dataclasses.replace``) to change the @@ -36,7 +36,7 @@ class LlmCallRequest: @dataclass(frozen=True) -class LlmCallResult: +class LLMCallResult: """Terminal outcome of one LLM call exposed to interception hooks. For streaming completions this is the fully accumulated final state, not @@ -54,14 +54,14 @@ class LlmCallResult: @dataclass(frozen=True) -class LlmCallDecision: +class LLMCallDecision: """Short-circuit verdict returned by a ``before_llm_call`` hook.""" action: Literal["finish"] = "finish" text: str = "" @classmethod - def finish(cls, text: str) -> LlmCallDecision: + def finish(cls, text: str) -> LLMCallDecision: """Skip the provider call and emit ``text`` as its final output.""" return cls(action="finish", text=text) @@ -152,6 +152,20 @@ class RunStarted(TapeEvent): metadata: dict[str, Any] = field(default_factory=dict) +@dataclass(kw_only=True) +class RunFinished(TapeEvent): + """A run ended after ``steps`` model steps, with ``status`` ``ok``, ``error`` or ``cancelled``. + + Every ``RunStarted`` is followed by one ``RunFinished`` when the run's stream closes: + ``cancelled`` when it closes before the run ends, including before it is iterated. + """ + + status: str + steps: int + elapsed_ms: int + error: str | None = None + + @dataclass(kw_only=True) class StepStarted(TapeEvent): """A model step began; ``prompt`` is None when the step continues the conversation.""" @@ -186,15 +200,15 @@ class CommandFinished(TapeEvent): @dataclass(kw_only=True) -class BeforeLlmCall(TapeEvent): +class LLMCallStarted(TapeEvent): """An LLM call is about to be sent. Handlers may replace ``request`` (``dataclasses.replace``) or set ``decision`` to finish without calling the model; setting a decision stops delivery. """ - request: LlmCallRequest - decision: LlmCallDecision | None = None + request: LLMCallRequest + decision: LLMCallDecision | None = None @property def stopped(self) -> bool: @@ -202,15 +216,15 @@ def stopped(self) -> bool: @dataclass(kw_only=True) -class AfterLlmCall(TapeEvent): +class LLMCallFinished(TapeEvent): """An LLM call completed or failed with an ``Exception``; cancellation is not observed.""" - request: LlmCallRequest - result: LlmCallResult + request: LLMCallRequest + result: LLMCallResult @dataclass(kw_only=True) -class BeforeToolCall(AgentEvent): +class ToolCallStarted(AgentEvent): """A tool call is about to run. Handlers may replace ``call`` to change its arguments, or set ``decision`` to @@ -226,7 +240,7 @@ def stopped(self) -> bool: @dataclass(kw_only=True) -class AfterToolCall(AgentEvent): +class ToolCallFinished(AgentEvent): """A tool call ended (success, failure, denial or replacement); assign ``result.result`` to change it.""" call: ToolCall @@ -234,7 +248,7 @@ class AfterToolCall(AgentEvent): @dataclass(kw_only=True) -class ChatCompleted(TapeEvent): +class ChatFinished(TapeEvent): """One model step's exchange is complete and ready to be recorded. Also emitted when the step fails before calling the model (``context_error``) or a @@ -281,8 +295,8 @@ def on(self, event_type: type[E], handler: EventHandler[E] | None = None) -> Any Without ``handler``, return a decorator registering the decorated function:: - @events.on(BeforeToolCall) - def guard(event: BeforeToolCall) -> None: ... + @events.on(ToolCallStarted) + def guard(event: ToolCallStarted) -> None: ... """ if handler is None: return lambda handler: self.on(event_type, handler) diff --git a/src/bub/core/model.py b/src/bub/core/model.py index 6034af9d..2ec1d48a 100644 --- a/src/bub/core/model.py +++ b/src/bub/core/model.py @@ -36,7 +36,7 @@ def open_stream( ) -> AbstractAsyncContextManager[tuple[str, republic.Stream[Any]]]: """Open a streaming completion for ``spec``; yield the provider name and the stream. - ``spec`` is the default spec unless a ``BeforeLlmCall`` handler redirected the call; + ``spec`` is the default spec unless a ``LLMCallStarted`` handler redirected the call; ``options`` are the call's ``republic.ChatOptions``, tools included. """ ... diff --git a/src/bub/core/recorder.py b/src/bub/core/recorder.py index fc3e48ca..d2446841 100644 --- a/src/bub/core/recorder.py +++ b/src/bub/core/recorder.py @@ -7,7 +7,15 @@ import republic -from bub.core.events import ChatCompleted, CommandFinished, EventBus, RunStarted, StepFinished, StepStarted +from bub.core.events import ( + ChatFinished, + CommandFinished, + EventBus, + RunFinished, + RunStarted, + StepFinished, + StepStarted, +) from bub.prompt import UserContent @@ -20,9 +28,10 @@ class TapeRecorder: def subscribe(self, events: EventBus) -> None: events.on(RunStarted, self.on_run_started) + events.on(RunFinished, self.on_run_finished) events.on(StepStarted, self.on_step_started) events.on(StepFinished, self.on_step_finished) - events.on(ChatCompleted, self.on_chat_completed) + events.on(ChatFinished, self.on_chat_finished) events.on(CommandFinished, self.on_command_finished) async def on_run_started(self, event: RunStarted) -> None: @@ -30,6 +39,13 @@ async def on_run_started(self, event: RunStarted) -> None: "loop.start", {"model": event.model, "prompt": prompt_payload(event.prompt), **event.metadata} ) + async def on_run_finished(self, event: RunFinished) -> None: + payload: dict[str, Any] = {"status": event.status, "steps": event.steps, "elapsed_ms": event.elapsed_ms} + if event.error is not None: + payload["error"] = event.error + payload["date"] = datetime.now(UTC).isoformat() + await event.tape.append_event("loop.finish", payload) + async def on_step_started(self, event: StepStarted) -> None: prompt = None if event.prompt is None else prompt_payload(event.prompt) await event.tape.append_event("loop.step.start", {"step": event.step, "prompt": prompt}) @@ -54,7 +70,7 @@ async def on_command_finished(self, event: CommandFinished) -> None: }, ) - async def on_chat_completed(self, event: ChatCompleted) -> None: + async def on_chat_finished(self, event: ChatFinished) -> None: await event.tape.record_chat( run_id=event.run_id, system_prompt=event.system_prompt, diff --git a/src/bub/core/store.py b/src/bub/core/store.py index 2b824929..51604ed7 100644 --- a/src/bub/core/store.py +++ b/src/bub/core/store.py @@ -4,7 +4,6 @@ import inspect import itertools import json -import re import threading from collections.abc import Coroutine, Iterable, Sequence from dataclasses import asdict, dataclass, field, replace @@ -18,12 +17,6 @@ from bub.core.tape import TapeEntry from bub.errors import BubError, ErrorKind -from bub.utils import get_entry_text - -WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") -MIN_FUZZY_QUERY_LENGTH = 3 -MIN_FUZZY_SCORE = 80 -MAX_FUZZY_CANDIDATES = 128 class TapeStore(Protocol): @@ -386,73 +379,6 @@ def __init__(self, directory: Path) -> None: self._directory.mkdir(parents=True, exist_ok=True) self._tape_files: dict[str, TapeFile] = {} - def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: - if not query._query: - result: Iterable[TapeEntry] = super().fetch_all(query) - return result - unlimited_query = replace(query, _limit=None) - entries: Iterable[TapeEntry] = super().fetch_all(unlimited_query) - return self._filter_entries(list(entries), query._query, query._limit or 20) - - def _filter_entries(self, entries: list[TapeEntry], query: str, limit: int) -> list[TapeEntry]: - normalized_query = query.strip().lower() - if not normalized_query: - return [] - results: list[TapeEntry] = [] - seen: set[str] = set() - - count = 0 - for entry in reversed(entries): - payload_text = get_entry_text(entry).lower() - if payload_text in seen: - continue - seen.add(payload_text) - - if normalized_query in payload_text or self._is_fuzzy_match(normalized_query, payload_text): - results.append(entry) - count += 1 - if count >= limit: - break - return results - - @staticmethod - def _is_fuzzy_match(normalized_query: str, payload_text: str) -> bool: - from rapidfuzz import fuzz, process - - if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH: - return False - - query_tokens = WORD_PATTERN.findall(normalized_query) - if not query_tokens: - return False - query_phrase = " ".join(query_tokens) - window_size = len(query_tokens) - - source_tokens = WORD_PATTERN.findall(payload_text) - if not source_tokens: - return False - - candidates: list[str] = [] - for token in source_tokens: - candidates.append(token) - if len(candidates) >= MAX_FUZZY_CANDIDATES: - break - - if window_size > 1: - max_window_start = len(source_tokens) - window_size + 1 - for idx in range(max(0, max_window_start)): - candidates.append(" ".join(source_tokens[idx : idx + window_size])) - if len(candidates) >= MAX_FUZZY_CANDIDATES: - break - - best_match = process.extractOne( - query_phrase, - candidates, - scorer=fuzz.WRatio, - score_cutoff=MIN_FUZZY_SCORE, - ) - return best_match is not None - def _tape_file(self, tape: str) -> TapeFile: if tape not in self._tape_files: self._tape_files[tape] = TapeFile(self._directory / f"{tape}.jsonl") diff --git a/src/bub/core/tools.py b/src/bub/core/tools.py index 93954e29..10fdbe9a 100644 --- a/src/bub/core/tools.py +++ b/src/bub/core/tools.py @@ -12,7 +12,7 @@ from loguru import logger from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus, ToolCall, ToolCallResult +from bub.core.events import EventBus, ToolCall, ToolCallFinished, ToolCallResult, ToolCallStarted from bub.core.tape import Tape from bub.errors import BubError, ErrorKind from bub.prompt import UserContent @@ -285,7 +285,7 @@ class ToolExecutor: With ``render`` (the default) successful results are rendered to model-facing text with ``Tool.render``; ``render=False`` returns tools' structured results unchanged, as needed - when the caller is code rather than the model. ``BeforeToolCall`` and ``AfterToolCall`` + when the caller is code rather than the model. ``ToolCallStarted`` and ``ToolCallFinished`` events are emitted on ``events``. """ @@ -467,11 +467,11 @@ async def _apply_before_tool_call( hook_state: dict[str, Any], started: float, ) -> tuple[ToolCall, Callable[[], Any] | None]: - """Emit BeforeToolCall and translate deny/replace into a short-circuit thunk.""" + """Emit ToolCallStarted and translate deny/replace into a short-circuit thunk.""" if self._events is None: return call, None - before = await self._events.emit(BeforeToolCall(state=hook_state, call=call)) + before = await self._events.emit(ToolCallStarted(state=hook_state, call=call)) call, decision = before.call, before.decision if decision.action == "deny": error = BubError( @@ -504,7 +504,7 @@ async def _fire_after_tool_call( duration_ms=duration_ms, ) if self._events is not None: - await self._events.emit(AfterToolCall(state=state, call=call, result=outcome)) + await self._events.emit(ToolCallFinished(state=state, call=call, result=outcome)) return outcome diff --git a/src/bub/framework.py b/src/bub/framework.py index e9300eab..4b6cbc40 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -11,12 +11,12 @@ import pluggy import typer -from dotenv import find_dotenv, load_dotenv from loguru import logger from bub import configure from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import ChannelRouter, MessageHandler +from bub.configure import DEFAULT_HOME from bub.core.sidecars import TapeSidecar from bub.core.store import AsyncTapeStore, TapeStore from bub.core.tape import Tape, TapeContext @@ -35,8 +35,6 @@ if TYPE_CHECKING: from bub.channels.base import Channel -load_dotenv(find_dotenv(usecwd=True)) -DEFAULT_HOME = Path.home() / ".bub" DEFAULT_CONFIG_FILE = (DEFAULT_HOME / "config.yml").resolve() diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index a16c9c74..bcc56c37 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -8,25 +8,25 @@ from loguru import logger from bub.core.events import ( - AfterLlmCall, - AfterToolCall, - BeforeLlmCall, - BeforeToolCall, EventBus, - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallFinished, + LLMCallRequest, + LLMCallResult, + LLMCallStarted, ToolCall, ToolCallDecision, + ToolCallFinished, ToolCallResult, + ToolCallStarted, ) from bub.hooks.runtime import _SKIP_VALUE, HookRuntime from bub.turn import TurnState __all__ = [ - "LlmCallDecision", - "LlmCallRequest", - "LlmCallResult", + "LLMCallDecision", + "LLMCallRequest", + "LLMCallResult", "PluginAgentHooks", "ToolCall", "ToolCallDecision", @@ -48,47 +48,47 @@ def __init__(self, runtime: HookRuntime) -> None: def subscribe(self, events: EventBus | None = None) -> EventBus: """Handle an agent's ``Before/After*Call`` events with the plugin hooks; return the bus.""" events = EventBus() if events is None else events - events.on(BeforeLlmCall, self._on_before_llm_call) - events.on(AfterLlmCall, self._on_after_llm_call) - events.on(BeforeToolCall, self._on_before_tool_call) - events.on(AfterToolCall, self._on_after_tool_call) + events.on(LLMCallStarted, self._on_llm_call_started) + events.on(LLMCallFinished, self._on_llm_call_finished) + events.on(ToolCallStarted, self._on_tool_call_started) + events.on(ToolCallFinished, self._on_tool_call_finished) return events - async def _on_before_llm_call(self, event: BeforeLlmCall) -> None: + async def _on_llm_call_started(self, event: LLMCallStarted) -> None: event.request, event.decision = await self.before_llm_call(event.request, event.state) - async def _on_after_llm_call(self, event: AfterLlmCall) -> None: + async def _on_llm_call_finished(self, event: LLMCallFinished) -> None: await self.after_llm_call(event.request, event.result, event.state) - async def _on_before_tool_call(self, event: BeforeToolCall) -> None: + async def _on_tool_call_started(self, event: ToolCallStarted) -> None: event.call, event.decision = await self.before_tool_call(event.call, event.state) - async def _on_after_tool_call(self, event: AfterToolCall) -> None: + async def _on_tool_call_finished(self, event: ToolCallFinished) -> None: await self.after_tool_call(event.call, event.result, event.state) async def before_llm_call( - self, request: LlmCallRequest, state: TurnState - ) -> tuple[LlmCallRequest, LlmCallDecision | None]: + self, request: LLMCallRequest, state: TurnState + ) -> tuple[LLMCallRequest, LLMCallDecision | None]: """Chain hooks so each implementation sees the previous request.""" for impl in self._runtime._iter_hookimpls("before_llm_call"): value = await self._safe_call_one("before_llm_call", impl, {"request": request, "state": state}) if value is None or value is _SKIP_VALUE: continue - if isinstance(value, LlmCallRequest): + if isinstance(value, LLMCallRequest): request = value - elif isinstance(value, LlmCallDecision): + elif isinstance(value, LLMCallDecision): return request, value else: self._warn_bad_return( - "before_llm_call", impl, value, expected="LlmCallRequest | LlmCallDecision | None" + "before_llm_call", impl, value, expected="LLMCallRequest | LLMCallDecision | None" ) return request, None async def after_llm_call( self, - request: LlmCallRequest, - result: LlmCallResult, + request: LLMCallRequest, + result: LLMCallResult, state: TurnState, ) -> None: """Notify every observer; return values are ignored.""" diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index 50f1619c..af4f3b58 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -11,9 +11,9 @@ from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import MessageHandler from bub.core.events import ( - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallRequest, + LLMCallResult, ToolCall, ToolCallDecision, ToolCallResult, @@ -148,20 +148,20 @@ def on_error(self, stage: str, error: Exception, message: Envelope | None) -> No """Observe framework errors from any stage.""" @hookspec - def before_llm_call(self, request: LlmCallRequest, state: TurnState) -> LlmCallRequest | LlmCallDecision | None: + def before_llm_call(self, request: LLMCallRequest, state: TurnState) -> LLMCallRequest | LLMCallDecision | None: """Observe, modify or short-circuit an outgoing agent-loop LLM request. Implementations are chained in pluggy's LIFO order (last registered runs first): each receives the request as modified by earlier implementations, and may return a modified copy (``dataclasses.replace``), ``None`` to leave it - unchanged, or ``LlmCallDecision.finish(text)`` to skip the provider + unchanged, or ``LLMCallDecision.finish(text)`` to skip the provider call and emit ``text`` as the final output (cost guards / call limits). Exceptions are logged and skipped, never fatal. """ @hookspec - def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None: + def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: TurnState) -> None: """Observe the terminal outcome of one agent-loop LLM call. Fires exactly once per completed call — success (for streaming diff --git a/src/bub/sidecars.py b/src/bub/sidecars.py deleted file mode 100644 index 88e59846..00000000 --- a/src/bub/sidecars.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.sidecars`.""" - -import sys - -from bub.core import sidecars as _module -from bub.core.sidecars import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/src/bub/store.py b/src/bub/store.py deleted file mode 100644 index 3fd8aeca..00000000 --- a/src/bub/store.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.store`.""" - -import sys - -from bub.core import store as _module -from bub.core.store import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/src/bub/tape.py b/src/bub/tape.py deleted file mode 100644 index 965d1d18..00000000 --- a/src/bub/tape.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.tape`.""" - -import sys - -from bub.core import tape as _module -from bub.core.tape import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/src/bub/tools.py b/src/bub/tools.py deleted file mode 100644 index 76d4ee66..00000000 --- a/src/bub/tools.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Compatibility alias for :mod:`bub.core.tools`.""" - -import sys - -from bub.core import tools as _module -from bub.core.tools import * # noqa: F403 - -sys.modules[__name__] = _module diff --git a/tests/conftest.py b/tests/conftest.py index a92497a7..56a64baf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ @pytest.fixture(autouse=True) def isolate_bub_environment(monkeypatch: pytest.MonkeyPatch) -> None: - # Importing bub.framework loads the developer's .env into os.environ; tests set what they need. + # Importing bub.configure loads the developer's .env into os.environ; tests set what they need. for name in list(os.environ): if name.startswith("BUB_"): monkeypatch.delenv(name) diff --git a/tests/model_fakes.py b/tests/model_fakes.py index ee8f98e6..c23bf7da 100644 --- a/tests/model_fakes.py +++ b/tests/model_fakes.py @@ -3,18 +3,18 @@ from __future__ import annotations import json -from collections.abc import Sequence +from collections.abc import AsyncIterator, Sequence from typing import Any import httpx2 from bub.builtin.settings import AgentSettings from bub.core import Agent as CoreAgent +from bub.core.tape import Tape +from bub.core.tools import Tool from bub.hooks.interception import PluginAgentHooks from bub.prompt import UserContent -from bub.streaming import AsyncStreamEvents -from bub.tape import Tape -from bub.tools import Tool +from bub.streaming import StreamEvent class StepRunner: @@ -33,7 +33,7 @@ def run( system_prompt: str | None, prompt: list[UserContent] | None, steering_messages: list[list[UserContent]] | None = None, - ) -> AsyncStreamEvents: + ) -> AsyncIterator[StreamEvent]: class Agent(CoreAgent): async def pending_messages(self, tape: Tape) -> list[list[UserContent]]: return steering_messages or [] diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 59d65f5f..f633c0a7 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -12,12 +12,15 @@ from republic.errors import StreamIncompleteError from bub.builtin.settings import AgentSettings +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import Tool, ToolExecutor from bub.errors import BubError from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import ( - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallRequest, + LLMCallResult, PluginAgentHooks, ToolCall, ToolCallDecision, @@ -25,9 +28,6 @@ ) from bub.hooks.runtime import HookRuntime from bub.prompt import to_content -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import Tool, ToolExecutor from tests.model_fakes import ProviderService, StepRunner, sse @@ -39,8 +39,8 @@ def make_hooks(*plugins: Any) -> PluginAgentHooks: return PluginAgentHooks(HookRuntime(pm)) -def request() -> LlmCallRequest: - return LlmCallRequest(run_id="run-1", model="openai:gpt-x", messages=[republic.user("hi")]) +def request() -> LLMCallRequest: + return LLMCallRequest(run_id="run-1", model="openai:gpt-x", messages=[republic.user("hi")]) class TestBeforeLlmCall: @@ -48,12 +48,12 @@ class TestBeforeLlmCall: async def test_chain_folds_modifications_in_order(self) -> None: class SwapModel: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: + def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest: return replace(request, model="anthropic:claude") class AppendMessage: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: + def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest: # must see SwapModel's change (registration-order chaining) assert request.model == "anthropic:claude" return replace(request, messages=[*request.messages, republic.user("extra")]) @@ -69,12 +69,12 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallReques async def test_none_and_bad_returns_leave_request_unchanged(self) -> None: class Noop: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> None: + def before_llm_call(self, request: LLMCallRequest, state: dict) -> None: return None class BadReturn: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> Any: + def before_llm_call(self, request: LLMCallRequest, state: dict) -> Any: return "not-a-request" hooks = make_hooks(Noop(), BadReturn()) @@ -85,12 +85,12 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> Any: async def test_raising_impl_is_isolated(self) -> None: class Boom: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: + def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest: raise RuntimeError("plugin broke") class After: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: + def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest: return replace(request, model="fallback:model") hooks = make_hooks(Boom(), After()) @@ -227,20 +227,20 @@ async def test_no_hooks_keeps_current_behavior(self) -> None: class TestAfterLlmCall: @pytest.mark.asyncio async def test_observe_only_and_isolated(self) -> None: - observed: list[LlmCallResult] = [] + observed: list[LLMCallResult] = [] class Boom: @hookimpl - def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None: raise RuntimeError("metrics plugin broke") class Observe: @hookimpl - def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None: observed.append(result) hooks = make_hooks(Boom(), Observe()) - result = LlmCallResult(run_id="run-1", text="hello", usage={"total_tokens": 5}, duration_ms=12) + result = LLMCallResult(run_id="run-1", text="hello", usage={"total_tokens": 5}, duration_ms=12) await hooks.after_llm_call(request(), result, state={}) assert observed == [result] @@ -250,12 +250,12 @@ class TestBeforeLlmCallFinish: async def test_finish_decision_short_circuits(self) -> None: class Limit: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallDecision: - return LlmCallDecision.finish("call budget exhausted") + def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallDecision: + return LLMCallDecision.finish("call budget exhausted") class Later: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> None: + def before_llm_call(self, request: LLMCallRequest, state: dict) -> None: raise AssertionError("must not run after finish") hooks = make_hooks(Later(), Limit()) # LIFO: Limit runs first @@ -283,7 +283,7 @@ async def test_rewritten_model_and_max_tokens_reach_provider_and_tape( ) -> None: class Reroute: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: + def before_llm_call(self, request: LLMCallRequest, state: dict) -> LLMCallRequest: return replace(request, model="anthropic:new", max_tokens=42) provider_transport.reply( @@ -306,11 +306,11 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallReques async def test_after_llm_call_not_fired_on_early_close( self, tmp_path: Path, provider_transport: ProviderService ) -> None: - observed: list[LlmCallResult] = [] + observed: list[LLMCallResult] = [] class Observe: @hookimpl - def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None: observed.append(result) provider_transport.reply_chat("done") @@ -326,11 +326,11 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: async def test_after_llm_call_fires_exactly_once_on_success( self, tmp_path: Path, provider_transport: ProviderService ) -> None: - observed: list[LlmCallResult] = [] + observed: list[LLMCallResult] = [] class Observe: @hookimpl - def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None: observed.append(result) provider_transport.reply_chat("done") @@ -345,11 +345,11 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: async def test_failed_stream_reports_partial_text_once_without_trying_fallback( self, tmp_path: Path, provider_transport: ProviderService ) -> None: - observed: list[LlmCallResult] = [] + observed: list[LLMCallResult] = [] class Observe: @hookimpl - def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None: observed.append(result) provider_transport.reply(sse([{"choices": [{"delta": {"content": "partial"}}]}])) @@ -370,11 +370,11 @@ def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: async def test_refusal_text_reaches_the_hook_final_event_and_tape( self, tmp_path: Path, provider_transport: ProviderService ) -> None: - observed: list[LlmCallResult] = [] + observed: list[LLMCallResult] = [] class Observe: @hookimpl - def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + def after_llm_call(self, request: LLMCallRequest, result: LLMCallResult, state: dict) -> None: observed.append(result) provider_transport.reply( diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 435780f8..e6b80e29 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -14,14 +14,14 @@ from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox -from bub.core import PromptContext, StepFinished +from bub.core import PromptContext from bub.core.context import default_tape_context -from bub.core.tape import session_tape_name +from bub.core.events import StepFinished +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import REGISTRY, tool from bub.prompt import UserContent -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState -from bub.tape import Tape -from bub.tools import REGISTRY, tool +from bub.streaming import StreamEvent, StreamState from tests.model_fakes import ProviderService # --------------------------------------------------------------------------- @@ -172,14 +172,12 @@ async def test_agent_loop_awaits_continue_prompt_hook_with_stream_state(continua continuation_prompts: list[list[UserContent] | None] = [] observed_usage: list[dict[str, Any] | None] = [] - def run_once(prompt: list[UserContent] | None, *, model: Any, tape: Tape, **_: Any) -> AsyncStreamEvents: + async def run_once( + prompt: list[UserContent] | None, *, model: Any, tape: Tape, state: StreamState, **_: Any + ) -> AsyncIterator[StreamEvent]: prompts.append(prompt) - should_continue = len(prompts) == 1 - - async def iterator() -> AsyncIterator[StreamEvent]: - yield StreamEvent("final", {"tool_calls": ["call"] if should_continue else []}) - - return AsyncStreamEvents(iterator(), state=StreamState(usage={"step": len(prompts)})) + state.usage = {"step": len(prompts)} + yield StreamEvent("final", {"tool_calls": ["call"] if len(prompts) == 1 else []}) async def continue_prompt(*, prompt: list[UserContent] | None, tape: Tape, state: StreamState) -> str | None: continuation_prompts.append(prompt) diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index 878ce94c..877651bf 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -633,7 +633,7 @@ def test_ensure_project_initializes_project_and_adds_bub_dependency(tmp_path: Pa assert captured == [ (("init", "--bare", "--name", "bub-project", "--app"), project), - (("add", "--active", "--no-sync", "--editable", "/tmp/bub"), project), # noqa: S108 + (("add", "--active", "--no-sync", "--extra", "app", "--editable", "/tmp/bub"), project), # noqa: S108 ] diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 3c6c42c6..2400582d 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -12,11 +12,11 @@ from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem from bub.core.context import default_tape_context +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, TapeEntry +from bub.core.tools import Tool from bub.framework import BubFramework from bub.prompt import to_content -from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry -from bub.tools import Tool from tests.model_fakes import ProviderService, StepRunner, sse diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index b2a9ee62..76147fe3 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -8,13 +8,13 @@ import pytest from bub.builtin.hook_impl import AGENTS_FILE_NAME, DEFAULT_SYSTEM_PROMPT, BuiltinImpl +from bub.builtin.store import FuzzyFileTapeStore from bub.channels.message import ChannelMessage -from bub.core.tape import session_tape_name +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, session_tape_name +from bub.core.tools import REGISTRY from bub.framework import BubFramework -from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tape import Tape, TapeContext -from bub.tools import REGISTRY class RecordingLifespan: @@ -442,7 +442,7 @@ def test_provide_tape_store_uses_bub_home_directory(tmp_path: Path, monkeypatch: store = impl.provide_tape_store() - assert isinstance(store, FileTapeStore) + assert isinstance(store, FuzzyFileTapeStore) assert store._directory == tmp_path / "tapes" diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 209a502f..a3960c53 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -26,10 +26,10 @@ set_reasoning_effort, tape_info, ) +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool from bub.errors import ErrorKind -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool def _tool_context(tmp_path, **state) -> ToolContext: diff --git a/tests/test_channels.py b/tests/test_channels.py index ec71b592..6ceced98 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1334,7 +1334,7 @@ async def test_terminal_presenter_redraw_wait_stops_when_prompt_finishes(monkeyp async def test_cli_tool_renderer_finishes_before_next_model_output() -> None: from bub.channels.cli import _CliToolCallRenderer from bub.core.events import EventBus - from bub.tools import Tool, ToolExecutor + from bub.core.tools import Tool, ToolExecutor events: list[str] = [] @@ -1367,7 +1367,7 @@ def ordered_tool() -> str: async def test_cli_tool_renderer_shows_the_error_the_tool_raised() -> None: from bub.channels.cli import _CliToolCallRenderer from bub.core.events import EventBus - from bub.tools import Tool, ToolExecutor + from bub.core.tools import Tool, ToolExecutor errors: list[BaseException] = [] diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py new file mode 100644 index 00000000..e52405e7 --- /dev/null +++ b/tests/test_cli_main.py @@ -0,0 +1,72 @@ +import subprocess +import sys +from importlib.metadata import PackageNotFoundError + +import pytest + +import bub.__main__ as cli + +REQUIREMENTS = [ + "republic>=0.6.0a1", + "typer>=0.24.1; extra == 'app'", + "httpx[socks]>=0.28.1; extra == 'app'", + "opentelemetry-api>=1.39.0; extra == 'trace'", +] + + +def _installed(*names: str): + def distribution(name: str) -> object: + if name not in names: + raise PackageNotFoundError(name) + return object() + + return distribution + + +def test_missing_app_requirements_lists_only_uninstalled_app_extra_packages(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli, "requires", lambda name: REQUIREMENTS) + monkeypatch.setattr(cli, "distribution", _installed("republic", "typer")) + + assert cli.missing_app_requirements() == ["httpx"] + + +def test_bub_command_without_app_extra_exits_with_install_hint(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli, "requires", lambda name: REQUIREMENTS) + monkeypatch.setattr(cli, "distribution", _installed()) + + with pytest.raises(SystemExit) as exc_info: + cli.main() + + message = str(exc_info.value.code) + assert "missing: typer, httpx" in message + assert "pip install 'bub[app]'" in message + + +def test_bub_command_runs_the_cli_when_app_extra_is_installed(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + monkeypatch.setattr(cli, "missing_app_requirements", lambda: []) + monkeypatch.setattr(cli, "create_cli_app", lambda: lambda: calls.append("app")) + + cli.main() + + assert calls == ["app"] + + +def test_uninstalled_source_tree_skips_the_check(monkeypatch: pytest.MonkeyPatch) -> None: + def requires(name: str) -> list[str]: + raise PackageNotFoundError(name) + + monkeypatch.setattr(cli, "requires", requires) + + assert cli.missing_app_requirements() == [] + + +def test_importing_the_cli_module_does_not_load_the_app_extra() -> None: + script = ( + "import sys, bub.__main__\n" + "print(sorted({name.split('.')[0] for name in sys.modules} & {'typer', 'pluggy', 'pydantic_settings'}))\n" + "print('bub.framework' in sys.modules)\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True) + + assert result.stdout.splitlines() == ["[]", "False"] diff --git a/tests/test_codemode.py b/tests/test_codemode.py index 75ca8be5..ff7f4e15 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -23,13 +23,13 @@ write_tool_stub, ) from bub.builtin.environment import LocalEnvironment, LocalProcess -from bub.core.events import BeforeToolCall, EventBus +from bub.core.events import EventBus, ToolCallStarted +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, CodeFailed from bub.errors import BubError, ErrorKind from bub.hooks.interception import ToolCallDecision -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool class Order(TypedDict): @@ -152,13 +152,13 @@ def get_mode(*, context: ToolContext) -> dict[str, bool]: async def test_run_code_routes_tool_calls_through_hooks(tmp_path: Path) -> None: calls: list[tuple[str, bool]] = [] - def before_tool_call(event: BeforeToolCall) -> None: + def before_tool_call(event: ToolCallStarted) -> None: calls.append((event.call.tool, event.call.context is not None and event.call.context.code_mode)) if event.call.tool == "blocked": event.decision = ToolCallDecision.deny("not allowed") events = EventBus() - events.on(BeforeToolCall, before_tool_call) + events.on(ToolCallStarted, before_tool_call) order_tool = Tool.from_callable(get_order, name="orders.get", context=True) blocked = Tool.from_callable(lambda: "secret", name="blocked") code = "try:\n await tools.blocked()\nexcept Exception as exc:\n print(type(exc).__name__, exc)\n" @@ -388,13 +388,13 @@ async def call(**kwargs: Any) -> Any: async def test_run_code_delegates_to_the_environment_code_runtime(tmp_path: Path) -> None: calls: list[tuple[str, bool]] = [] - def before_tool_call(event: BeforeToolCall) -> None: + def before_tool_call(event: ToolCallStarted) -> None: calls.append((event.call.tool, event.call.context is not None and event.call.context.code_mode)) if event.call.tool == "blocked": event.decision = ToolCallDecision.deny("not allowed") events = EventBus() - events.on(BeforeToolCall, before_tool_call) + events.on(ToolCallStarted, before_tool_call) order_tool = Tool.from_callable(get_order, name="orders.get", context=True) blocked = Tool.from_callable(lambda: "secret", name="blocked") code = ( @@ -469,7 +469,7 @@ async def test_run_code_attaches_media_to_its_result(tmp_path: Path) -> None: import republic from bub.builtin.codemode import attach_audio, attach_image, attach_video - from bub.tools import result_content + from bub.core.tools import result_content (tmp_path / "charts").mkdir() (tmp_path / "charts" / "chart.png").write_bytes(PNG_BYTES) @@ -527,8 +527,8 @@ def test_media_tool_results_become_multimodal_tool_messages(tmp_path: Path) -> N import republic from bub.core.context import default_tape_context - from bub.tape import TapeEntry - from bub.tools import content_result + from bub.core.tape import TapeEntry + from bub.core.tools import content_result image = republic.Image("image/png", data=PNG_BYTES) entries = [ diff --git a/tests/test_core_agent.py b/tests/test_core_agent.py index 087a7d07..e2e22327 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -9,24 +9,24 @@ import republic import bub.core -from bub.core import ( - AfterLlmCall, - AfterToolCall, - Agent, +from bub.core import Agent, PromptContext +from bub.core.events import ( AgentEvent, - BeforeLlmCall, - BeforeToolCall, - ChatCompleted, + ChatFinished, CommandFinished, EventBus, - LlmCallDecision, - PromptContext, + LLMCallDecision, + LLMCallFinished, + LLMCallStarted, + RunFinished, RunStarted, StepFinished, - Tool, ToolCallDecision, + ToolCallFinished, + ToolCallStarted, ) from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tools import Tool from bub.errors import BubError from tests.model_fakes import ProviderService, chat_events, sse, tool_events @@ -62,8 +62,8 @@ def tools_section(context: PromptContext) -> str: async def empty_section(context: PromptContext) -> None: return None - @agent.events.on(BeforeToolCall) - def rewrite_path(event: BeforeToolCall) -> None: + @agent.events.on(ToolCallStarted) + def rewrite_path(event: ToolCallStarted) -> None: event.call = replace(event.call, arguments={"path": "README.md"}) assert agent.system_prompt.add("Removed section.") == "Removed section." @@ -85,39 +85,40 @@ def rewrite_path(event: BeforeToolCall) -> None: assert seen == [ "RunStarted", "StepStarted", - "BeforeLlmCall", - "AfterLlmCall", - "BeforeToolCall", - "AfterToolCall", - "ChatCompleted", + "LLMCallStarted", + "LLMCallFinished", + "ToolCallStarted", + "ToolCallFinished", + "ChatFinished", "StepFinished", "StepStarted", - "BeforeLlmCall", - "AfterLlmCall", - "ChatCompleted", + "LLMCallStarted", + "LLMCallFinished", + "ChatFinished", "StepFinished", + "RunFinished", ] # The run's fork merged back into the store's tape. tape = agent.tape("session") roles = [message.role for message in await tape.read_messages()] assert roles[-4:] == ["user", "assistant", "tool", "assistant"] names = [entry.payload["name"] for entry in await tape.store.fetch_all(tape.query().kinds("event"))] - assert names.index("loop.start") < names.index("loop.step.start") < names.index("run") + assert names.index("loop.start") < names.index("loop.step.start") < names.index("run") < names.index("loop.finish") @pytest.mark.asyncio async def test_before_events_short_circuit_remaining_handlers(provider_service: ProviderService) -> None: provider_service.reply(_tool_call("secret")) later: list[str] = [] - finished: list[ChatCompleted] = [] + finished: list[ChatFinished] = [] async with provider_service.client() as client: model = _model(client) agent = Agent(store=InMemoryTapeStore(), tools=[Tool.from_callable(lambda: "x", name="secret")]) - agent.events.on(BeforeToolCall, lambda event: setattr(event, "decision", ToolCallDecision.deny("no secrets"))) - agent.events.on(BeforeToolCall, lambda event: later.append("tool")) - agent.events.on(BeforeLlmCall, lambda event: later.append("llm")) - agent.events.on(ChatCompleted, finished.append) + agent.events.on(ToolCallStarted, lambda event: setattr(event, "decision", ToolCallDecision.deny("no secrets"))) + agent.events.on(ToolCallStarted, lambda event: later.append("tool")) + agent.events.on(LLMCallStarted, lambda event: later.append("llm")) + agent.events.on(ChatFinished, finished.append) async with agent.session("deny") as tape: events = [event async for event in agent.run_once(["go"], model=model, tape=tape)] @@ -126,9 +127,9 @@ async def test_before_events_short_circuit_remaining_handlers(provider_service: assert later == ["llm"] agent.events.on( - BeforeLlmCall, lambda event: setattr(event, "decision", LlmCallDecision.finish("budget exhausted")) + LLMCallStarted, lambda event: setattr(event, "decision", LLMCallDecision.finish("budget exhausted")) ) - agent.events.on(AfterLlmCall, lambda event: later.append("after")) + agent.events.on(LLMCallFinished, lambda event: later.append("after")) async with agent.run(["again"], model=model, tape="budget") as stream: events = [event async for event in stream] @@ -172,11 +173,12 @@ async def test_core_agent_stops_at_max_steps(provider_service: ProviderService) ) agent.events.on(StepFinished, lambda event: statuses.append(event.status)) agent.events.on(RunStarted, lambda event: statuses.append(f"model={event.model}")) - agent.events.on(AfterToolCall, lambda event: statuses.append(f"tool={event.result.result}")) + agent.events.on(ToolCallFinished, lambda event: statuses.append(f"tool={event.result.result}")) + agent.events.on(RunFinished, lambda event: statuses.append(f"run={event.status}:{event.steps}:{event.error}")) with pytest.raises(RuntimeError, match="max_steps_reached=1"): async with agent.run(["loop"], model=model, tape="steps") as stream: [event async for event in stream] - assert statuses == ["model=openai:test", "tool=ok", "continue"] + assert statuses == ["model=openai:test", "tool=ok", "continue", "run=error:1:max_steps_reached=1"] @pytest.mark.asyncio @@ -185,8 +187,8 @@ async def test_redirected_call_reuses_the_provider_of_a_republic_model(provider_ async with provider_service.client() as client: agent = Agent(store=InMemoryTapeStore()) - @agent.events.on(BeforeLlmCall) - def reroute(event: BeforeLlmCall) -> None: + @agent.events.on(LLMCallStarted) + def reroute(event: LLMCallStarted) -> None: event.request = replace(event.request, model="openai:other") async with agent.run(["hi"], model=_model(client), tape="redirect") as stream: @@ -220,8 +222,10 @@ def test_tapes_share_the_agent_store() -> None: @pytest.mark.asyncio async def test_leaving_run_early_closes_the_stream_and_merges_the_fork(provider_service: ProviderService) -> None: provider_service.reply(sse(chat_events("hello there"))) + finished: list[RunFinished] = [] async with provider_service.client() as client: agent = Agent() + agent.events.on(RunFinished, finished.append) async with agent.run(["hi"], model=_model(client), tape="early") as stream: async for event in stream: if event.kind == "text": @@ -231,6 +235,34 @@ async def test_leaving_run_early_closes_the_stream_and_merges_the_fork(provider_ assert [message.role for message in messages] == [] names = [entry.payload["name"] for entry in await agent.tape("early").store.fetch_all(agent.tape("early").query())] assert "loop.start" in names + assert "loop.finish" in names + assert [(event.status, event.steps) for event in finished] == [("cancelled", 1)] + + +@pytest.mark.asyncio +async def test_run_finished_follows_a_run_closed_before_iteration(provider_service: ProviderService) -> None: + finished: list[RunFinished] = [] + async with provider_service.client() as client: + agent = Agent() + agent.events.on(RunFinished, finished.append) + async with agent.run(["hi"], model=_model(client), tape="unread"): + pass + + assert [(event.status, event.steps, event.error) for event in finished] == [("cancelled", 0, None)] + assert provider_service.requests == [] + + +@pytest.mark.asyncio +async def test_commands_do_not_start_or_finish_a_run() -> None: + seen: list[str] = [] + agent = Agent(tools=[Tool.from_callable(lambda: "tool ran", name="echo")], command_prefix=",") + agent.events.on(AgentEvent, lambda event: seen.append(type(event).__name__)) + async with agent.run([",echo"], tape="command") as stream: + [event async for event in stream] + + assert "RunStarted" not in seen + assert "RunFinished" not in seen + assert "CommandFinished" in seen @pytest.mark.asyncio @@ -305,8 +337,8 @@ def greet(name: str, punctuation: str = "!") -> str: agent = Agent(tools=[Tool.from_callable(greet, name="greet")], command_prefix="/") calls: list[tuple[str, dict[str, Any]]] = [] - @agent.events.on(BeforeToolCall) - def deny_bob(event: BeforeToolCall) -> None: + @agent.events.on(ToolCallStarted) + def deny_bob(event: ToolCallStarted) -> None: calls.append((event.call.tool, event.call.arguments)) if event.call.arguments["name"] == "bob": event.decision = ToolCallDecision.deny("no bob") diff --git a/tests/test_core_agent_step.py b/tests/test_core_agent_step.py index 00145bef..0ef1ecff 100644 --- a/tests/test_core_agent_step.py +++ b/tests/test_core_agent_step.py @@ -11,11 +11,11 @@ from bub.builtin.settings import AgentSettings from bub.core.context import default_tape_context +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, TapeEntry +from bub.core.tools import Tool from bub.errors import BubError, ErrorKind from bub.prompt import to_content -from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry -from bub.tools import Tool from tests.model_fakes import ProviderService, StepRunner, chat_events, sse, tool_events diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py new file mode 100644 index 00000000..7194d045 --- /dev/null +++ b/tests/test_core_boundary.py @@ -0,0 +1,43 @@ +import subprocess +import sys + +from bub.builtin.store import FuzzyFileTapeStore +from bub.core.store import FileTapeStore, TapeQuery +from bub.core.tape import TapeEntry + +APP_MODULES = ("typer", "pluggy", "pydantic_settings", "dotenv", "yaml", "rapidfuzz", "telegram", "rich") + + +def test_importing_core_loads_no_app_dependencies() -> None: + script = ( + "import sys, bub.core\n" + "loaded = {name.split('.')[0] for name in sys.modules}\n" + f"print(sorted(loaded & set({APP_MODULES!r})))\n" + "print('bub.framework' in sys.modules)\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True) + + assert result.stdout.splitlines() == ["[]", "False"] + + +def _search(store: FileTapeStore, text: str) -> list[str]: + return [entry.payload["content"] for entry in store.fetch_all(TapeQuery("tape", store).query(text))] + + +def _write_messages(store: FileTapeStore) -> None: + for content in ["deploy the gateway", "unrelated", "deploy the gateway", "deploy again"]: + store.append("tape", TapeEntry.message({"role": "user", "content": content})) + + +def test_core_file_store_matches_text_in_order(tmp_path) -> None: + store = FileTapeStore(tmp_path) + _write_messages(store) + + assert _search(store, "deploy") == ["deploy the gateway", "deploy the gateway", "deploy again"] + + +def test_builtin_file_store_returns_newest_unique_matches_first(tmp_path) -> None: + store = FuzzyFileTapeStore(tmp_path) + _write_messages(store) + + assert _search(store, "deploy") == ["deploy again", "deploy the gateway"] diff --git a/tests/test_environment.py b/tests/test_environment.py index 1169996f..4d5b066d 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -8,12 +8,12 @@ import bub.builtin.tools as builtin_tools from bub.builtin.environment import LocalEnvironment, LocalProcess, environment_from_state from bub.builtin.shell_manager import ShellManager +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import ToolContext from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, Environment, Process from bub.framework import BubFramework from bub.hooks import hookimpl -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import ToolContext class MemoryEnvironment(Environment): diff --git a/tests/test_file_tape_store_entry_ids.py b/tests/test_file_tape_store_entry_ids.py index 0f73b91e..1f046fe9 100644 --- a/tests/test_file_tape_store_entry_ids.py +++ b/tests/test_file_tape_store_entry_ids.py @@ -2,9 +2,9 @@ import pytest -from bub.sidecars import sidecar_tape_name -from bub.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore -from bub.tape import TapeEntry +from bub.core.sidecars import sidecar_tape_name +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, ForkTapeStore +from bub.core.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_fork_store_merge_back.py b/tests/test_fork_store_merge_back.py index e614900e..8e756c70 100644 --- a/tests/test_fork_store_merge_back.py +++ b/tests/test_fork_store_merge_back.py @@ -2,8 +2,8 @@ import pytest -from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery -from bub.tape import TapeEntry +from bub.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore, TapeQuery +from bub.core.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py index 81cc08b7..1bdd83e1 100644 --- a/tests/test_install_scripts.py +++ b/tests/test_install_scripts.py @@ -173,7 +173,7 @@ def test_install_sh_uses_existing_uv_with_minimal_preset(tmp_path: Path) -> None venv_path = tmp_path / "home" / ".bub" / ".venv" assert uv_calls[1:] == [ f"venv --python 3.12 --allow-existing {venv_path}", - f"pip install --python {venv_path / 'bin' / 'python'} --upgrade bub", + f"pip install --python {venv_path / 'bin' / 'python'} --upgrade bub[app]", ] executable_link = tmp_path / "home" / ".local" / "bin" / "bub" assert executable_link.is_symlink() diff --git a/tests/test_otlp.py b/tests/test_otlp.py index caa563ab..b852869b 100644 --- a/tests/test_otlp.py +++ b/tests/test_otlp.py @@ -75,7 +75,7 @@ def unexpected_configuration(*args, **kwargs): logfire.configure = unexpected_configuration from bub.__main__ import _instrument_bub from bub.tracing import Span -_instrument_bub('INFO') # CLI import already configured it; repeat must not duplicate exports. +_instrument_bub('INFO') root = Span('invoke_agent bub', {'gen_ai.operation.name': 'invoke_agent'}) with root.activate(): child = Span('chat test', {'gen_ai.operation.name': 'chat'}) diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py index ad0f21fa..1b7bb911 100644 --- a/tests/test_provider_messages.py +++ b/tests/test_provider_messages.py @@ -12,12 +12,12 @@ from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem from bub.core.context import default_tape_context +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import Tool from bub.errors import BubError, ErrorKind from bub.framework import BubFramework from bub.prompt import to_content -from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import Tool from tests.model_fakes import ProviderService, StepRunner, chat_events, sse @@ -177,8 +177,8 @@ async def test_fresh_process_replays_tool_signatures_to_the_provider( from bub.core.context import default_tape_context from tests.model_fakes import StepRunner from bub.builtin.settings import AgentSettings -from bub.store import FileTapeStore, AsyncTapeStoreAdapter -from bub.tape import Tape +from bub.core.store import FileTapeStore, AsyncTapeStoreAdapter +from bub.core.tape import Tape async def main(): bodies = [] def respond(request): diff --git a/tests/test_sdk_isolation.py b/tests/test_sdk_isolation.py index eab0d938..72407ebb 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -7,13 +7,12 @@ from bub.builtin.model import BubModel from bub.builtin.tools import resolve_tool_names, run_subagent, show_help from bub.core import Agent as CoreAgent -from bub.core.tape import session_tape_name +from bub.core.store import InMemoryTapeStore +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import Tool, ToolContext from bub.framework import BubFramework from bub.hooks import hookimpl -from bub.store import InMemoryTapeStore from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tape import Tape -from bub.tools import Tool, ToolContext def _reply() -> AsyncStreamEvents: diff --git a/tests/test_session_settings.py b/tests/test_session_settings.py index bc49ab9f..c6c19886 100644 --- a/tests/test_session_settings.py +++ b/tests/test_session_settings.py @@ -5,9 +5,9 @@ import pytest from bub.builtin.settings import load_session_settings, set_session_setting -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import ToolContext +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import ToolContext def _context(tmp_path: Path) -> ToolContext: diff --git a/tests/test_settings.py b/tests/test_settings.py index 632fd546..41926d9a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -11,9 +11,9 @@ from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, load_settings from bub.builtin.spill import SpillSettings from bub.configure import ensure_config +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext from bub.prompt import to_content -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext from tests.model_fakes import ProviderService, StepRunner, chat_events, sse diff --git a/tests/test_spill.py b/tests/test_spill.py index 2e870955..d89286bd 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -21,22 +21,22 @@ ) from bub.builtin.tools import render_tools_prompt from bub.core.context import default_tape_context -from bub.core.events import AfterToolCall, EventBus +from bub.core.events import EventBus, ToolCallFinished +from bub.core.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore +from bub.core.tape import Tape +from bub.core.tools import Tool, ToolContext, ToolExecutor, model_tools from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import PluginAgentHooks, ToolCall, ToolCallResult from bub.hooks.runtime import HookRuntime -from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore -from bub.tape import Tape -from bub.tools import Tool, ToolContext, ToolExecutor, model_tools from tests.model_fakes import ProviderService, StepRunner def _spill_events() -> EventBus: - async def spill(event: AfterToolCall) -> None: + async def spill(event: ToolCallFinished) -> None: await BuiltinImpl.after_tool_call(None, event.call, event.result, event.state) # type: ignore[arg-type] events = EventBus() - events.on(AfterToolCall, spill) + events.on(ToolCallFinished, spill) return events @@ -438,7 +438,7 @@ async def test_result_spill_without_tool_availability_keeps_full_output(tmp_path async def test_spill_bounds_text_of_content_results_and_keeps_media(tmp_path: Path) -> None: import republic - from bub.tools import content_result, result_content + from bub.core.tools import content_result, result_content image = republic.Image("image/png", data=b"png") root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py index 1826de24..f3e44d0a 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -6,8 +6,8 @@ import pytest from bub.builtin.tools import run_subagent +from bub.core.tools import REGISTRY, tool from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tools import REGISTRY, tool class FakeContext: diff --git a/tests/test_tape.py b/tests/test_tape.py index 21fa8be2..e0874722 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -6,12 +6,12 @@ import republic from bub.core.context import default_tape_context -from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry, to_messages +from bub.core.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext, TapeEntry, to_messages def test_tape_reexports_legacy_store_objects() -> None: - from bub import store, tape + from bub.core import store, tape expected_exports = { "AsyncTapeStore", diff --git a/tests/test_tape_search_output.py b/tests/test_tape_search_output.py index f30e2403..c16fc9f1 100644 --- a/tests/test_tape_search_output.py +++ b/tests/test_tape_search_output.py @@ -6,7 +6,7 @@ import bub.builtin.tools as builtin_tools from bub.builtin.tools import tape_search -from bub.tools import ToolContext +from bub.core.tools import ToolContext @dataclass(frozen=True) diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py index b84b0819..e0658693 100644 --- a/tests/test_tool_loading.py +++ b/tests/test_tool_loading.py @@ -7,8 +7,8 @@ import pytest from bub.builtin.agent import BubAgent +from bub.core.tools import Tool from bub.framework import BubFramework -from bub.tools import Tool from tests.model_fakes import ProviderService diff --git a/tests/test_tools.py b/tests/test_tools.py index 99d07c5e..08441506 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -7,11 +7,11 @@ from loguru import logger from pydantic import BaseModel -from bub.core.events import AfterToolCall, BeforeToolCall, EventBus +from bub.core.events import EventBus, ToolCallFinished, ToolCallStarted +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, TapeContext +from bub.core.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool from bub.errors import BubError -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore -from bub.tape import Tape, TapeContext -from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, model_tools, tool class EchoInput(BaseModel): @@ -149,8 +149,8 @@ async def test_tool_executor_logs_arguments_changed_by_handlers(monkeypatch: pyt monkeypatch.setattr(logger, "info", lambda message, *args: messages.append(message.format(*args))) events = EventBus() - @events.on(BeforeToolCall) - def rewrite(event: BeforeToolCall) -> None: + @events.on(ToolCallStarted) + def rewrite(event: ToolCallStarted) -> None: event.call = replace(event.call, arguments={"value": "rewritten"}) echo = Tool.from_callable(lambda value: value, name="tests.echo") @@ -233,8 +233,8 @@ async def test_executor_passes_tool_context_to_context_tools_and_hooks(tmp_path) seen: dict[str, Any] = {} events = EventBus() - events.on(BeforeToolCall, lambda event: seen.update(before=event.call.context)) - events.on(AfterToolCall, lambda event: seen.update(after=(event.call.context, event.result.result))) + events.on(ToolCallStarted, lambda event: seen.update(before=event.call.context)) + events.on(ToolCallFinished, lambda event: seen.update(after=(event.call.context, event.result.result))) def mode(*, context: ToolContext) -> dict[str, bool]: return {"code_mode": context.code_mode} @@ -252,8 +252,8 @@ def mode(*, context: ToolContext) -> dict[str, bool]: @pytest.mark.asyncio async def test_executor_execute_returns_the_result_or_raises_the_tool_error() -> None: events = EventBus() - finished: list[AfterToolCall] = [] - events.on(AfterToolCall, finished.append) + finished: list[ToolCallFinished] = [] + events.on(ToolCallFinished, finished.append) executor = ToolExecutor(events, render=False) def failing_tool() -> str: diff --git a/tests/test_tracing.py b/tests/test_tracing.py index a23352d6..ff820122 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -15,14 +15,13 @@ from bub.builtin.agent import BubAgent from bub.builtin.settings import AgentSettings from bub.core.context import default_tape_context -from bub.core.tape import session_tape_name +from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.core.tape import Tape, session_tape_name +from bub.core.tools import Tool, ToolContext, ToolExecutor from bub.framework import BubFramework from bub.hooks import hookimpl -from bub.hooks.interception import LlmCallDecision, ToolCallDecision, ToolCallResult -from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.hooks.interception import LLMCallDecision, ToolCallDecision, ToolCallResult from bub.streaming import AsyncStreamEvents, StreamEvent -from bub.tape import Tape -from bub.tools import Tool, ToolContext, ToolExecutor from bub.utils import workspace_from_state from tests.model_fakes import ProviderService, chat_events, sse @@ -257,8 +256,8 @@ async def cleanup() -> None: async def test_llm_short_circuit_has_no_phantom_model_span(spans: Any, agent: BubAgent) -> None: class Finish: @hookimpl - def before_llm_call(self) -> LlmCallDecision: - return LlmCallDecision.finish("stopped by policy") + def before_llm_call(self) -> LLMCallDecision: + return LLMCallDecision.finish("stopped by policy") agent.framework.plugin_manager.register(Finish()) events = await agent.run_stream(session_id="policy", prompt="hello", state={}, allowed_tools=[]) @@ -323,7 +322,7 @@ def find_spec(self, fullname, path=None, target=None): sys.meta_path.insert(0, BlockTelemetry()) from bub import tracing from bub.builtin.agent import BubAgent -from bub.tools import Tool, ToolExecutor +from bub.core.tools import Tool, ToolExecutor assert tracing.otel is None async def main(): span = tracing.Span('noop') diff --git a/uv.lock b/uv.lock index 6480ddab..fa9d5ef3 100644 --- a/uv.lock +++ b/uv.lock @@ -185,29 +185,42 @@ wheels = [ name = "bub" source = { editable = "." } dependencies = [ + { name = "loguru" }, + { name = "pydantic" }, + { name = "republic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +app = [ { name = "aiohttp" }, { name = "httpx", extra = ["socks"] }, { name = "inquirer-textual" }, - { name = "loguru" }, { name = "pluggy" }, { name = "prompt-toolkit" }, - { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-telegram-bot" }, { name = "pyyaml" }, { name = "rapidfuzz" }, - { name = "republic" }, { name = "rich" }, { name = "typer" }, - { name = "typing-extensions" }, ] - -[package.optional-dependencies] logfire = [ + { name = "aiohttp" }, + { name = "httpx", extra = ["socks"] }, + { name = "inquirer-textual" }, { name = "logfire" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, + { name = "pluggy" }, + { name = "prompt-toolkit" }, + { name = "pydantic-settings" }, + { name = "python-telegram-bot" }, + { name = "pyyaml" }, + { name = "rapidfuzz" }, + { name = "rich" }, + { name = "typer" }, ] trace = [ { name = "opentelemetry-api" }, @@ -217,6 +230,7 @@ trace = [ [package.dev-dependencies] dev = [ + { name = "bub", extra = ["app"] }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, @@ -228,9 +242,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.14.3" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, - { name = "inquirer-textual", specifier = ">=0.8.0" }, + { name = "aiohttp", marker = "extra == 'app'", specifier = ">=3.14.3" }, + { name = "aiohttp", marker = "extra == 'logfire'", specifier = ">=3.14.3" }, + { name = "httpx", extras = ["socks"], marker = "extra == 'app'", specifier = ">=0.28.1" }, + { name = "httpx", extras = ["socks"], marker = "extra == 'logfire'", specifier = ">=0.28.1" }, + { name = "inquirer-textual", marker = "extra == 'app'", specifier = ">=0.8.0" }, + { name = "inquirer-textual", marker = "extra == 'logfire'", specifier = ">=0.8.0" }, { name = "logfire", marker = "extra == 'logfire'", specifier = ">=4.32.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "opentelemetry-api", marker = "extra == 'logfire'", specifier = ">=1.39.0" }, @@ -239,22 +256,31 @@ requires-dist = [ { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'trace'", specifier = ">=1.39.0" }, { name = "opentelemetry-sdk", marker = "extra == 'logfire'", specifier = ">=1.39.0" }, { name = "opentelemetry-sdk", marker = "extra == 'trace'", specifier = ">=1.39.0" }, - { name = "pluggy", specifier = ">=1.6.0" }, - { name = "prompt-toolkit", specifier = ">=3.0.52" }, + { name = "pluggy", marker = "extra == 'app'", specifier = ">=1.6.0" }, + { name = "pluggy", marker = "extra == 'logfire'", specifier = ">=1.6.0" }, + { name = "prompt-toolkit", marker = "extra == 'app'", specifier = ">=3.0.52" }, + { name = "prompt-toolkit", marker = "extra == 'logfire'", specifier = ">=3.0.52" }, { name = "pydantic", specifier = ">=2.12.5" }, - { name = "pydantic-settings", specifier = ">=2.14.2" }, - { name = "python-telegram-bot", specifier = ">=22.7" }, - { name = "pyyaml", specifier = ">=6.0.3" }, - { name = "rapidfuzz", specifier = ">=3.14.5" }, + { name = "pydantic-settings", marker = "extra == 'app'", specifier = ">=2.14.2" }, + { name = "pydantic-settings", marker = "extra == 'logfire'", specifier = ">=2.14.2" }, + { name = "python-telegram-bot", marker = "extra == 'app'", specifier = ">=22.7" }, + { name = "python-telegram-bot", marker = "extra == 'logfire'", specifier = ">=22.7" }, + { name = "pyyaml", marker = "extra == 'app'", specifier = ">=6.0.3" }, + { name = "pyyaml", marker = "extra == 'logfire'", specifier = ">=6.0.3" }, + { name = "rapidfuzz", marker = "extra == 'app'", specifier = ">=3.14.5" }, + { name = "rapidfuzz", marker = "extra == 'logfire'", specifier = ">=3.14.5" }, { name = "republic", specifier = ">=0.6.0a1" }, - { name = "rich", specifier = ">=14.3.4" }, - { name = "typer", specifier = ">=0.24.1" }, + { name = "rich", marker = "extra == 'app'", specifier = ">=14.3.4" }, + { name = "rich", marker = "extra == 'logfire'", specifier = ">=14.3.4" }, + { name = "typer", marker = "extra == 'app'", specifier = ">=0.24.1" }, + { name = "typer", marker = "extra == 'logfire'", specifier = ">=0.24.1" }, { name = "typing-extensions", specifier = ">=4.15.0" }, ] -provides-extras = ["logfire", "trace"] +provides-extras = ["app", "logfire", "trace"] [package.metadata.requires-dev] dev = [ + { name = "bub", extras = ["app"] }, { name = "mypy", specifier = ">=1.20.0" }, { name = "prek", specifier = ">=0.3.8" }, { name = "pytest", specifier = ">=9.0.3" }, diff --git a/website/public/install.ps1 b/website/public/install.ps1 index bfbfed39..4923d935 100644 --- a/website/public/install.ps1 +++ b/website/public/install.ps1 @@ -3,7 +3,7 @@ Set-StrictMode -Version Latest $UvInstallUrl = "https://astral.sh/uv/install.ps1" $DefaultPresetsUrl = "https://bub.build/presets.json" -$BubPackage = "bub" +$BubPackage = "bub[app]" $BubPython = "3.12" $InquirerPackage = "inquirer-textual==0.8.0" $script:UvPath = $null diff --git a/website/public/install.sh b/website/public/install.sh index 3357f4dd..4b9479b3 100755 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -4,7 +4,7 @@ set -euo pipefail readonly UV_INSTALL_URL="https://astral.sh/uv/install.sh" readonly DEFAULT_PRESETS_URL="https://bub.build/presets.json" -readonly BUB_PACKAGE="bub" +readonly BUB_PACKAGE="bub[app]" readonly BUB_PYTHON="3.12" readonly INQUIRER_PACKAGE="inquirer-textual==0.8.0" diff --git a/website/src/content/docs/docs/build/distribution.mdx b/website/src/content/docs/docs/build/distribution.mdx index 9f33d11d..1f47f5ed 100644 --- a/website/src/content/docs/docs/build/distribution.mdx +++ b/website/src/content/docs/docs/build/distribution.mdx @@ -55,15 +55,15 @@ def main() -> None: workspace.mkdir(parents=True, exist_ok=True) os.chdir(workspace) - from bub.__main__ import app + from bub.__main__ import main as bub_main - app() + bub_main() ``` Two points worth calling out: - The product resolves a sticky workspace before importing Bub, then `chdir`s into it. [`BubFramework`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py) snapshots `Path.cwd()` as its workspace at construction time, so the `chdir` must happen first. -- After that, the product hands off to `bub.__main__.app` — the same Typer app the bare `bub` command uses. Every plugin contributing `register_cli_commands` lights up automatically. +- After that, the product hands off to `bub.__main__.main` — the same entry point as the bare `bub` command, which builds the Typer app with `create_cli_app()` and reports a missing `bub[app]` extra. Every plugin contributing `register_cli_commands` lights up automatically. Wire the CLI to a script entry point: diff --git a/website/src/content/docs/docs/build/hooks.mdx b/website/src/content/docs/docs/build/hooks.mdx index d986a300..ec37d386 100644 --- a/website/src/content/docs/docs/build/hooks.mdx +++ b/website/src/content/docs/docs/build/hooks.mdx @@ -249,12 +249,12 @@ Route models, cap spend, and redact — `before_llm_call`: from dataclasses import replace from bub import hookimpl -from bub.hooks.interception import LlmCallDecision, LlmCallRequest +from bub.hooks.interception import LLMCallDecision, LLMCallRequest class RouteAndBudget: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state): + def before_llm_call(self, request: LLMCallRequest, state): # `state` persists for the session (it is not reset per turn), so # this is a session-wide budget. For a per-turn budget, reset the # counter yourself at a turn boundary (e.g. in build_prompt). @@ -262,7 +262,7 @@ class RouteAndBudget: state["session_llm_calls"] = calls + 1 if calls >= 10: # Skip the provider entirely; this text becomes the final output. - return LlmCallDecision.finish("LLM call budget exhausted for this session.") + return LLMCallDecision.finish("LLM call budget exhausted for this session.") # Rewrites are honored end-to-end: the provider receives this model # and max_tokens, and the tape records the effective model. return replace(request, model="openai:gpt-4o-mini", max_tokens=512) diff --git a/website/src/content/docs/docs/build/plugins.mdx b/website/src/content/docs/docs/build/plugins.mdx index 834b1f98..a3078950 100644 --- a/website/src/content/docs/docs/build/plugins.mdx +++ b/website/src/content/docs/docs/build/plugins.mdx @@ -41,7 +41,7 @@ Bub discovers plugins through Python entry points in the `bub` group. Add the en [project] name = "bub-myplugin" version = "0.1.0" -dependencies = ["bub>=0.3"] +dependencies = ["bub[app]>=0.3"] [project.entry-points."bub"] myplugin = "bub_myplugin.plugin" diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index c7dfe9e1..1e2fc35d 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -20,6 +20,8 @@ Install Bub in your Python 3.12+ project: uv add bub ``` +This installs only the kernel. Add the `app` extra (`bub[app]`) when you also need the `bub` command, channels, or plugins. + The API below describes this source checkout; use a release containing these interfaces or install the checkout. ## Create an agent @@ -32,8 +34,9 @@ from pathlib import Path import republic -from bub.core import Agent, Tool +from bub.core import Agent from bub.core.store import FileTapeStore +from bub.core.tools import Tool async def lookup_order(order_id: str) -> dict[str, str]: @@ -106,7 +109,7 @@ async with agent.run(prompt, tape="t") as stream: ``` `options` are `republic.ChatOptions` sent with every model call of the run; the loop adds the step's tools. -A `BeforeLlmCall` handler may redirect a call to another `provider:model` spec: a model of the same provider reuses +A `LLMCallStarted` handler may redirect a call to another `provider:model` spec: a model of the same provider reuses the run model's client, and another provider gets a client configured from Republic's environment. For a policy of your own, such as fallbacks, per-provider credentials, or a timeout for the whole call, pass an @@ -117,14 +120,14 @@ provider name and a `republic.Stream`. Bub's own `bub.builtin.model.BubModel` is ## Tools `Tool.from_callable()` derives a schema from annotations and a description from the docstring, without registering -the tool globally. The `@tool` decorator from `bub.core` builds the same `Tool` and also adds it to Bub's global +the tool globally. The `@tool` decorator from `bub.core.tools` builds the same `Tool` and also adds it to Bub's global registry, which Bub's own agent snapshots. A tool created with `context=True` receives a `ToolContext` with the run's `tape`, turn `state`, `run_id`, and event bus: ```python -from bub.core import ToolContext +from bub.core.tools import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -207,7 +210,7 @@ async with agent.run(",lookup_order order_id=A123", model=model, tape="customer- Leading and trailing whitespace is ignored and exactly one prefix is removed; a bare prefix raises `ValueError`. Prefixes must be non-empty and contain no whitespace. Prompts with media are never commands. Command tool calls emit -`BeforeToolCall` and `AfterToolCall` like model tool calls, so tool handlers apply to them; their `ToolContext` has +`ToolCallStarted` and `ToolCallFinished` like model tool calls, so tool handlers apply to them; their `ToolContext` has `command` set. `agent.run_command(tape, line)` runs a command on an open tape directly. ## Events @@ -215,17 +218,17 @@ Prefixes must be non-empty and contain no whitespace. Prompts with media are nev The loop reports its progress as events from `bub.core.events`; register handlers to observe or steer it. ```python -from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision +from bub.core.events import ToolCallFinished, ToolCallStarted, StepFinished, ToolCallDecision -@agent.events.on(BeforeToolCall) -def guard(event: BeforeToolCall) -> None: +@agent.events.on(ToolCallStarted) +def guard(event: ToolCallStarted) -> None: if event.call.tool == "lookup_order" and not event.call.arguments.get("order_id"): event.decision = ToolCallDecision.deny("order_id is required") -@agent.events.on(AfterToolCall) -async def audit(event: AfterToolCall) -> None: +@agent.events.on(ToolCallFinished) +async def audit(event: ToolCallFinished) -> None: print("tool", event.call.tool, event.result.duration_ms, "ms") @@ -235,12 +238,13 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat | Event | When | Handlers may | | ------------------------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `RunStarted` | A run begins | observe the model spec, prompt, and run metadata | +| `RunFinished` | The run's stream closes | observe the status (`ok`, `error`, or `cancelled`), step count, elapsed time, and error | | `StepStarted` / `StepFinished` | A model step begins / ends with a status | observe | -| `BeforeLlmCall` | Before each model call | replace `event.request` (model, messages, `max_tokens`), or set `event.decision = LlmCallDecision.finish(text)` to skip the call | -| `AfterLlmCall` | After each completed or failed model call | observe text, tool calls, usage, and errors | -| `BeforeToolCall` | Before each tool call | replace `event.call` to change arguments, or set `ToolCallDecision.replace(result)` / `deny(message)` | -| `AfterToolCall` | After each tool call, including denied and replaced ones | assign `event.result.result` to change what the model sees | -| `ChatCompleted` | A step's exchange is ready to record | observe | +| `LLMCallStarted` | Before each model call | replace `event.request` (model, messages, `max_tokens`), or set `event.decision = LLMCallDecision.finish(text)` to skip the call | +| `LLMCallFinished` | After each completed or failed model call | observe text, tool calls, usage, and errors | +| `ToolCallStarted` | Before each tool call | replace `event.call` to change arguments, or set `ToolCallDecision.replace(result)` / `deny(message)` | +| `ToolCallFinished` | After each tool call, including denied and replaced ones | assign `event.result.result` to change what the model sees | +| `ChatFinished` | A step's exchange is ready to record | observe | | `CommandFinished` | A command prompt ran (see [Commands](#commands)) | observe the command, status, and output | `agent.events.on(EventType, handler)` registers a sync or async handler and returns it; `@agent.events.on(EventType)` @@ -261,7 +265,8 @@ Inside a tool, the run's bus is `context.events`: ```python from dataclasses import dataclass -from bub.core import AgentEvent, ToolContext +from bub.core.events import AgentEvent +from bub.core.tools import ToolContext @dataclass(kw_only=True) @@ -351,7 +356,7 @@ the turn state: | `continue_prompt(tape, prompt, state)` | `None` | Return a user message for the next step after tool calls. | ```python -from bub.core import Tape +from bub.core.tape import Tape class TriageAgent(Agent): diff --git a/website/src/content/docs/docs/build/tools.mdx b/website/src/content/docs/docs/build/tools.mdx index 1cf8b08d..506883b9 100644 --- a/website/src/content/docs/docs/build/tools.mdx +++ b/website/src/content/docs/docs/build/tools.mdx @@ -14,7 +14,7 @@ This guide shows how to define a Bub **tool** — a Python function the model ca ## Define a tool -Decorate any function with `@tool`. The decorator builds a [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py) object, wraps it with timing logs, and inserts it into the central `REGISTRY`: +Decorate any function with `@tool`. The decorator builds a [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/core/tools.py) object, wraps it with timing logs, and inserts it into the central `REGISTRY`: ```python from bub import tool @@ -60,7 +60,7 @@ Code mode lets the model call tools from Python instead of one tool call at a ti - Bub writes a Python stub with one function per allowed `auto` or `code` tool — parameter and return types plus docstring — under `~/.bub/codemode/` and puts its path in the system prompt. The path stays the same for a session as long as its tool set does not change. - `run_code(code, timeout_seconds=120)` returns what the code writes to stdout. An uncaught exception becomes a tool error whose details carry the printed output and the traceback. - The code also gets a `republic` module (preset as a global and importable), documented at the end of the stub. It has the media classes `republic.Image`, `Audio` and `Video`. `republic.get_model(spec=None)` returns a chat model — without a spec, the model running the session — with `chat()` and `stream()` (`async with model.stream(...) as stream: async for event in stream`, events from `republic.events`), and `republic.get_embedding_model(spec)` and `republic.get_decision_model(spec)` return embedding and decision models. These are proxies: requests are made by Bub with its provider settings and credentials, which never reach the code. -- The code-only tools `tools.attach_image(image=...)`, `tools.attach_audio(audio=...)` and `tools.attach_video(video=...)` attach media to the `run_code` result, so the model can look at a chart or screenshot the code produced. Besides a `republic` media object, they accept a file path in the environment (relative to its workspace), an http(s)/gs URL or a data URL, and load it themselves, reading files with the environment's `read_bytes`. A result with media is recorded as `{"type": "tool_content", "content": [...]}` (build one with `bub.tools.content_result`) and sent to the model as a multimodal tool message; spilling bounds only its text. +- The code-only tools `tools.attach_image(image=...)`, `tools.attach_audio(audio=...)` and `tools.attach_video(video=...)` attach media to the `run_code` result, so the model can look at a chart or screenshot the code produced. Besides a `republic` media object, they accept a file path in the environment (relative to its workspace), an http(s)/gs URL or a data URL, and load it themselves, reading files with the environment's `read_bytes`. A result with media is recorded as `{"type": "tool_content", "content": [...]}` (build one with `bub.core.tools.content_result`) and sent to the model as a multimodal tool message; spilling bounds only its text. If `run_code` is not allowed (for example, a subagent restricted with `allowed_tools`), that turn falls back to direct tool calls. @@ -101,10 +101,10 @@ Provide one per session with the `provide_environment(session_id, workspace)` ho ## How tools become available -The `REGISTRY` lives in [`bub.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py): +The `REGISTRY` lives in [`bub.core.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/core/tools.py): ```python -# from src/bub/tools.py +# from src/bub/core/tools.py REGISTRY: dict[str, Tool] = {} ``` @@ -143,7 +143,7 @@ See [Surfaces](/docs/concepts/surfaces/) for the full split between operator and Tools do not appear in `bub hooks`, but you can confirm registration with a Python one-liner: ```bash -uv run python -c "import bub_myplugin.plugin; from bub.tools import REGISTRY; print(sorted(REGISTRY))" +uv run python -c "import bub_myplugin.plugin; from bub.core.tools import REGISTRY; print(sorted(REGISTRY))" ``` The output should include your tool's name. From there, run a turn that asks the model to call it. diff --git a/website/src/content/docs/docs/concepts/tape-and-context.mdx b/website/src/content/docs/docs/concepts/tape-and-context.mdx index 55ecedaf..63d7d2ca 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -46,7 +46,7 @@ tape_name = f"{workspace_hash}__{session_hash}" This means the same `session_id` produces a different tape in a different workspace, so two repos do not share state by accident. -The default `provide_tape_store` returns a `FileTapeStore` rooted at `~/.bub/tapes/`. Plugins replace it by providing their own `provide_tape_store` (for example, a SQLite or HTTP-backed store). +The default `provide_tape_store` returns a `FuzzyFileTapeStore` (from `bub.builtin.store`) rooted at `~/.bub/tapes/`: a `bub.core.store.FileTapeStore` whose text queries return the newest unique matches first and also match approximately. Plugins replace it by providing their own `provide_tape_store` (for example, a SQLite or HTTP-backed store). ### Spill sidecar @@ -69,13 +69,13 @@ Before the first turn on a tape, `TapeService.ensure_bootstrap_anchor` checks fo `default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and returns a list of `republic.Message` objects. The mapping is: - `anchor` → an `assistant` message of the form `[Anchor created: ]: `. -- `message` → the entry payload, converted with `bub.tape.to_message`. New user messages are stored as `republic.Message.to_dict()`; older Bub payloads (media URLs, `tool_call_id`, `reasoning`, `provider_data`) are still read. +- `message` → the entry payload, converted with `bub.core.tape.to_message`. New user messages are stored as `republic.Message.to_dict()`; older Bub payloads (media URLs, `tool_call_id`, `reasoning`, `provider_data`) are still read. - `tool_call` → one `assistant` message with the saved text (or empty content for calls without text, including older entries) and a `tool_calls` array. - `tool_result` → one `republic.tool(...)` message per result, paired with the preceding `tool_call` entry's calls by position. Results without a recorded call are skipped. Saved reasoning and provider data are always included; Republic sends them back only to API formats that accept them. -The context selector is a hook (`build_tape_context`), so plugins can replace it with a different strategy — compaction, summarization, retrieval — without touching the rest of the pipeline. A custom `select` must return Republic messages (or an awaitable of them); `bub.tape.to_messages` converts stored message payloads. +The context selector is a hook (`build_tape_context`), so plugins can replace it with a different strategy — compaction, summarization, retrieval — without touching the rest of the pipeline. A custom `select` must return Republic messages (or an awaitable of them); `bub.core.tape.to_messages` converts stored message payloads. Entries marked `context=False` are removed before the selector runs. This is how operational spill events remain queryable on the tape without appearing in either the default context or a plugin-defined context. diff --git a/website/src/content/docs/docs/getting-started/first-plugin.mdx b/website/src/content/docs/docs/getting-started/first-plugin.mdx index aed48413..9cc76662 100644 --- a/website/src/content/docs/docs/getting-started/first-plugin.mdx +++ b/website/src/content/docs/docs/getting-started/first-plugin.mdx @@ -38,7 +38,7 @@ build-backend = "hatchling.build" name = "bub-echo-plugin" version = "0.1.0" requires-python = ">=3.12" -dependencies = ["bub>=0.1"] +dependencies = ["bub[app]>=0.1"] [project.entry-points."bub"] echo = "bub_echo_plugin.plugin:echo_plugin" diff --git a/website/src/content/docs/docs/getting-started/install.mdx b/website/src/content/docs/docs/getting-started/install.mdx index 2c636fd3..8e1eddda 100644 --- a/website/src/content/docs/docs/getting-started/install.mdx +++ b/website/src/content/docs/docs/getting-started/install.mdx @@ -76,16 +76,18 @@ You can inspect the scripts and the versioned preset catalog before running them If `uv` is already available, the equivalent user-level installation is: ```bash -uv tool install "bub@latest" +uv tool install "bub[app]@latest" uv tool update-shell ``` If you prefer `pip`, the equivalent is: ```bash -pip install bub +pip install "bub[app]" ``` +The `app` extra brings the `bub` command, channels, plugins and the builtin agent. A plain `bub` install contains only the agent kernel `bub.core` for [embedding in Python](/docs/build/sdk/); its `bub` command asks for the extra. + ## 3. Install from source Use this path if you want to contribute to the framework or follow `main`: diff --git a/website/src/content/docs/docs/operate/deploy.mdx b/website/src/content/docs/docs/operate/deploy.mdx index 74869b63..00a1b54b 100644 --- a/website/src/content/docs/docs/operate/deploy.mdx +++ b/website/src/content/docs/docs/operate/deploy.mdx @@ -56,7 +56,7 @@ docker compose up -d --build docker compose logs -f app ``` -The first run takes a few minutes — the image installs system tools (git, node, jq, etc.) and runs `uv sync --no-dev --no-editable`. +The first run takes a few minutes — the image installs system tools (git, node, jq, etc.) and runs `uv sync --no-dev --no-editable --extra app`. ## 4. What `entrypoint.sh` does diff --git a/website/src/content/docs/docs/reference/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx index 5c6f6dc0..e0be40b0 100644 --- a/website/src/content/docs/docs/reference/hooks.mdx +++ b/website/src/content/docs/docs/reference/hooks.mdx @@ -36,8 +36,8 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi | `provide_channels` | sync-only consumer (deduped) | `(message_handler: MessageHandler) -> list[Channel]` | channels | `BubFramework.get_channels` (`call_many_sync`) | Channels are deduplicated by `Channel.name`; the first channel seen in hook priority order wins. | | `build_tape_context` | firstresult | `() -> TapeContext` | tape context | `BubFramework.build_tape_context` (`call_first_sync`) | Sync-only; awaitable returns are skipped. | | `admit_message` | firstresult | `(session_id, message, turn) -> AdmitDecision \| None` | turn admission decision | `ChannelManager` | Runs before channel scheduling. `None` keeps default concurrent scheduling; decision types are listed in [Core contracts](/docs/reference/types/). | -| `before_llm_call` | chained | `(request: LlmCallRequest, state: TurnState) -> LlmCallRequest \| LlmCallDecision \| None` | modified request or finish decision | core `Agent` via `PluginAgentHooks` | Impls chain in LIFO order; each sees the previous impl's request. `LlmCallDecision.finish(text)` skips the provider call. Raising impls are logged and skipped. | -| `after_llm_call` | observer | `(request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None` | none | core `Agent` via `PluginAgentHooks` | Fires exactly once per completed call: success or `Exception` failure. Cancellation / consumer `aclose()` is not observed. `result.error` is the original exception. | +| `before_llm_call` | chained | `(request: LLMCallRequest, state: TurnState) -> LLMCallRequest \| LLMCallDecision \| None` | modified request or finish decision | core `Agent` via `PluginAgentHooks` | Impls chain in LIFO order; each sees the previous impl's request. `LLMCallDecision.finish(text)` skips the provider call. Raising impls are logged and skipped. | +| `after_llm_call` | observer | `(request: LLMCallRequest, result: LLMCallResult, state: TurnState) -> None` | none | core `Agent` via `PluginAgentHooks` | Fires exactly once per completed call: success or `Exception` failure. Cancellation / consumer `aclose()` is not observed. `result.error` is the original exception. | | `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` via `PluginAgentHooks` | Per tool invocation. `proceed(arguments=…)` folds argument changes; `replace(result)` / `deny(message)` short-circuit. Veto only via the decision object — exceptions are logged and skipped. | | `after_tool_call` | terminal handler | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | none | `ToolExecutor` via `PluginAgentHooks` | Fires for success, failure and deny/replace. Cancellation is not observed. Assigning `result.result` replaces the model-facing value; `result.error` remains the original `BubError` for failures. | @@ -128,13 +128,13 @@ Each `provide_tape_sidecar` implementation contributes one named capability back ### Agent-loop hooks (`PluginAgentHooks`) -The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` events (`bub.core.events`), not `call_first`/`call_many`, with three deliberate differences: +The four `*_llm_call` / `*_tool_call` hooks are dispatched through the `PluginAgentHooks` adapter (`src/bub/hooks/interception.py`), which subscribes to the core agent's `LLMCallStarted`/`LLMCallFinished`/`ToolCallStarted`/`ToolCallFinished` events (`bub.core.events`), not `call_first`/`call_many`, with three deliberate differences: -- **Chaining** — `before_llm_call` and `before_tool_call` run every implementation in LIFO order and *fold* modifications: each impl receives the request/call as modified by the impls before it. The first short-circuiting decision (`LlmCallDecision.finish`, `ToolCallDecision.replace`/`deny`) stops the chain. +- **Chaining** — `before_llm_call` and `before_tool_call` run every implementation in LIFO order and *fold* modifications: each impl receives the request/call as modified by the impls before it. The first short-circuiting decision (`LLMCallDecision.finish`, `ToolCallDecision.replace`/`deny`) stops the chain. - **Fault isolation** — a raising implementation is logged (`hook.agent_hook_failed`) and skipped, never fatal to the turn. Blocking is only expressible through decision objects, so a broken policy plugin cannot veto by crashing. - **Exactly-once terminal handling** — `after_llm_call` and `after_tool_call` fire exactly once per call for real completions: success or `Exception` failure. Cancellation and consumer close (`BaseException`) intentionally bypass after hooks. `result.error` carries the **original exception object** (a `BubError` for tool failures with `kind`/`details` intact). `after_tool_call` implementations share one outcome, so assigning `result.result` changes the model-facing value seen by later implementations while leaving any failure in `result.error` intact. -Payload dataclasses (`LlmCallRequest`, `LlmCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LlmCallDecision`) live in `src/bub/core/events.py` and are re-exported by `bub.hooks.interception`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. +Payload dataclasses (`LLMCallRequest`, `LLMCallResult`, `ToolCall`, `ToolCallDecision`, `ToolCallResult`, `LLMCallDecision`) live in `src/bub/core/events.py` and are re-exported by `bub.hooks.interception`. Every payload carries `run_id`, matching the `run_id` meta on tape entries, so observers can correlate hook events with the tape. Rewritten `model`/`max_tokens` from `before_llm_call` are honored end-to-end: the provider receives them and the tape records the effective model. A continuation-style `wrap_tool_call` (retry / human-in-the-loop / caching) was prototyped in the original PR and removed by review direction; if those use cases materialize it will return as a separate proposal. diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index e783ebf8..54eb23cd 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -86,16 +86,16 @@ Hook declarations and markers live in `bub.hooks`; agent-loop interception paylo ```python from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl, hookspec from bub.hooks.interception import ( - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallRequest, + LLMCallResult, PluginAgentHooks, ToolCall, ToolCallDecision, ToolCallResult, ) from bub.hooks.runtime import HookRuntime -from bub.sidecars import TapeSidecar, sidecar_tape_name +from bub.core.sidecars import TapeSidecar, sidecar_tape_name ``` Plugin authors normally need only `hookimpl` plus the payload types used by their hooks. `TapeSidecar` is the minimal named contract returned by `provide_tape_sidecar`; `sidecar_tape_name` resolves its sibling tape name. See the [Hook reference](/docs/reference/hooks/) for dispatch and fault-isolation semantics. diff --git a/website/src/content/docs/docs/tutorials/fastapi.mdx b/website/src/content/docs/docs/tutorials/fastapi.mdx index 738fe60d..e068ed67 100644 --- a/website/src/content/docs/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/docs/tutorials/fastapi.mdx @@ -32,8 +32,9 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.core import Agent, Tool +from bub.core import Agent from bub.core.store import FileTapeStore +from bub.core.tools import Tool async def lookup_order(order_id: str) -> dict[str, str]: diff --git a/website/src/content/docs/docs/tutorials/observability.mdx b/website/src/content/docs/docs/tutorials/observability.mdx index 21375da5..a89861e9 100644 --- a/website/src/content/docs/docs/tutorials/observability.mdx +++ b/website/src/content/docs/docs/tutorials/observability.mdx @@ -22,7 +22,7 @@ You need: - The `trace` extra installed in Bub's activated virtual environment before starting Bub with Phoenix: ```bash -uv pip install "bub[trace]" +uv pip install "bub[app,trace]" ``` The `trace` extra includes the OpenTelemetry API, SDK, and HTTP/protobuf OTLP exporter. The Phoenix walkthrough below requires no Logfire installation or configuration. @@ -144,7 +144,7 @@ Use both when debugging production behavior: start with `,tape.info` to understa ## Send trajectories to Logfire -To send trajectories to Logfire, install the `logfire` extra, which includes `bub[trace]` and Logfire. With no explicit OTLP endpoint configured, Bub's CLI configures Logfire at startup; no tracing plugin is required. +To send trajectories to Logfire, install the `logfire` extra, which includes `bub[app,trace]` and Logfire. With no explicit OTLP endpoint configured, Bub's CLI configures Logfire at startup; no tracing plugin is required. When switching from Phoenix, clear both OTLP endpoint variables, authenticate with your Logfire project, then run Bub: diff --git a/website/src/content/docs/zh-cn/docs/build/distribution.mdx b/website/src/content/docs/zh-cn/docs/build/distribution.mdx index c0ef09e3..47b6e219 100644 --- a/website/src/content/docs/zh-cn/docs/build/distribution.mdx +++ b/website/src/content/docs/zh-cn/docs/build/distribution.mdx @@ -55,15 +55,15 @@ def main() -> None: workspace.mkdir(parents=True, exist_ok=True) os.chdir(workspace) - from bub.__main__ import app + from bub.__main__ import main as bub_main - app() + bub_main() ``` 两点值得指出: - 产品在导入 Bub 之前先解析出粘性工作区,然后 `chdir` 进去。[`BubFramework`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py) 在构造时把 `Path.cwd()` 快照为工作区,所以 `chdir` 必须先发生。 -- 之后产品把控制权交给 `bub.__main__.app` —— 与裸 `bub` 命令使用的是同一个 Typer 应用。每个贡献 `register_cli_commands` 的插件都会自动接入。 +- 之后产品把控制权交给 `bub.__main__.main` —— 与裸 `bub` 命令使用的是同一个入口,它用 `create_cli_app()` 构建 Typer 应用,并在缺少 `bub[app]` extra 时给出提示。每个贡献 `register_cli_commands` 的插件都会自动接入。 把 CLI 接入脚本入口: diff --git a/website/src/content/docs/zh-cn/docs/build/hooks.mdx b/website/src/content/docs/zh-cn/docs/build/hooks.mdx index 55c933cf..d0eac9e0 100644 --- a/website/src/content/docs/zh-cn/docs/build/hooks.mdx +++ b/website/src/content/docs/zh-cn/docs/build/hooks.mdx @@ -249,12 +249,12 @@ agent loop 暴露四个拦截钩子(完整契约见 [参考 › 钩子](/zh-cn/d from dataclasses import replace from bub import hookimpl -from bub.hooks.interception import LlmCallDecision, LlmCallRequest +from bub.hooks.interception import LLMCallDecision, LLMCallRequest class RouteAndBudget: @hookimpl - def before_llm_call(self, request: LlmCallRequest, state): + def before_llm_call(self, request: LLMCallRequest, state): # `state` persists for the session (it is not reset per turn), so # this is a session-wide budget. For a per-turn budget, reset the # counter yourself at a turn boundary (e.g. in build_prompt). @@ -262,7 +262,7 @@ class RouteAndBudget: state["session_llm_calls"] = calls + 1 if calls >= 10: # Skip the provider entirely; this text becomes the final output. - return LlmCallDecision.finish("LLM call budget exhausted for this session.") + return LLMCallDecision.finish("LLM call budget exhausted for this session.") # Rewrites are honored end-to-end: the provider receives this model # and max_tokens, and the tape records the effective model. return replace(request, model="openai:gpt-4o-mini", max_tokens=512) diff --git a/website/src/content/docs/zh-cn/docs/build/plugins.mdx b/website/src/content/docs/zh-cn/docs/build/plugins.mdx index 9ee35879..e56336c7 100644 --- a/website/src/content/docs/zh-cn/docs/build/plugins.mdx +++ b/website/src/content/docs/zh-cn/docs/build/plugins.mdx @@ -41,7 +41,7 @@ Bub 通过 `bub` 组的 Python 入口点发现插件。在 `pyproject.toml` 里 [project] name = "bub-myplugin" version = "0.1.0" -dependencies = ["bub>=0.3"] +dependencies = ["bub[app]>=0.3"] [project.entry-points."bub"] myplugin = "bub_myplugin.plugin" diff --git a/website/src/content/docs/zh-cn/docs/build/sdk.md b/website/src/content/docs/zh-cn/docs/build/sdk.md index 1b67ed00..3097e792 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -19,6 +19,8 @@ Bub 自身的运行时也构建在这个内核之上:`bub.builtin.BubAgent` uv add bub ``` +这只会安装内核。如果还需要 `bub` 命令、channel 或插件,请安装 `app` extra(`bub[app]`)。 + 下文描述的是当前源码中的 API;请使用包含这些接口的发布版本,或安装当前源码。 ## 创建 Agent @@ -31,8 +33,9 @@ from pathlib import Path import republic -from bub.core import Agent, Tool +from bub.core import Agent from bub.core.store import FileTapeStore +from bub.core.tools import Tool async def lookup_order(order_id: str) -> dict[str, str]: @@ -103,7 +106,7 @@ async with agent.run(prompt, tape="t") as stream: ... ``` -`options` 是随本次运行每次模型调用发送的 `republic.ChatOptions`;循环会加入本步的工具。`BeforeLlmCall` +`options` 是随本次运行每次模型调用发送的 `republic.ChatOptions`;循环会加入本步的工具。`LLMCallStarted` 处理器可以把一次调用重定向到其他 `provider:model`:同一 provider 的模型复用运行模型的客户端,其他 provider 使用从 Republic 环境变量配置的客户端。 @@ -114,13 +117,13 @@ Bub 自己的 `bub.builtin.model.BubModel` 就是这样的客户端。 ## 工具 -`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core` 中的 +`Tool.from_callable()` 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。`bub.core.tools` 中的 `@tool` 装饰器生成同样的 `Tool`,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。 以 `context=True` 创建的工具会收到 `ToolContext`,其中包含本次运行的 `tape`、turn `state`、`run_id` 和事件总线: ```python -from bub.core import ToolContext +from bub.core.tools import ToolContext def remember(note: str, *, context: ToolContext) -> str: @@ -197,7 +200,7 @@ async with agent.run(",lookup_order order_id=A123", model=model, tape="customer- ``` 首尾空白会被忽略,且只去掉一个前缀;只有前缀时会抛出 `ValueError`。前缀必须非空且不含空白。带媒体的 prompt 不会作为命令。 -命令调用工具时和模型调用工具一样发出 `BeforeToolCall` 和 `AfterToolCall`,因此工具处理器同样作用于它们;其 +命令调用工具时和模型调用工具一样发出 `ToolCallStarted` 和 `ToolCallFinished`,因此工具处理器同样作用于它们;其 `ToolContext` 的 `command` 为真。 `agent.run_command(tape, line)` 可以在已打开的 tape 上直接执行命令。 @@ -206,17 +209,17 @@ async with agent.run(",lookup_order order_id=A123", model=model, tape="customer- 循环通过 `bub.core.events` 中的事件报告进度;注册处理器即可观察或干预它。 ```python -from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision +from bub.core.events import ToolCallFinished, ToolCallStarted, StepFinished, ToolCallDecision -@agent.events.on(BeforeToolCall) -def guard(event: BeforeToolCall) -> None: +@agent.events.on(ToolCallStarted) +def guard(event: ToolCallStarted) -> None: if event.call.tool == "lookup_order" and not event.call.arguments.get("order_id"): event.decision = ToolCallDecision.deny("order_id is required") -@agent.events.on(AfterToolCall) -async def audit(event: AfterToolCall) -> None: +@agent.events.on(ToolCallFinished) +async def audit(event: ToolCallFinished) -> None: print("tool", event.call.tool, event.result.duration_ms, "ms") @@ -226,12 +229,13 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat | 事件 | 时机 | 处理器可以 | | ------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `RunStarted` | 一次运行开始 | 观察模型 spec、prompt 和运行 metadata | +| `RunFinished` | 运行的 stream 关闭 | 观察状态(`ok`、`error` 或 `cancelled`)、步数、耗时和错误 | | `StepStarted` / `StepFinished` | 一个模型步骤开始 / 以某个状态结束 | 观察 | -| `BeforeLlmCall` | 每次模型调用之前 | 替换 `event.request`(模型、消息、`max_tokens`),或设置 `event.decision = LlmCallDecision.finish(text)` 跳过调用 | -| `AfterLlmCall` | 每次模型调用完成或失败之后 | 观察文本、工具调用、用量和错误 | -| `BeforeToolCall` | 每次工具调用之前 | 替换 `event.call` 以修改参数,或设置 `ToolCallDecision.replace(result)` / `deny(message)` | -| `AfterToolCall` | 每次工具调用之后,包括被拒绝和被替换的调用 | 赋值 `event.result.result` 以改变模型看到的结果 | -| `ChatCompleted` | 一个步骤的对话已可记录 | 观察 | +| `LLMCallStarted` | 每次模型调用之前 | 替换 `event.request`(模型、消息、`max_tokens`),或设置 `event.decision = LLMCallDecision.finish(text)` 跳过调用 | +| `LLMCallFinished` | 每次模型调用完成或失败之后 | 观察文本、工具调用、用量和错误 | +| `ToolCallStarted` | 每次工具调用之前 | 替换 `event.call` 以修改参数,或设置 `ToolCallDecision.replace(result)` / `deny(message)` | +| `ToolCallFinished` | 每次工具调用之后,包括被拒绝和被替换的调用 | 赋值 `event.result.result` 以改变模型看到的结果 | +| `ChatFinished` | 一个步骤的对话已可记录 | 观察 | | `CommandFinished` | 一个命令 prompt 执行完毕(见 [命令](#命令)) | 观察命令、状态和输出 | `agent.events.on(EventType, handler)` 注册同步或异步处理器并返回它;`@agent.events.on(EventType)` 以装饰器形式完成同样的注册, @@ -250,7 +254,8 @@ agent.events.on(StepFinished, lambda event: print("step", event.step, event.stat ```python from dataclasses import dataclass -from bub.core import AgentEvent, ToolContext +from bub.core.events import AgentEvent +from bub.core.tools import ToolContext @dataclass(kw_only=True) @@ -335,7 +340,7 @@ async with agent.run("Check order A123", model=model, tape="customer-42") as str | `continue_prompt(tape, prompt, state)` | `None` | 在工具调用后返回下一步的用户消息。 | ```python -from bub.core import Tape +from bub.core.tape import Tape class TriageAgent(Agent): diff --git a/website/src/content/docs/zh-cn/docs/build/tools.mdx b/website/src/content/docs/zh-cn/docs/build/tools.mdx index e7b82f38..cf675b49 100644 --- a/website/src/content/docs/zh-cn/docs/build/tools.mdx +++ b/website/src/content/docs/zh-cn/docs/build/tools.mdx @@ -14,7 +14,7 @@ sidebar: ## 定义工具 -用 `@tool` 装饰任意函数。装饰器会构造一个 [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py) 对象,套上计时日志,并写入中央 `REGISTRY`: +用 `@tool` 装饰任意函数。装饰器会构造一个 [`Tool`](https://github.com/bubbuild/bub/blob/main/src/bub/core/tools.py) 对象,套上计时日志,并写入中央 `REGISTRY`: ```python from bub import tool @@ -60,7 +60,7 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 - Bub 会在 `~/.bub/codemode/` 下为允许的 `auto` 和 `code` 工具生成一个 Python stub 文件,每个工具对应一个函数,包含参数类型、返回类型和 docstring,并把文件路径写入 system prompt。只要工具集合不变,同一个 session 中这个路径保持不变。 - `run_code(code, timeout_seconds=120)` 返回代码写到 stdout 的内容。未捕获的异常会变成工具错误,错误详情中包含已打印的输出和 traceback。 - 代码里还有一个 `republic` 模块(已作为全局变量预置,也可以 `import`),其用法写在 stub 文件末尾。其中有媒体类 `republic.Image`、`Audio` 和 `Video`。`republic.get_model(spec=None)` 返回对话模型——不传 spec 时就是当前会话使用的模型——支持 `chat()` 和 `stream()`(`async with model.stream(...) as stream: async for event in stream`,事件类型在 `republic.events` 中);`republic.get_embedding_model(spec)` 和 `republic.get_decision_model(spec)` 分别返回 embedding 模型和决策模型。它们都是代理:请求由 Bub 使用自己的 provider 配置和凭证发出,凭证不会进入代码。 -- 仅代码可用的工具 `tools.attach_image(image=...)`、`tools.attach_audio(audio=...)` 和 `tools.attach_video(video=...)` 会把媒体附加到这次 `run_code` 的结果里,模型因此可以看到代码生成的图表或截图。除了 `republic` 媒体对象,它们也接受执行环境内的文件路径(相对路径基于其 workspace)、http(s)/gs URL 或 data URL,并自行加载,文件通过执行环境的 `read_bytes` 读取。带媒体的结果以 `{"type": "tool_content", "content": [...]}` 记录(可用 `bub.tools.content_result` 构造),并作为多模态工具消息发给模型;spill 只截断其中的文本。 +- 仅代码可用的工具 `tools.attach_image(image=...)`、`tools.attach_audio(audio=...)` 和 `tools.attach_video(video=...)` 会把媒体附加到这次 `run_code` 的结果里,模型因此可以看到代码生成的图表或截图。除了 `republic` 媒体对象,它们也接受执行环境内的文件路径(相对路径基于其 workspace)、http(s)/gs URL 或 data URL,并自行加载,文件通过执行环境的 `read_bytes` 读取。带媒体的结果以 `{"type": "tool_content", "content": [...]}` 记录(可用 `bub.core.tools.content_result` 构造),并作为多模态工具消息发给模型;spill 只截断其中的文本。 如果 `run_code` 不在允许范围内(例如被 `allowed_tools` 限制的 subagent),该 turn 会退回直接调用工具的方式。 @@ -102,10 +102,10 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 ## 工具如何变得可用 -`REGISTRY` 位于 [`bub.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/tools.py): +`REGISTRY` 位于 [`bub.core.tools`](https://github.com/bubbuild/bub/blob/main/src/bub/core/tools.py): ```python -# 来自 src/bub/tools.py +# 来自 src/bub/core/tools.py REGISTRY: dict[str, Tool] = {} ``` @@ -144,7 +144,7 @@ from . import tools # noqa: F401 —— 触发 @tool 注册 工具不会出现在 `bub hooks` 中,但可以用一行 Python 验证注册: ```bash -uv run python -c "import bub_myplugin.plugin; from bub.tools import REGISTRY; print(sorted(REGISTRY))" +uv run python -c "import bub_myplugin.plugin; from bub.core.tools import REGISTRY; print(sorted(REGISTRY))" ``` 输出应包含你的工具名。然后运行一次让模型调用它的轮次。 diff --git a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx index c0d730cc..2a70d676 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx @@ -46,7 +46,7 @@ tape_name = f"{workspace_hash}__{session_hash}" 这意味着同一个 `session_id` 在不同 workspace 会得到不同 tape,两个仓库不会互相串 state。 -默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FileTapeStore`。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。 +默认的 `provide_tape_store` 返回根目录在 `~/.bub/tapes/` 的 `FuzzyFileTapeStore`(来自 `bub.builtin.store`):它是 `bub.core.store.FileTapeStore` 的子类,文本查询按从新到旧返回去重后的结果,并支持近似匹配。插件通过提供自己的 `provide_tape_store`(例如 SQLite 或 HTTP 后端)来替换它。 ### spill sidecar @@ -69,13 +69,13 @@ spill 写入以 `spill.write` event 记录。框架管理的生命周期结果 `default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并返回 `republic.Message` 列表。映射规则如下: - `anchor` → 形如 `[Anchor created: ]: ` 的 `assistant` 消息。 -- `message` → 用 `bub.tape.to_message` 转换 entry payload。新的用户消息以 `republic.Message.to_dict()` 格式存储;旧版 Bub payload(媒体 URL、`tool_call_id`、`reasoning`、`provider_data`)仍可读取。 +- `message` → 用 `bub.core.tape.to_message` 转换 entry payload。新的用户消息以 `republic.Message.to_dict()` 格式存储;旧版 Bub payload(媒体 URL、`tool_call_id`、`reasoning`、`provider_data`)仍可读取。 - `tool_call` → 一条包含已保存文本与 `tool_calls` 数组的 `assistant` 消息;无文本的调用(包括旧版 entry)使用空 content。 - `tool_result` → 每个 result 一条 `republic.tool(...)` 消息,按位置与前一个 `tool_call` entry 中的 call 配对;没有对应 call 的 result 会被跳过。 已保存的 reasoning 与 provider data 总是会带上;Republic 只会把它们发回接受这些数据的 API format。 -context selector 本身是个 hook(`build_tape_context`)。自定义 `select` 必须返回 Republic 消息(或返回它们的 awaitable);`bub.tape.to_messages` 可转换已存储的消息 payload。插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 +context selector 本身是个 hook(`build_tape_context`)。自定义 `select` 必须返回 Republic 消息(或返回它们的 awaitable);`bub.core.tape.to_messages` 可转换已存储的消息 payload。插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 带有 `context=False` 标记的 entry 会在 selector 运行前被移除。spill 运维 event 因此仍可在 tape 上查询,但不会进入默认 context 或插件自定义 context。 diff --git a/website/src/content/docs/zh-cn/docs/getting-started/first-plugin.mdx b/website/src/content/docs/zh-cn/docs/getting-started/first-plugin.mdx index c456956f..7ac97683 100644 --- a/website/src/content/docs/zh-cn/docs/getting-started/first-plugin.mdx +++ b/website/src/content/docs/zh-cn/docs/getting-started/first-plugin.mdx @@ -38,7 +38,7 @@ build-backend = "hatchling.build" name = "bub-echo-plugin" version = "0.1.0" requires-python = ">=3.12" -dependencies = ["bub>=0.1"] +dependencies = ["bub[app]>=0.1"] [project.entry-points."bub"] echo = "bub_echo_plugin.plugin:echo_plugin" diff --git a/website/src/content/docs/zh-cn/docs/getting-started/install.mdx b/website/src/content/docs/zh-cn/docs/getting-started/install.mdx index 10acbdd0..1646d5f8 100644 --- a/website/src/content/docs/zh-cn/docs/getting-started/install.mdx +++ b/website/src/content/docs/zh-cn/docs/getting-started/install.mdx @@ -75,16 +75,18 @@ import { TabItem, Tabs } from '@astrojs/starlight/components'; 如果系统中已有 `uv`,等价的用户级安装命令是: ```bash -uv tool install "bub@latest" +uv tool install "bub[app]@latest" uv tool update-shell ``` 如果你倾向使用 `pip`,等价命令是: ```bash -pip install bub +pip install "bub[app]" ``` +`app` extra 提供 `bub` 命令、channel、插件和 builtin agent。不带 extra 安装的 `bub` 只包含 agent 内核 `bub.core`,用于[在 Python 中嵌入](/zh-cn/docs/build/sdk/);此时运行 `bub` 命令会提示安装该 extra。 + ## 3. 从源码安装 如果你想为框架贡献代码或跟随 `main` 分支,使用本路径: diff --git a/website/src/content/docs/zh-cn/docs/operate/deploy.mdx b/website/src/content/docs/zh-cn/docs/operate/deploy.mdx index e4897530..edf8bc99 100644 --- a/website/src/content/docs/zh-cn/docs/operate/deploy.mdx +++ b/website/src/content/docs/zh-cn/docs/operate/deploy.mdx @@ -56,7 +56,7 @@ docker compose up -d --build docker compose logs -f app ``` -首次构建需要几分钟 —— 镜像会安装系统工具(git、node、jq 等)并执行 `uv sync --no-dev --no-editable`。 +首次构建需要几分钟 —— 镜像会安装系统工具(git、node、jq 等)并执行 `uv sync --no-dev --no-editable --extra app`。 ## 4. `entrypoint.sh` 做了什么 diff --git a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx index fbace925..6c9686de 100644 --- a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx @@ -36,8 +36,8 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用 | `provide_channels` | sync-only consumer (deduped) | `(message_handler: MessageHandler) -> list[Channel]` | channels | `BubFramework.get_channels` (`call_many_sync`) | 按 `Channel.name` 去重;在钩子优先级顺序中最先出现的 channel 胜出。 | | `build_tape_context` | firstresult | `() -> TapeContext` | tape context | `BubFramework.build_tape_context` (`call_first_sync`) | 仅同步;awaitable 返回会被跳过。 | | `admit_message` | firstresult | `(session_id, message, turn) -> AdmitDecision \| None` | turn admission decision | `ChannelManager` | 调度 channel message 前调用。返回 `None` 保持默认并发调度;decision 类型见 [核心契约](/zh-cn/docs/reference/types/)。 | -| `before_llm_call` | chained | `(request: LlmCallRequest, state: TurnState) -> LlmCallRequest \| LlmCallDecision \| None` | 修改后的 request 或 finish 决定 | core `Agent` 经 `PluginAgentHooks` | 实现按 LIFO 链式执行,每个实现看到的是前一个实现修改后的 request。返回 `LlmCallDecision.finish(text)` 跳过 provider 调用。实现抛异常仅记日志并跳过。 | -| `after_llm_call` | observer | `(request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None` | 无 | core `Agent` 经 `PluginAgentHooks` | 每次完成的调用恰好触发一次:成功或 `Exception` 失败。取消 / 消费方 `aclose()` 不观察。`result.error` 是原始异常。 | +| `before_llm_call` | chained | `(request: LLMCallRequest, state: TurnState) -> LLMCallRequest \| LLMCallDecision \| None` | 修改后的 request 或 finish 决定 | core `Agent` 经 `PluginAgentHooks` | 实现按 LIFO 链式执行,每个实现看到的是前一个实现修改后的 request。返回 `LLMCallDecision.finish(text)` 跳过 provider 调用。实现抛异常仅记日志并跳过。 | +| `after_llm_call` | observer | `(request: LLMCallRequest, result: LLMCallResult, state: TurnState) -> None` | 无 | core `Agent` 经 `PluginAgentHooks` | 每次完成的调用恰好触发一次:成功或 `Exception` 失败。取消 / 消费方 `aclose()` 不观察。`result.error` 是原始异常。 | | `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` 经 `PluginAgentHooks` | 逐次工具调用。`proceed(arguments=…)` 折叠参数修改;`replace(result)` / `deny(message)` 短路。否决只能通过 decision 对象——异常仅记日志并跳过。 | | `after_tool_call` | 终态处理 | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | 无 | `ToolExecutor` 经 `PluginAgentHooks` | 成功、失败、deny/replace 会触发。取消不观察。赋值 `result.result` 会替换模型可见结果;失败时 `result.error` 仍是原始 `BubError`。 | @@ -127,13 +127,13 @@ Windows 当前只清理直接 shell 进程。 ### Agent-loop 钩子(`PluginAgentHooks`) -四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `BeforeLlmCall`/`AfterLlmCall`/`BeforeToolCall`/`AfterToolCall` 事件,见 `bub.core.events`)分发,而不是 `call_first`/`call_many`,有三处刻意差异: +四个 `*_llm_call` / `*_tool_call` 钩子经由 `PluginAgentHooks` 适配器(`src/bub/hooks/interception.py`,订阅 core agent 的 `LLMCallStarted`/`LLMCallFinished`/`ToolCallStarted`/`ToolCallFinished` 事件,见 `bub.core.events`)分发,而不是 `call_first`/`call_many`,有三处刻意差异: -- **链式** — `before_llm_call` 与 `before_tool_call` 按 LIFO 顺序执行全部实现并**折叠**修改:每个实现收到的是前序实现修改后的 request/call。第一个短路决定(`LlmCallDecision.finish`、`ToolCallDecision.replace`/`deny`)终止链。 +- **链式** — `before_llm_call` 与 `before_tool_call` 按 LIFO 顺序执行全部实现并**折叠**修改:每个实现收到的是前序实现修改后的 request/call。第一个短路决定(`LLMCallDecision.finish`、`ToolCallDecision.replace`/`deny`)终止链。 - **错误隔离** — 实现抛异常只记日志(`hook.agent_hook_failed`)并跳过,绝不炸掉回合。阻断只能通过 decision 对象表达,坏掉的策略插件无法靠 crash 否决。 - **终态处理恰好一次** — `after_llm_call` 与 `after_tool_call` 对真实完成的调用恰好触发一次:成功或 `Exception` 失败。取消与消费方关闭(`BaseException`)刻意不进入 after hook。`result.error` 携带**原始异常对象**(工具失败为完整 `BubError`,保留 `kind`/`details`)。所有 `after_tool_call` 实现共享同一个 outcome,因此赋值 `result.result` 会改变后续实现看到的模型可见结果,同时保留 `result.error` 中的失败状态。 -载荷数据类(`LlmCallRequest`、`LlmCallResult`、`ToolCall`、`ToolCallDecision`、`ToolCallResult`、`LlmCallDecision`)位于 `src/bub/core/events.py`,并由 `bub.hooks.interception` 重新导出。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 +载荷数据类(`LLMCallRequest`、`LLMCallResult`、`ToolCall`、`ToolCallDecision`、`ToolCallResult`、`LLMCallDecision`)位于 `src/bub/core/events.py`,并由 `bub.hooks.interception` 重新导出。所有载荷携带 `run_id`,与 tape 条目 meta 中的 `run_id` 对应,观察者可将钩子事件与 tape 记录对齐。`before_llm_call` 改写的 `model`/`max_tokens` 全链路生效:provider 收到改写值,tape 记录 effective model。 continuation 风格的 `wrap_tool_call`(重试 / human-in-the-loop / 缓存)曾在原 PR 中实现,按 review 方向移除;若相关用例成熟,将作为独立提案回归。 diff --git a/website/src/content/docs/zh-cn/docs/reference/types.mdx b/website/src/content/docs/zh-cn/docs/reference/types.mdx index edd76252..5dc5da0c 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -86,16 +86,16 @@ Hook 声明与 marker 位于 `bub.hooks`;agent-loop 拦截 payload 与它们 ```python from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl, hookspec from bub.hooks.interception import ( - LlmCallDecision, - LlmCallRequest, - LlmCallResult, + LLMCallDecision, + LLMCallRequest, + LLMCallResult, PluginAgentHooks, ToolCall, ToolCallDecision, ToolCallResult, ) from bub.hooks.runtime import HookRuntime -from bub.sidecars import TapeSidecar, sidecar_tape_name +from bub.core.sidecars import TapeSidecar, sidecar_tape_name ``` 插件作者通常只需要 `hookimpl` 与对应 hook 使用的 payload 类型。`TapeSidecar` 是 `provide_tape_sidecar` 返回的最小具名契约;`sidecar_tape_name` 用于解析 sibling tape 名称。分发和故障隔离语义见 [Hook 参考](/zh-cn/docs/reference/hooks/)。 diff --git a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx index b425630f..50513580 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/fastapi.mdx @@ -32,8 +32,9 @@ import republic from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel -from bub.core import Agent, Tool +from bub.core import Agent from bub.core.store import FileTapeStore +from bub.core.tools import Tool async def lookup_order(order_id: str) -> dict[str, str]: diff --git a/website/src/content/docs/zh-cn/docs/tutorials/observability.mdx b/website/src/content/docs/zh-cn/docs/tutorials/observability.mdx index 0c58b5d4..87d06470 100644 --- a/website/src/content/docs/zh-cn/docs/tutorials/observability.mdx +++ b/website/src/content/docs/zh-cn/docs/tutorials/observability.mdx @@ -22,7 +22,7 @@ sidebar: - 启动带 Phoenix 的 Bub 之前,在已激活的 Bub 虚拟环境中安装 `trace` extra: ```bash -uv pip install "bub[trace]" +uv pip install "bub[app,trace]" ``` `trace` extra 包含 OpenTelemetry API、SDK 和 HTTP/protobuf OTLP exporter。以下 Phoenix 流程无需安装或配置 Logfire。 @@ -144,7 +144,7 @@ bub run ",tape.info" ## 将执行轨迹发送到 Logfire -如需将执行轨迹发送到 Logfire,安装包含 `bub[trace]` 和 Logfire 的 `logfire` extra。没有显式配置 OTLP endpoint 时,Bub CLI 会在启动时配置 Logfire,无需额外的 tracing 插件。 +如需将执行轨迹发送到 Logfire,安装包含 `bub[app,trace]` 和 Logfire 的 `logfire` extra。没有显式配置 OTLP endpoint 时,Bub CLI 会在启动时配置 Logfire,无需额外的 tracing 插件。 从 Phoenix 切换时,先清除两个 OTLP endpoint 环境变量,再登录并选择 Logfire 项目,然后运行 Bub: From debe365ef9337ca0c740ce8ca0ac2a973f5f5089 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 16 Oct 2026 16:59:41 +0800 Subject: [PATCH 14/16] feat: add max_tool_concurrency to core Agent and restore compatibility aliases (#366) * fix: restore compatibility aliases for moved modules and renamed payloads Bring back bub.tape, bub.tools, bub.store, bub.sidecars, bub.builtin.commands and bub.builtin.context as aliases of their bub.core modules, and keep the LlmCallRequest / LlmCallResult / LlmCallDecision names in bub.hooks.interception. Co-Authored-By: Claude Opus 5.5 * feat: add max_tool_concurrency to core Agent Limit how many tool calls from one model response run at once. ToolExecutor holds the limiter: a Semaphore, or nullcontext when unlimited. Co-Authored-By: Claude Opus 5.5 --- src/bub/builtin/commands.py | 8 +++ src/bub/builtin/context.py | 8 +++ src/bub/core/agent.py | 8 ++- src/bub/core/tools.py | 70 +++++++++++-------- src/bub/hooks/interception.py | 8 +++ src/bub/sidecars.py | 8 +++ src/bub/store.py | 8 +++ src/bub/tape.py | 8 +++ src/bub/tools.py | 8 +++ tests/test_compat_aliases.py | 39 +++++++++++ tests/test_core_agent.py | 43 ++++++++++++ website/src/content/docs/docs/build/sdk.md | 1 + .../src/content/docs/zh-cn/docs/build/sdk.md | 1 + 13 files changed, 188 insertions(+), 30 deletions(-) create mode 100644 src/bub/builtin/commands.py create mode 100644 src/bub/builtin/context.py create mode 100644 src/bub/sidecars.py create mode 100644 src/bub/store.py create mode 100644 src/bub/tape.py create mode 100644 src/bub/tools.py create mode 100644 tests/test_compat_aliases.py diff --git a/src/bub/builtin/commands.py b/src/bub/builtin/commands.py new file mode 100644 index 00000000..132dd98a --- /dev/null +++ b/src/bub/builtin/commands.py @@ -0,0 +1,8 @@ +"""Compatibility alias for :mod:`bub.core.commands`.""" + +import sys + +from bub.core import commands as _module +from bub.core.commands import * # noqa: F403 + +sys.modules[__name__] = _module diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py new file mode 100644 index 00000000..510f507c --- /dev/null +++ b/src/bub/builtin/context.py @@ -0,0 +1,8 @@ +"""Compatibility alias for :mod:`bub.core.context`.""" + +import sys + +from bub.core import context as _module +from bub.core.context import * # noqa: F403 + +sys.modules[__name__] = _module diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py index 78e8502a..1b1bc5c2 100644 --- a/src/bub/core/agent.py +++ b/src/bub/core/agent.py @@ -73,6 +73,8 @@ class Agent: :func:`default_tape_context`, which replays the recorded chat. sidecars: Sidecar capabilities mounted on every tape. max_steps: Maximum model calls in one run. + max_tool_concurrency: Maximum tool calls from one model response that run at + once. None runs them all concurrently; 1 runs them one at a time, in order. name: Agent name, reported in ``invoke_agent`` traces. command_prefix: When set, a text prompt starting with it runs as a command (see :meth:`run_command`) instead of the agent loop. None disables commands. @@ -97,6 +99,7 @@ def __init__( context: TapeContext | None = None, sidecars: Sequence[TapeSidecar] = (), max_steps: int = sys.maxsize, + max_tool_concurrency: int | None = None, name: str = "agent", command_prefix: str | None = None, ) -> None: @@ -117,6 +120,9 @@ def __init__( sidecars=tuple(sidecars), ) self.max_steps = max_steps + if max_tool_concurrency is not None and max_tool_concurrency < 1: + raise ValueError("max_tool_concurrency must be a positive integer or None") + self.max_tool_concurrency = max_tool_concurrency self.name = name self.command_prefix = None if command_prefix is None else validate_command_prefix(command_prefix) self.events = EventBus() @@ -352,7 +358,7 @@ async def run_once( yield StreamEvent("tool_call", {"tool_calls": serialized_tool_calls}) turn_state["_runtime_tool_names"] = tuple(tool_map) context = ToolContext(tape=tape, run_id=run_id, state=turn_state, events=self.events) - execution = await ToolExecutor(events=self.events).execute_many( + execution = await ToolExecutor(events=self.events, max_concurrency=self.max_tool_concurrency).execute_many( tool_invocations, context=context, call_ids=[call.id for call in tool_calls], diff --git a/src/bub/core/tools.py b/src/bub/core/tools.py index 10fdbe9a..76eec3bf 100644 --- a/src/bub/core/tools.py +++ b/src/bub/core/tools.py @@ -5,6 +5,7 @@ import json import time from collections.abc import Callable, Iterable, Sequence +from contextlib import AbstractAsyncContextManager, nullcontext from dataclasses import dataclass, field, replace from typing import Any, Literal, get_args, get_type_hints, overload @@ -286,12 +287,20 @@ 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. ``ToolCallStarted`` and ``ToolCallFinished`` - events are emitted on ``events``. + events are emitted on ``events``. ``max_concurrency`` limits how many of this executor's + calls run at once; None leaves them unlimited. """ - def __init__(self, events: EventBus | None = None, *, render: bool = True) -> None: + def __init__( + self, events: EventBus | None = None, *, render: bool = True, max_concurrency: int | None = None + ) -> None: + if max_concurrency is not None and max_concurrency < 1: + raise ValueError("max_concurrency must be a positive integer or None") self._events = events self._render = render + self._limiter: AbstractAsyncContextManager[Any] = ( + nullcontext() if max_concurrency is None else asyncio.Semaphore(max_concurrency) + ) async def execute( self, @@ -347,33 +356,36 @@ async def _trace_tool_response( context: ToolContext | None, call_id: str | None, ) -> Any: - span = Span( - f"execute_tool {tool.name}", - { - "gen_ai.operation.name": "execute_tool", - "gen_ai.tool.name": tool.name, - "gen_ai.tool.type": "function", - "gen_ai.tool.call.id": call_id, - "bub.run_id": context.run_id if context else None, - "gen_ai.conversation.id": context.state.get("session_id") if context else None, - }, - ) - try: - with span.activate(): - result = await self._handle_tool_response_async(tool, arguments, context, span=span) - if isinstance(result, _FailedToolResult): - span.fail(result.error) - span.set(**{ - "gen_ai.tool.call.result": result.error.as_dict() if result.result is None else result.result - }) - else: - span.set(**{"gen_ai.tool.call.result": result}) - return result - except BaseException as exc: - span.fail(exc) - raise - finally: - span.end() + async with self._limiter: + span = Span( + f"execute_tool {tool.name}", + { + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": tool.name, + "gen_ai.tool.type": "function", + "gen_ai.tool.call.id": call_id, + "bub.run_id": context.run_id if context else None, + "gen_ai.conversation.id": context.state.get("session_id") if context else None, + }, + ) + try: + with span.activate(): + result = await self._handle_tool_response_async(tool, arguments, context, span=span) + if isinstance(result, _FailedToolResult): + span.fail(result.error) + span.set(**{ + "gen_ai.tool.call.result": result.error.as_dict() + if result.result is None + else result.result + }) + else: + span.set(**{"gen_ai.tool.call.result": result}) + return result + except BaseException as exc: + span.fail(exc) + raise + finally: + span.end() def _invoke_tool( self, diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index bcc56c37..57588a36 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -27,12 +27,20 @@ "LLMCallDecision", "LLMCallRequest", "LLMCallResult", + "LlmCallDecision", + "LlmCallRequest", + "LlmCallResult", "PluginAgentHooks", "ToolCall", "ToolCallDecision", "ToolCallResult", ] +# Compatibility aliases for the names used before 0.6.0a2. +LlmCallDecision = LLMCallDecision +LlmCallRequest = LLMCallRequest +LlmCallResult = LLMCallResult + class PluginAgentHooks: """Fault-isolated adapter running the framework's pluggy interception hooks for agent events. diff --git a/src/bub/sidecars.py b/src/bub/sidecars.py new file mode 100644 index 00000000..88e59846 --- /dev/null +++ b/src/bub/sidecars.py @@ -0,0 +1,8 @@ +"""Compatibility alias for :mod:`bub.core.sidecars`.""" + +import sys + +from bub.core import sidecars as _module +from bub.core.sidecars import * # noqa: F403 + +sys.modules[__name__] = _module diff --git a/src/bub/store.py b/src/bub/store.py new file mode 100644 index 00000000..3fd8aeca --- /dev/null +++ b/src/bub/store.py @@ -0,0 +1,8 @@ +"""Compatibility alias for :mod:`bub.core.store`.""" + +import sys + +from bub.core import store as _module +from bub.core.store import * # noqa: F403 + +sys.modules[__name__] = _module diff --git a/src/bub/tape.py b/src/bub/tape.py new file mode 100644 index 00000000..965d1d18 --- /dev/null +++ b/src/bub/tape.py @@ -0,0 +1,8 @@ +"""Compatibility alias for :mod:`bub.core.tape`.""" + +import sys + +from bub.core import tape as _module +from bub.core.tape import * # noqa: F403 + +sys.modules[__name__] = _module diff --git a/src/bub/tools.py b/src/bub/tools.py new file mode 100644 index 00000000..76d4ee66 --- /dev/null +++ b/src/bub/tools.py @@ -0,0 +1,8 @@ +"""Compatibility alias for :mod:`bub.core.tools`.""" + +import sys + +from bub.core import tools as _module +from bub.core.tools import * # noqa: F403 + +sys.modules[__name__] = _module diff --git a/tests/test_compat_aliases.py b/tests/test_compat_aliases.py new file mode 100644 index 00000000..14b535a5 --- /dev/null +++ b/tests/test_compat_aliases.py @@ -0,0 +1,39 @@ +import importlib +import subprocess +import sys + +import pytest + +from bub.core import events +from bub.hooks import interception + +MOVED_MODULES = { + "bub.tape": "bub.core.tape", + "bub.tools": "bub.core.tools", + "bub.store": "bub.core.store", + "bub.sidecars": "bub.core.sidecars", + "bub.builtin.commands": "bub.core.commands", + "bub.builtin.context": "bub.core.context", +} + + +@pytest.mark.parametrize(("old", "new"), MOVED_MODULES.items()) +def test_moved_module_alias_is_the_new_module(old: str, new: str) -> None: + assert importlib.import_module(old) is importlib.import_module(new) + + +def test_top_level_module_aliases_load_no_app_dependencies() -> None: + script = ( + "import sys, bub.tape, bub.tools, bub.store, bub.sidecars\n" + "loaded = {name.split('.')[0] for name in sys.modules}\n" + "print(sorted(loaded & {'typer', 'pluggy', 'pydantic_settings', 'yaml', 'rapidfuzz'}))\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True) + + assert result.stdout.splitlines() == ["[]"] + + +@pytest.mark.parametrize("suffix", ["Request", "Result", "Decision"]) +def test_interception_keeps_llm_call_payload_aliases(suffix: str) -> None: + assert getattr(interception, f"LlmCall{suffix}") is getattr(events, f"LLMCall{suffix}") + assert f"LlmCall{suffix}" in interception.__all__ diff --git a/tests/test_core_agent.py b/tests/test_core_agent.py index e2e22327..e6448325 100644 --- a/tests/test_core_agent.py +++ b/tests/test_core_agent.py @@ -1,6 +1,7 @@ from __future__ import annotations import ast +import asyncio from dataclasses import replace from pathlib import Path from typing import Any @@ -181,6 +182,48 @@ async def test_core_agent_stops_at_max_steps(provider_service: ProviderService) assert statuses == ["model=openai:test", "tool=ok", "continue", "run=error:1:max_steps_reached=1"] +@pytest.mark.asyncio +@pytest.mark.parametrize(("limit", "expected_peak"), [(None, 3), (2, 2), (1, 1)]) +async def test_max_tool_concurrency_limits_tool_calls_of_one_response( + provider_service: ProviderService, limit: int | None, expected_peak: int +) -> None: + calls = [ + {"index": i, "id": f"call-{i}", "function": {"name": "wait", "arguments": f'{{"n": {i}}}'}} for i in range(3) + ] + provider_service.reply(sse(tool_events(calls))) + provider_service.reply(sse(chat_events("done"))) + running = 0 + peak = 0 + started: list[int] = [] + + async def wait(n: int) -> str: + nonlocal running, peak + running += 1 + peak = max(peak, running) + await asyncio.sleep(0.01) + running -= 1 + return "ok" + + async with provider_service.client() as client: + agent = Agent( + model=_model(client), + tools=[Tool.from_callable(wait, name="wait")], + max_tool_concurrency=limit, + ) + agent.events.on(ToolCallStarted, lambda event: started.append(event.call.arguments["n"])) + async with agent.run(["go"], tape="concurrency") as stream: + [event async for event in stream] + + assert peak == expected_peak + assert started == [0, 1, 2] + + +@pytest.mark.parametrize("limit", [0, -1]) +def test_max_tool_concurrency_must_be_positive(limit: int) -> None: + with pytest.raises(ValueError, match="max_tool_concurrency"): + Agent(max_tool_concurrency=limit) + + @pytest.mark.asyncio async def test_redirected_call_reuses_the_provider_of_a_republic_model(provider_service: ProviderService) -> None: provider_service.reply(sse(chat_events())) diff --git a/website/src/content/docs/docs/build/sdk.md b/website/src/content/docs/docs/build/sdk.md index 1e2fc35d..f8b7c614 100644 --- a/website/src/content/docs/docs/build/sdk.md +++ b/website/src/content/docs/docs/build/sdk.md @@ -88,6 +88,7 @@ continues the conversation stored under `sessions/`. | `context` | A `TapeContext` choosing which tape entries become model context. Defaults to `default_tape_context()`. | | `sidecars` | `TapeSidecar` capabilities mounted on every tape. | | `max_steps` | Maximum model calls in one run. | +| `max_tool_concurrency` | Maximum tool calls from one model response that run at once. `None` (the default) runs them all concurrently; `1` runs them one at a time, in order. | | `name` | Agent name reported in `invoke_agent` traces. Defaults to `"agent"`. | | `command_prefix` | Prefix marking command prompts, such as `","`; see [Commands](#commands). `None` (the default) disables commands. | 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 3097e792..8b3fdbb9 100644 --- a/website/src/content/docs/zh-cn/docs/build/sdk.md +++ b/website/src/content/docs/zh-cn/docs/build/sdk.md @@ -86,6 +86,7 @@ if __name__ == "__main__": | `context` | 决定哪些 tape 条目进入模型上下文的 `TapeContext`。默认 `default_tape_context()`。 | | `sidecars` | 挂载到每条 tape 上的 `TapeSidecar` 能力。 | | `max_steps` | 一次运行中最多的模型调用次数。 | +| `max_tool_concurrency` | 同一次模型回复中可同时执行的工具调用数上限。默认 `None` 即全部并发;`1` 则按顺序逐个执行。 | | `name` | 在 `invoke_agent` trace 中上报的 agent 名称。默认 `"agent"`。 | | `command_prefix` | 标记命令 prompt 的前缀,例如 `","`;见 [命令](#命令)。默认 `None`,即不启用命令。 | From 4d2b78d88135ed7b99356798a17582732205f1ec Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 17 Oct 2026 19:04:14 +0800 Subject: [PATCH 15/16] feat!: make code mode a reusable core component (#367) --- AGENTS.md | 2 +- src/bub/builtin/agent.py | 10 +- src/bub/builtin/codemode/__init__.py | 501 +++--------------- src/bub/builtin/environment.py | 129 +---- src/bub/builtin/hook_impl.py | 2 +- src/bub/builtin/shell_manager.py | 3 +- src/bub/core/codemode/__init__.py | 306 +++++++++++ .../codemode/republic_bridge.py | 54 +- .../codemode/runner.py} | 6 +- .../codemode/runner_child.py} | 0 src/bub/core/codemode/stub.py | 277 ++++++++++ src/bub/environment.py | 129 ++++- src/bub/hooks/specs.py | 2 +- tests/test_codemode.py | 7 +- tests/test_core_codemode.py | 141 +++++ tests/test_environment.py | 4 +- website/src/content/docs/docs/build/tools.mdx | 28 +- .../content/docs/zh-cn/docs/build/tools.mdx | 28 +- 18 files changed, 1014 insertions(+), 615 deletions(-) create mode 100644 src/bub/core/codemode/__init__.py rename src/bub/{builtin => core}/codemode/republic_bridge.py (87%) rename src/bub/{builtin/codemode/code_runner.py => core/codemode/runner.py} (96%) rename src/bub/{builtin/codemode/code_runner_child.py => core/codemode/runner_child.py} (100%) create mode 100644 src/bub/core/codemode/stub.py create mode 100644 tests/test_core_codemode.py diff --git a/AGENTS.md b/AGENTS.md index 498b4382..26b6c643 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ 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. +- `src/bub/core/`: agent kernel with no settings or plugin dependencies: agent loop over a tape store (`agent.py`), model protocol and Republic chat model adapter (`model.py`), agent events and their bus (`events.py`), the tape recorder handler (`recorder.py`), system prompt sections (`system_prompt.py`), command prompts (`commands.py`), tools, tape, store, tape context, and code mode (`codemode/`: `CodeMode`, the `run_code` subprocess runner, and the `republic` bridge). - `src/bub/framework.py`: turn orchestration and outbound routing. - `src/bub/hooks/`: hook specifications, execution helpers, and interception contracts. - `src/bub/channels/`: channel bases, routing contracts, admission policy, and lifecycle manager. diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 8f6f6cd7..cee7d20a 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -229,15 +229,9 @@ def _hook_prompt(self, context: core.PromptContext) -> str: @staticmethod 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 + from bub.builtin.codemode import CODE_MODE - state = context.state - if (code_tools := state.get(CODE_TOOLS_STATE_KEY)) is None: - return None - stub_path = write_tool_stub( - code_tools, session_id=str(state.get("session_id", "")), workspace=workspace_from_state(state) - ) - return render_code_mode_prompt(stub_path) + return CODE_MODE.prompt(context) def _deferred_tools_prompt(self, context: core.PromptContext) -> str: from bub.builtin.tools import render_deferred_tools_prompt diff --git a/src/bub/builtin/codemode/__init__.py b/src/bub/builtin/codemode/__init__.py index 0a366a67..921bd97e 100644 --- a/src/bub/builtin/codemode/__init__.py +++ b/src/bub/builtin/codemode/__init__.py @@ -1,466 +1,93 @@ -"""Code mode: expose tools to model-written Python through a generated stub and ``run_code``.""" +"""Bub's code mode: :class:`bub.core.codemode.CodeMode` wired to session settings, environments and models. + +``BubAgent`` chooses the code-callable tools of each turn and stores them in the turn +state under ``CODE_TOOLS_STATE_KEY``; the ``code_mode`` command switches it per session. +""" from __future__ import annotations -import asyncio -import contextvars import hashlib -import keyword -import mimetypes -import re -import uuid -from collections.abc import Iterable -from dataclasses import replace +from collections.abc import Iterable, Mapping from pathlib import Path from typing import Any -import republic - -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.core.codemode import ( + DEFAULT_RUN_CODE_TIMEOUT_SECONDS, + MEDIA_TOOLS, + RUN_CODE_TOOL_NAME, + CodeMode, + attach_audio, + attach_image, + attach_video, + render_code_mode_prompt, + render_tool_stub, + write_stub, +) +from bub.core.tools import REGISTRY, Tool, ToolContext, tool +from bub.turn import TurnState +from bub.utils import workspace_from_state + +__all__ = [ + "CODE_MODE", + "CODE_MODE_STATE_KEY", + "CODE_TOOLS_STATE_KEY", + "DEFAULT_RUN_CODE_TIMEOUT_SECONDS", + "RUN_CODE_TOOL_NAME", + "attach_audio", + "attach_image", + "attach_video", + "render_code_mode_prompt", + "render_tool_stub", + "run_code", + "set_code_mode", + "write_tool_stub", +] -RUN_CODE_TOOL_NAME = "run_code" CODE_MODE_STATE_KEY = "code_mode" CODE_TOOLS_STATE_KEY = "_runtime_code_tools" -DEFAULT_RUN_CODE_TIMEOUT_SECONDS = 120 - -_RUN_CODE_MEDIA: contextvars.ContextVar[list[UserContent] | None] = contextvars.ContextVar( - "bub_run_code_media", default=None -) - -_STUB_HEADER = '''"""Bub tools available inside `run_code` as `tools.(...)`. - -Every tool is an async function: `await` it (top-level `await` is allowed) and pass keyword -arguments only. Each call returns the structured value described by its return type and -raises an exception when the tool fails. Use `asyncio.gather` to run independent calls -concurrently. - -The `republic` module is also available (preset as a global and importable); it is documented -at the end of this file. -""" - -from typing import Any, Literal, NotRequired, TypedDict - -import republic''' - -_REPUBLIC_STUB = """# --------------------------------------------------------------------------- -# `republic`: media and models inside `run_code`. Models are served by Bub with its -# credentials and settings; every model method is async. Only the members below exist. -# -# class republic.Image / republic.Audio / republic.Video: -# __init__(media_type: str, data: bytes | None = None, url: str | None = None) -# media_type: str; data: bytes | None; url: str | None -# base64_data: str; data_url: str -# `tools.attach_image/audio/video` also take a file path in the environment, an http(s)/gs URL -# or a data URL instead, and load it for you. -# -# republic.system(text: str) -> Message -# republic.user(*content: str | Image | Audio | Video) -> Message -# republic.assistant(*content: str | Image | Audio | Video) -> Message -# Message has .role, .content (list of text and media) and .text. -# -# republic.get_model(spec: str | None = None) -> ChatModel -# A "provider:model" spec; None is the model running this session. -# await model.chat(prompt: str | Message | list[str | Message], **options) -> response -# options: max_tokens, temperature, top_p, top_k, stop, seed, reasoning_effort, extra_body... -# (no tools). A plain string is a user message. -# response.text, .reasoning, .refusal, .finish_reason, .model, -# .token_usage.input_tokens / .output_tokens, .message (append it to continue the conversation) -# model.stream(prompt, **options) -> Stream # same arguments as chat(); not awaited -# async with model.stream(...) as stream: -# async for event in stream: ... -# Events from republic.events: TextDelta(.chunk), ReasoningDelta(.chunk), RefusalDelta(.chunk), -# UsageDelta(.usage), ImageReady(.image), CitationAdded(.citation), Completed(.response, last). -# After iterating: stream.response (as returned by chat()), stream.text, .reasoning, .token_usage. -# republic.get_embedding_model(spec: str) -> EmbeddingModel -# await model.embed(text: str, *, dimensions: int | None = None) -> response # response.vector -# await model.embed_many(texts: list[str], *, dimensions: int | None = None) -> response # response.vectors -# republic.get_decision_model(spec: str) -> DecisionModel -# await model.decide(state, *, questions: dict[str, Question]) -> response -# Questions from republic.decisions: Noul(instructions, criteria=None) (yes/no), -# Choice(instructions, criteria) (pick one), Score(instructions, criteria) (ordered levels). -# response.answers[id] or response.: .noul | .choice/.probabilities/.confidence | .score/... -# -# Example: -# model = republic.get_model() -# chart = republic.Image("image/png", data=open("chart.png", "rb").read()) -# reply = await model.chat([republic.user("Describe this chart.", chart)]) -# print(reply.text) -# async with model.stream("Write a haiku.") as stream: -# async for event in stream: -# if isinstance(event, republic.events.TextDelta): -# print(event.chunk, end="")""" -_JSON_TYPES = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None"} - - -def _identifier(name: str) -> str: - ident = re.sub(r"\W", "_", name) - if not ident or ident[0].isdigit() or keyword.iskeyword(ident): - ident = f"_{ident}" - return ident - - -def _is_identifier(name: str) -> bool: - return name.isidentifier() and not keyword.iskeyword(name) - - -def _pascal(name: str) -> str: - return "".join(part[:1].upper() + part[1:] for part in re.split(r"[^0-9A-Za-z]+", name)) - - -_MEDIA_FIELDS = {"media_type", "data", "url"} - - -def _media_class(schema: dict[str, Any]) -> str | None: - """Recognize the JSON schema of ``republic.Image``, ``Audio`` and ``Video`` parameters.""" - title = schema.get("title") - properties = schema.get("properties") - if title in ("Image", "Audio", "Video") and isinstance(properties, dict) and set(properties) == _MEDIA_FIELDS: - return str(title) - return None - - -def _docstring(text: str, indent: str = " ") -> list[str]: - text = text.strip().replace("\\", "\\\\").replace('"""', '\\"\\"\\"') - if not text: - return [] - lines = text.splitlines() - if len(lines) == 1: - return [f'{indent}"""{lines[0]}"""'] - return [f'{indent}"""{lines[0]}', *(f"{indent}{line}".rstrip() for line in lines[1:]), f'{indent}"""'] - - -class _StubBuilder: - """Render tool JSON schemas as Python stub declarations.""" - - def __init__(self) -> None: - self.classes: dict[str, str] = {} - self._refs: dict[tuple[int, str], str] = {} - - def function(self, tool_item: Tool) -> str: - name = _identifier(tool_item.name) - schema = tool_item.parameters or {} - defs = self._defs(schema) - properties = schema.get("properties") or {} - required = set(schema.get("required") or []) - params: list[str] = [] - arg_docs: list[str] = [] - for key, prop in properties.items(): - hint = self.type_hint(prop, defs, f"{_pascal(name)}{_pascal(key)}") - if key in required: - params.append(f"{key}: {hint}") - elif isinstance(prop, dict) and "default" in prop: - params.append(f"{key}: {hint} = {prop['default']!r}") - else: - params.append(f"{key}: {hint} = ...") - description = prop.get("description") if isinstance(prop, dict) else None - if description: - arg_docs.append(f" {key}: {' '.join(description.split())}") - if not all(_is_identifier(key) for key in properties): - signature = "**kwargs: Any" - elif params: - signature = f"*, {', '.join(params)}" - else: - signature = "" - - output = tool_item.output_schema - returns = "Any" if output is None else self.type_hint(output, self._defs(output), f"{_pascal(name)}Result") - doc = tool_item.description.strip() - if arg_docs: - doc = f"{doc}\n\nArgs:\n" + "\n".join(arg_docs) if doc else "Args:\n" + "\n".join(arg_docs) - body = _docstring(doc) or [" ..."] - return "\n".join([f"async def {name}({signature}) -> {returns}:", *body]) - - def type_hint( # noqa: C901 - self, schema: Any, defs: dict[str, Any], name_hint: str, ref_key: tuple[int, str] | None = None - ) -> str: - if not isinstance(schema, dict) or not schema: - return "Any" - if (media := _media_class(schema)) is not None: - return f"republic.{media}" - if local_defs := self._defs(schema): - # Schemas of single parameters carry their own definitions. - defs = {**defs, **local_defs} - if isinstance(ref := schema.get("$ref"), str): - return self._ref(ref, defs) - for key in ("anyOf", "oneOf"): - if isinstance(options := schema.get(key), list): - return self._union(self.type_hint(option, defs, name_hint) for option in options) - if isinstance(all_of := schema.get("allOf"), list) and len(all_of) == 1: - return self.type_hint(all_of[0], defs, name_hint, ref_key) - if "const" in schema: - return f"Literal[{schema['const']!r}]" - if isinstance(enum := schema.get("enum"), list) and enum: - return f"Literal[{', '.join(repr(value) for value in enum)}]" - - schema_type = schema.get("type") - if isinstance(schema_type, list): - return self._union(self.type_hint({**schema, "type": item}, defs, name_hint) for item in schema_type) - if schema_type == "array": - if isinstance(prefix := schema.get("prefixItems"), list): - return f"tuple[{', '.join(self.type_hint(item, defs, name_hint) for item in prefix)}]" - return f"list[{self.type_hint(schema.get('items'), defs, f'{name_hint}Item')}]" - if schema_type == "object": - if isinstance(properties := schema.get("properties"), dict) and properties: - return self._typed_dict(schema, properties, defs, schema.get("title") or name_hint, ref_key) - extra = schema.get("additionalProperties") - if isinstance(extra, dict) and extra: - return f"dict[str, {self.type_hint(extra, defs, f'{name_hint}Value')}]" - return "dict[str, Any]" - return _JSON_TYPES.get(schema_type, "Any") if isinstance(schema_type, str) else "Any" - @staticmethod - def _defs(schema: dict[str, Any]) -> dict[str, Any]: - defs = schema.get("$defs", schema.get("definitions")) - return defs if isinstance(defs, dict) else {} - @staticmethod - def _union(hints: Iterable[str]) -> str: - unique = list(dict.fromkeys(hints)) - return "Any" if "Any" in unique else " | ".join(unique) +def _resolve_model(spec: str | None, context: ToolContext) -> tuple[str, Mapping[str, Any]]: + """Resolve a spec, or the session's model, with Bub's settings: fallbacks, credentials and API bases.""" + agent = context.state.get("_runtime_agent") + settings = getattr(agent, "settings", None) + if settings is None: + raise ValueError("Republic models need a running Bub agent.") + candidate = settings.model_candidates(spec or context.state.get("model") or settings.model)[0] + client_kwargs = settings.model_client_kwargs(candidate.provider_name or candidate.provider) + return f"{candidate.provider}:{candidate.model_id}", client_kwargs - def _ref(self, ref: str, defs: dict[str, Any]) -> str: - name = ref.rsplit("/", 1)[-1] - key = (id(defs), name) - if key in self._refs: - return self._refs[key] - target = defs.get(name) - if not isinstance(target, dict): - return "Any" - hint = self.type_hint(target, defs, name, ref_key=key) - self._refs[key] = hint - return hint - def _typed_dict( - self, - schema: dict[str, Any], - properties: dict[str, Any], - defs: dict[str, Any], - title: str, - ref_key: tuple[int, str] | None, - ) -> str: - base = _identifier(_pascal(title) or "Result") - class_name = base - index = 2 - while class_name in self.classes: - class_name = f"{base}{index}" - index += 1 - # Reserve the name first so recursive references resolve to this class. - self.classes[class_name] = "" - if ref_key is not None: - self._refs[ref_key] = class_name - - required = set(schema.get("required") or []) - fields: list[tuple[str, str, str | None]] = [] - for key, prop in properties.items(): - hint = self.type_hint(prop, defs, f"{class_name}{_pascal(key)}") - if key not in required: - hint = f"NotRequired[{hint}]" - description = prop.get("description") if isinstance(prop, dict) else None - fields.append((key, hint, description)) - source = self._class_source(class_name, fields, schema.get("description")) - - # Reuse an identical class that was declared under the same title by another tool. - for existing, existing_source in self.classes.items(): - if ( - existing != class_name - and re.fullmatch(rf"{re.escape(base)}\d*", existing) - and existing_source == source.replace(class_name, existing) - ): - del self.classes[class_name] - if ref_key is not None: - self._refs[ref_key] = existing - return existing - self.classes[class_name] = source - return class_name +def _stub_dir(session_id: str, workspace: Path) -> Path: + import bub - @staticmethod - def _class_source(name: str, fields: list[tuple[str, str, str | None]], description: object) -> str: - if not all(_is_identifier(key) for key, _, _ in fields): - items = ", ".join(f"{key!r}: {hint}" for key, hint, _ in fields) - return f"{name} = TypedDict({name!r}, {{{items}}})" - lines = [f"class {name}(TypedDict):"] - if isinstance(description, str): - lines.extend(_docstring(description)) - for key, hint, field_description in fields: - if field_description: - lines.append(f" # {' '.join(field_description.split())}") - lines.append(f" {key}: {hint}") - return "\n".join(lines) + return bub.home / "codemode" / hashlib.sha256(f"{workspace}\0{session_id}".encode()).hexdigest()[:16] -def render_tool_stub(tools: Iterable[Tool]) -> str: - """Render a Python stub declaring one function per model-facing tool.""" - builder = _StubBuilder() - functions = [builder.function(tool_item) for tool_item in model_tools(tools)] - return "\n\n\n".join([_STUB_HEADER, *builder.classes.values(), *functions, _REPUBLIC_STUB]) + "\n" +def _state_stub_dir(state: TurnState) -> Path: + return _stub_dir(str(state.get("session_id", "")), workspace_from_state(state)) def write_tool_stub(tools: Iterable[Tool], *, session_id: str, workspace: Path) -> Path: """Write the tool stub under Bub home; the path stays stable while the session's tool set is unchanged.""" - import bub - - content = render_tool_stub(tools) - session_key = hashlib.sha256(f"{workspace}\0{session_id}".encode()).hexdigest()[:16] - content_key = hashlib.sha256(content.encode()).hexdigest()[:12] - path = bub.home / "codemode" / session_key / f"tools-{content_key}.pyi" - if not path.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - temp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") - temp_path.write_text(content, encoding="utf-8") - temp_path.replace(path) - return path - - -def render_code_mode_prompt(stub_path: Path) -> str: - return ( - "\n" - f"More tools are available as async Python functions `tools.(...)` inside `{RUN_CODE_TOOL_NAME}`. " - f"Their signatures, result types and documentation are in the stub file: {stub_path}\n" - "Read the stub before calling a tool you have not used yet. Always `await` tool calls (top-level `await` " - "is allowed) and pass keyword arguments; they return structured values and raise on failure. " - f"`{RUN_CODE_TOOL_NAME}` returns only what the code prints, so print the results you need, and combine " - "several tool calls in one run when possible. To look at an image, audio or video, pass its path, URL " - "or `republic` media object to `tools.attach_image(image=...)`, `tools.attach_audio(audio=...)` or " - f"`tools.attach_video(video=...)`: it is attached to the `{RUN_CODE_TOOL_NAME}` result.\n" - "The code can also use the `republic` module (see the end of the stub) to call models with " - "`republic.get_model()`, " - "`republic.get_embedding_model(spec)` and `republic.get_decision_model(spec)`; `get_model()` without a " - "spec is the model running this session.\n" - "" - ) - - -def _code_tool_caller( - tools_by_name: dict[str, Tool], context: ToolContext, republic_session: RepublicSession -) -> CallTool: - """Serve the code's ``tools.`` and ``republic`` calls.""" - # Code consumes structured results, so this executor does not render them to text. - executor = ToolExecutor(context.events, render=False) - code_context = replace(context, code_mode=True) - - async def call_tool(name: str, arguments: dict[str, Any]) -> Any: - arguments = decode_value(arguments) - if name.startswith(REPUBLIC_CALL_PREFIX): - return encode_value(await republic_session.call(name, arguments)) - tool_item = tools_by_name.get(name) - if tool_item is None: - raise BubError(ErrorKind.INVALID_INPUT, f"unknown tool: tools.{name}") - return encode_value(await executor.execute(tool_item, arguments, context=code_context)) - - return call_tool - - -@tool(name=RUN_CODE_TOOL_NAME, context=True, exposure="direct") -async def run_code( - code: str, timeout_seconds: int = DEFAULT_RUN_CODE_TIMEOUT_SECONDS, *, context: ToolContext -) -> str | dict[str, Any]: - """Run Python code in the environment and return everything it prints. - - Tools are async functions available as `tools.(...)`: await them with keyword arguments - (top-level `await` is allowed). See the tool stub file referenced in the system prompt for their - signatures and result types. The `republic` module provides media loaders and models. Media passed - to `tools.attach_image/audio/video`, as an object, path or URL, is attached to the result. The code is stopped after timeout_seconds. - """ - code_tools = context.state.get(CODE_TOOLS_STATE_KEY) - if code_tools is None: - raise BubError(ErrorKind.INVALID_INPUT, "Code mode is not enabled for this run.") - tools_by_name = {_identifier(item.name): item for item in code_tools} - republic_session = RepublicSession(replace(context, code_mode=True)) - call_tool = _code_tool_caller(tools_by_name, context, republic_session) - - output: list[str] = [] - media: list[UserContent] = [] - token = _RUN_CODE_MEDIA.set(media) - try: - async with asyncio.timeout(timeout_seconds) as deadline: - await environment_from_state(context.state).run_code( - code, tools=list(tools_by_name), call_tool=call_tool, write=output.append - ) - except TimeoutError: - if not deadline.expired(): - raise - raise BubError( - ErrorKind.TOOL, f"Code timed out after {timeout_seconds} seconds", details={"output": "".join(output)} - ) from None - except CodeFailed as exc: - raise BubError( - ErrorKind.TOOL, f"Code raised {exc.error}", details={"output": "".join(output), "traceback": exc.traceback} - ) from exc - except BubError as exc: - raise BubError(exc.kind, exc.message, details={"output": "".join(output), **(exc.details or {})}) from exc - finally: - _RUN_CODE_MEDIA.reset(token) - await republic_session.aclose() - if media: - return content_result(["".join(output), *media] if output else media) - return "".join(output) + return write_stub(render_tool_stub(tools), _stub_dir(session_id, workspace)) -_REMOTE_MEDIA_PREFIXES = ("http://", "https://", "gs://", "data:") -_MEDIA_CLASSES: dict[str, type[republic.Image | republic.Audio | republic.Video]] = { - "image": republic.Image, - "audio": republic.Audio, - "video": republic.Video, -} - - -async def _load_media(kind: str, source: str, context: ToolContext) -> republic.Image | republic.Audio | republic.Video: - """Load media from a URL, a data URL, or a file path in the session environment.""" - if source.startswith(_REMOTE_MEDIA_PREFIXES): - media: republic.Image | republic.Audio | republic.Video = getattr(republic, kind)(source) - return media - media_type, _ = mimetypes.guess_type(source) - if media_type is None: - raise ValueError(f"cannot guess the media type of {source!r}") - environment = environment_from_state(context.state) - data = await environment.read_bytes(environment.resolve_path(source)) - return _MEDIA_CLASSES[kind](media_type, data=data) - - -async def _attach(kind: str, item: republic.Image | republic.Audio | republic.Video | str, context: ToolContext) -> str: - media = _RUN_CODE_MEDIA.get() - if media is None: - raise BubError(ErrorKind.INVALID_INPUT, f"tools.attach_{kind} can only be called from {RUN_CODE_TOOL_NAME}.") - if isinstance(item, str): - try: - item = await _load_media(kind, item, context) - except (OSError, ValueError) as exc: - raise BubError(ErrorKind.INVALID_INPUT, f"Cannot load the {kind}: {exc}") from exc - media.append(item) - return f"The {kind} is attached to the {RUN_CODE_TOOL_NAME} result." - - -@tool(name="attach_image", context=True, exposure="code") -async def attach_image(image: republic.Image | str, *, context: ToolContext) -> str: - """Attach an image to the result of this `run_code` call, so you can see it. - - `image` is a `republic.Image`, a file path in the environment, an http(s) URL, or a data URL. - """ - return await _attach("image", image, context) - - -@tool(name="attach_audio", context=True, exposure="code") -async def attach_audio(audio: republic.Audio | str, *, context: ToolContext) -> str: - """Attach audio to the result of this `run_code` call, so you can hear it. - - `audio` is a `republic.Audio`, a file path in the environment, an http(s) URL, or a data URL. - """ - return await _attach("audio", audio, context) - - -@tool(name="attach_video", context=True, exposure="code") -async def attach_video(video: republic.Video | str, *, context: ToolContext) -> str: - """Attach a video to the result of this `run_code` call, so you can watch it. +CODE_MODE = CodeMode( + lambda state: state.get(CODE_TOOLS_STATE_KEY), + environment=environment_from_state, + models=_resolve_model, + stub_dir=_state_stub_dir, + # The media tools are registered below, so turns select them like any other tool. + media_tools=False, +) +"""Code mode of Bub's agents.""" - `video` is a `republic.Video`, a file path in the environment, an http(s) URL, or a data URL. - """ - return await _attach("video", video, context) +run_code = CODE_MODE.run_code +for _tool in (run_code, *MEDIA_TOOLS): + REGISTRY[_tool.name] = _tool @tool(name="code_mode", context=True, exposure="command") diff --git a/src/bub/builtin/environment.py b/src/bub/builtin/environment.py index 91a4d3f1..f3399dca 100644 --- a/src/bub/builtin/environment.py +++ b/src/bub/builtin/environment.py @@ -1,139 +1,16 @@ -"""Host implementation of :class:`bub.environment.Environment`, provided by Bub's builtin hooks.""" +"""Bub's builtin environment provider: the session's environment from turn state.""" from __future__ import annotations -import asyncio -import contextlib -import os -import shutil -import signal -import sys -from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from bub.environment import ENVIRONMENT_STATE_KEY, CallTool, Environment, Process +from bub.environment import ENVIRONMENT_STATE_KEY, Environment, LocalEnvironment if TYPE_CHECKING: from bub.turn import TurnState -class LocalProcess(Process): - """A host process in its own session, so signals reach the whole process group.""" - - def __init__(self, process: asyncio.subprocess.Process) -> None: - self.process = process - - @property - def pid(self) -> int: - return self.process.pid - - @property - def stdout(self) -> asyncio.StreamReader: - assert self.process.stdout is not None # noqa: S101 - return self.process.stdout - - @property - def stderr(self) -> asyncio.StreamReader: - assert self.process.stderr is not None # noqa: S101 - return self.process.stderr - - @property - def returncode(self) -> int | None: - return self.process.returncode - - async def wait(self) -> int: - return await self.process.wait() - - async def write_stdin(self, data: bytes) -> None: - assert self.process.stdin is not None # noqa: S101 - self.process.stdin.write(data) - await self.process.stdin.drain() - - def close_stdin(self) -> None: - if self.process.stdin is not None: - self.process.stdin.close() - - def signal(self, *, kill: bool) -> None: - with contextlib.suppress(ProcessLookupError): - if os.name != "nt": - os.killpg(self.process.pid, signal.SIGKILL if kill else signal.SIGTERM) - elif self.returncode is None: - if kill: - self.process.kill() - else: - self.process.terminate() - - def is_running(self) -> bool: - if os.name == "nt": - return self.returncode is None - try: - os.killpg(self.process.pid, 0) - except ProcessLookupError: - return False - except PermissionError: - # EPERM does not establish that the group is gone (in particular - # while its leader is exiting on macOS). - return True - return True - - -class LocalEnvironment(Environment): - """Run processes and access files directly on the host.""" - - SHELL = shutil.which("bash") or shutil.which("sh") if os.name != "nt" else None - - def __init__(self, workspace: str | Path | None = None) -> None: - self.workspace = str(workspace) if workspace is not None else None - self.python = sys.executable - - async def spawn( - self, command: str | Sequence[str], *, cwd: str | None = None, env: Mapping[str, str] | None = None - ) -> LocalProcess: - options: dict[str, Any] = { - "cwd": cwd or self.workspace, - "env": {**os.environ, **env} if env else None, - "stdin": asyncio.subprocess.PIPE, - "stdout": asyncio.subprocess.PIPE, - "stderr": asyncio.subprocess.PIPE, - "start_new_session": os.name != "nt", - } - if isinstance(command, str): - process = await asyncio.create_subprocess_shell(command, executable=self.SHELL, **options) - else: - process = await asyncio.create_subprocess_exec(*command, **options) - return LocalProcess(process) - - async def read_text(self, path: str) -> str: - return await asyncio.to_thread(Path(path).read_text, encoding="utf-8") - - async def read_bytes(self, path: str) -> bytes: - return await asyncio.to_thread(Path(path).read_bytes) - - async def write_text(self, path: str, content: str) -> None: - def write() -> None: - target = Path(path) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - - await asyncio.to_thread(write) - - async def run_code( - self, code: str, *, tools: Sequence[str], call_tool: CallTool, write: Callable[[str], None] - ) -> None: - from bub.builtin.codemode.code_runner import run_code_in_subprocess - - await run_code_in_subprocess(self, code, tools=tools, call_tool=call_tool, write=write) - - def resolve_path(self, path: str) -> str: - expanded = Path(path).expanduser() - if expanded.is_absolute(): - return str(expanded) - if self.workspace is None: - raise ValueError(f"relative path '{path}' is not allowed without a workspace") - return str((Path(self.workspace) / expanded).resolve()) - - def environment_from_state(state: TurnState) -> Environment: """Return the session's environment, or a host environment for the state's workspace when none was provided. diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index cfc302b2..4454a455 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -288,7 +288,7 @@ async def provide_lifespan(self) -> AsyncIterator[None]: @hookimpl def provide_environment(self, session_id: str, workspace: Path) -> Environment: - from bub.builtin.environment import LocalEnvironment + from bub.environment import LocalEnvironment return LocalEnvironment(workspace) diff --git a/src/bub/builtin/shell_manager.py b/src/bub/builtin/shell_manager.py index 9cebde9d..e6bea2a8 100644 --- a/src/bub/builtin/shell_manager.py +++ b/src/bub/builtin/shell_manager.py @@ -7,8 +7,7 @@ from contextvars import ContextVar from dataclasses import dataclass, field -from bub.builtin.environment import LocalEnvironment -from bub.environment import Environment, Process +from bub.environment import Environment, LocalEnvironment, Process @dataclass(eq=False) diff --git a/src/bub/core/codemode/__init__.py b/src/bub/core/codemode/__init__.py new file mode 100644 index 00000000..50062a5c --- /dev/null +++ b/src/bub/core/codemode/__init__.py @@ -0,0 +1,306 @@ +"""Code mode: let the model call tools from Python it writes, through one ``run_code`` tool. + +:class:`CodeMode` bundles what an agent needs: the ``run_code`` tool, a system prompt +section describing the code-callable tools as a Python stub, and the ``attach_*`` media +tools. It works with a bare :class:`bub.core.Agent`:: + + code_mode = CodeMode([search, fetch], models="openai:gpt-5") + agent = Agent(model=model, tools=[code_mode.run_code], system_prompt=["You are ...", code_mode.prompt]) + +Code runs in an :class:`bub.environment.Environment`, a Python process on the host by +default. Calls the code makes go through the running agent's tool events, so hooks and +handlers see them with ``context.code_mode`` set. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import hashlib +import mimetypes +import uuid +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +import republic + +from bub.core.codemode.republic_bridge import ( + REPUBLIC_CALL_PREFIX, + ModelResolver, + RepublicSession, + decode_value, + default_model_resolver, + encode_value, +) +from bub.core.codemode.stub import _identifier, render_tool_stub +from bub.core.system_prompt import PromptContext +from bub.core.tools import Tool, ToolContext, ToolExecutor, content_result, model_tools +from bub.environment import CallTool, CodeFailed, Environment, LocalEnvironment +from bub.errors import BubError, ErrorKind +from bub.prompt import UserContent +from bub.turn import TurnState + +__all__ = [ + "DEFAULT_RUN_CODE_TIMEOUT_SECONDS", + "MEDIA_TOOLS", + "RUN_CODE_TOOL_NAME", + "CodeMode", + "ModelResolver", + "attach_audio", + "attach_image", + "attach_video", + "default_model_resolver", + "render_code_mode_prompt", + "render_tool_stub", + "write_stub", +] + +RUN_CODE_TOOL_NAME = "run_code" +DEFAULT_RUN_CODE_TIMEOUT_SECONDS = 120 + +CodeTools = Iterable[Tool] | Callable[[TurnState], Iterable[Tool] | None] +"""The tools callable from code: fixed, or chosen per run from the turn state (None disables ``run_code``).""" + + +@dataclass(frozen=True) +class _CodeRun: + environment: Environment + media: list[UserContent] + + +_ACTIVE_RUN: contextvars.ContextVar[_CodeRun | None] = contextvars.ContextVar("bub_run_code", default=None) + + +class CodeMode: + """The ``run_code`` tool and the system prompt section for a set of code-callable tools. + + Args: + tools: Tools callable from code as ``tools.(...)``. A callable receives the + turn state and returns the tools for that run, or None when code mode is off. + environment: Where the code runs, or a callable returning it for the turn state. + Defaults to a :class:`bub.environment.LocalEnvironment` in the working directory. + models: What ``republic.get_*model(spec)`` in the code resolves to: a default + ``provider:model`` spec for ``get_model()``, or a :data:`ModelResolver`. By default + specs must be explicit and clients read credentials from the environment. + stub_dir: Write the tool stub to a file in this directory (or the one returned for + the turn state) and point the prompt at it, for agents that can read files. + None puts the stub in the system prompt itself. + media_tools: Also offer ``attach_image``, ``attach_audio`` and ``attach_video`` to + the code, which attach media to the ``run_code`` result. + name: Name of the ``run_code`` tool. + """ + + def __init__( + self, + tools: CodeTools = (), + *, + environment: Environment | Callable[[TurnState], Environment] | None = None, + models: str | ModelResolver | None = None, + stub_dir: Path | Callable[[TurnState], Path] | None = None, + media_tools: bool = True, + name: str = RUN_CODE_TOOL_NAME, + ) -> None: + self._tools = tools if callable(tools) else list(tools) + self._environment = LocalEnvironment() if environment is None else environment + self.resolve_model = models if callable(models) else default_model_resolver(models) + self.stub_dir = stub_dir + self.media_tools = media_tools + self.run_code: Tool = Tool.from_callable( + self._run_code, name=name, description=_RUN_CODE_DESCRIPTION, context=True, exposure="direct" + ) + """The model-facing tool; add it to the agent's tools.""" + + def code_tools(self, state: TurnState) -> list[Tool] | None: + """Return the model-safe tools callable from code in this run, or None when code mode is off.""" + tools = self._tools(state) if callable(self._tools) else self._tools + if tools is None: + return None + result = model_tools(tools) + if self.media_tools: + names = {tool_item.name for tool_item in result} + result.extend(tool_item for tool_item in MEDIA_TOOLS if tool_item.name not in names) + return result + + def environment(self, state: TurnState) -> Environment: + """Return the environment the code runs in for this run.""" + return self._environment(state) if callable(self._environment) else self._environment + + def prompt(self, context: PromptContext) -> str | None: + """System prompt section describing the code-callable tools; add it to the agent's system prompt.""" + state = context.state + if (tools := self.code_tools(state)) is None: + return None + stub = render_tool_stub(tools) + if self.stub_dir is not None: + directory = self.stub_dir(state) if callable(self.stub_dir) else self.stub_dir + return render_code_mode_prompt(write_stub(stub, directory), tool_name=self.run_code.name) + return render_code_mode_prompt(stub, tool_name=self.run_code.name) + + async def _run_code( + self, code: str, timeout_seconds: int = DEFAULT_RUN_CODE_TIMEOUT_SECONDS, *, context: ToolContext + ) -> str | dict[str, Any]: + code_tools = self.code_tools(context.state) + if code_tools is None: + raise BubError(ErrorKind.INVALID_INPUT, "Code mode is not enabled for this run.") + tools_by_name = {_identifier(item.name): item for item in code_tools} + code_context = replace(context, code_mode=True) + republic_session = RepublicSession(code_context, self.resolve_model) + call_tool = _code_tool_caller(tools_by_name, code_context, republic_session) + environment = self.environment(context.state) + + output: list[str] = [] + media: list[UserContent] = [] + token = _ACTIVE_RUN.set(_CodeRun(environment, media)) + try: + async with asyncio.timeout(timeout_seconds) as deadline: + await environment.run_code(code, tools=list(tools_by_name), call_tool=call_tool, write=output.append) + except TimeoutError: + if not deadline.expired(): + raise + raise BubError( + ErrorKind.TOOL, f"Code timed out after {timeout_seconds} seconds", details={"output": "".join(output)} + ) from None + except CodeFailed as exc: + raise BubError( + ErrorKind.TOOL, + f"Code raised {exc.error}", + details={"output": "".join(output), "traceback": exc.traceback}, + ) from exc + except BubError as exc: + raise BubError(exc.kind, exc.message, details={"output": "".join(output), **(exc.details or {})}) from exc + finally: + _ACTIVE_RUN.reset(token) + await republic_session.aclose() + if media: + return content_result(["".join(output), *media] if output else media) + return "".join(output) + + +_RUN_CODE_DESCRIPTION = """Run Python code in the environment and return everything it prints. + +Tools are async functions available as `tools.(...)`: await them with keyword arguments +(top-level `await` is allowed). See the tool stub referenced in the system prompt for their +signatures and result types. The `republic` module provides media loaders and models. Media passed +to `tools.attach_image/audio/video`, as an object, path or URL, is attached to the result. The code is stopped after timeout_seconds.""" + + +def write_stub(content: str, directory: Path) -> Path: + """Write a stub into ``directory``; the file name depends only on the content, so unchanged stubs keep their path.""" + path = directory / f"tools-{hashlib.sha256(content.encode()).hexdigest()[:12]}.pyi" + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + temp_path.write_text(content, encoding="utf-8") + temp_path.replace(path) + return path + + +def render_code_mode_prompt(stub: Path | str, *, tool_name: str = RUN_CODE_TOOL_NAME) -> str: + """Render the code mode prompt for a stub file path, or for the stub text itself.""" + if isinstance(stub, Path): + where = f"in the stub file: {stub}\nRead the stub before calling a tool you have not used yet. " + else: + where = f"in this stub:\n```python\n{stub.rstrip()}\n```\n" + return ( + "\n" + f"More tools are available as async Python functions `tools.(...)` inside `{tool_name}`. " + f"Their signatures, result types and documentation are {where}" + "Always `await` tool calls (top-level `await` " + "is allowed) and pass keyword arguments; they return structured values and raise on failure. " + f"`{tool_name}` returns only what the code prints, so print the results you need, and combine " + "several tool calls in one run when possible. To look at an image, audio or video, pass its path, URL " + "or `republic` media object to `tools.attach_image(image=...)`, `tools.attach_audio(audio=...)` or " + f"`tools.attach_video(video=...)`: it is attached to the `{tool_name}` result.\n" + "The code can also use the `republic` module (see the end of the stub) to call models with " + "`republic.get_model()`, " + "`republic.get_embedding_model(spec)` and `republic.get_decision_model(spec)`; `get_model()` without a " + "spec is the default model of this session.\n" + "" + ) + + +def _code_tool_caller( + tools_by_name: Mapping[str, Tool], context: ToolContext, republic_session: RepublicSession +) -> CallTool: + """Serve the code's ``tools.`` and ``republic`` calls.""" + # Code consumes structured results, so this executor does not render them to text. + executor = ToolExecutor(context.events, render=False) + + async def call_tool(name: str, arguments: dict[str, Any]) -> Any: + arguments = decode_value(arguments) + if name.startswith(REPUBLIC_CALL_PREFIX): + return encode_value(await republic_session.call(name, arguments)) + tool_item = tools_by_name.get(name) + if tool_item is None: + raise BubError(ErrorKind.INVALID_INPUT, f"unknown tool: tools.{name}") + return encode_value(await executor.execute(tool_item, arguments, context=context)) + + return call_tool + + +_REMOTE_MEDIA_PREFIXES = ("http://", "https://", "gs://", "data:") +_MEDIA_CLASSES: dict[str, type[republic.Image | republic.Audio | republic.Video]] = { + "image": republic.Image, + "audio": republic.Audio, + "video": republic.Video, +} + + +async def _load_media( + kind: str, source: str, environment: Environment +) -> republic.Image | republic.Audio | republic.Video: + """Load media from a URL, a data URL, or a file path in the environment.""" + if source.startswith(_REMOTE_MEDIA_PREFIXES): + media: republic.Image | republic.Audio | republic.Video = getattr(republic, kind)(source) + return media + media_type, _ = mimetypes.guess_type(source) + if media_type is None: + raise ValueError(f"cannot guess the media type of {source!r}") + data = await environment.read_bytes(environment.resolve_path(source)) + return _MEDIA_CLASSES[kind](media_type, data=data) + + +async def _attach(kind: str, item: republic.Image | republic.Audio | republic.Video | str) -> str: + run = _ACTIVE_RUN.get() + if run is None: + raise BubError(ErrorKind.INVALID_INPUT, f"tools.attach_{kind} can only be called from {RUN_CODE_TOOL_NAME}.") + if isinstance(item, str): + try: + item = await _load_media(kind, item, run.environment) + except (OSError, ValueError) as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"Cannot load the {kind}: {exc}") from exc + run.media.append(item) + return f"The {kind} is attached to the {RUN_CODE_TOOL_NAME} result." + + +async def _attach_image(image: republic.Image | str, *, context: ToolContext) -> str: + """Attach an image to the result of this `run_code` call, so you can see it. + + `image` is a `republic.Image`, a file path in the environment, an http(s) URL, or a data URL. + """ + return await _attach("image", image) + + +async def _attach_audio(audio: republic.Audio | str, *, context: ToolContext) -> str: + """Attach audio to the result of this `run_code` call, so you can hear it. + + `audio` is a `republic.Audio`, a file path in the environment, an http(s) URL, or a data URL. + """ + return await _attach("audio", audio) + + +async def _attach_video(video: republic.Video | str, *, context: ToolContext) -> str: + """Attach a video to the result of this `run_code` call, so you can watch it. + + `video` is a `republic.Video`, a file path in the environment, an http(s) URL, or a data URL. + """ + return await _attach("video", video) + + +attach_image = Tool.from_callable(_attach_image, name="attach_image", context=True, exposure="code") +attach_audio = Tool.from_callable(_attach_audio, name="attach_audio", context=True, exposure="code") +attach_video = Tool.from_callable(_attach_video, name="attach_video", context=True, exposure="code") +MEDIA_TOOLS = (attach_image, attach_audio, attach_video) +"""Code-only tools attaching media to the ``run_code`` result; they work only inside ``run_code``.""" diff --git a/src/bub/builtin/codemode/republic_bridge.py b/src/bub/core/codemode/republic_bridge.py similarity index 87% rename from src/bub/builtin/codemode/republic_bridge.py rename to src/bub/core/codemode/republic_bridge.py index 902d3ceb..833744d6 100644 --- a/src/bub/builtin/codemode/republic_bridge.py +++ b/src/bub/core/codemode/republic_bridge.py @@ -1,7 +1,8 @@ -"""Serve the ``republic`` module of ``run_code``: Republic values over JSON and model calls made in Bub. +"""Serve the ``republic`` module of ``run_code``: Republic values over JSON and model calls made by the host. Model-written code never sees provider credentials: its models are proxies whose calls arrive -here as tool calls named ``republic.`` (see ``code_runner_child``). +here as tool calls named ``republic.`` (see ``runner_child``), and a +:data:`ModelResolver` turns their specs into Republic specs and client options. """ from __future__ import annotations @@ -35,6 +36,23 @@ REPUBLIC_CALL_PREFIX = "republic." REPUBLIC_KEY = "$republic" +ModelResolver = Callable[[str | None, ToolContext], tuple[str, Mapping[str, Any]]] +"""Resolve the spec the code passed to ``republic.get_*model`` (None for ``get_model()``) to a Republic +``provider:model`` spec and the client options to open it with. Raise ``ValueError`` to reject the spec.""" + + +def default_model_resolver(default: str | None = None) -> ModelResolver: + """Pass specs through, with ``default`` for ``get_model()``; clients read credentials from the environment.""" + + def resolve(spec: str | None, context: ToolContext) -> tuple[str, Mapping[str, Any]]: + spec = spec or default + if not spec: + raise ValueError("No default model: pass a 'provider:model' spec.") + return spec, {} + + return resolve + + _MEDIA_CLASSES: dict[str, type[republic.Image | republic.Audio | republic.Video]] = { "image": republic.Image, "audio": republic.Audio, @@ -98,21 +116,6 @@ def _encode_message(message: republic.Message) -> dict[str, Any]: return {REPUBLIC_KEY: "message", "role": message.role, "content": content} -def _model_spec(context: ToolContext, spec: str | None) -> tuple[str, dict[str, Any]]: - """Resolve a spec, or the session's model, to a Republic spec and client options from Bub's settings.""" - agent = context.state.get("_runtime_agent") - settings = getattr(agent, "settings", None) - if settings is None: - raise BubError(ErrorKind.INVALID_INPUT, "Republic models need a running Bub agent.") - spec = spec or context.state.get("model") or settings.model - try: - candidate = settings.model_candidates(spec)[0] - except ValueError as exc: - raise BubError(ErrorKind.INVALID_INPUT, str(exc)) from exc - client_kwargs = settings.model_client_kwargs(candidate.provider_name or candidate.provider) - return f"{candidate.provider}:{candidate.model_id}", client_kwargs - - def _chat_messages(messages: list[Any], options: dict[str, Any]) -> list[republic.Message]: if "tools" in options or "tool_choice" in options: raise BubError(ErrorKind.INVALID_INPUT, "republic models in run_code do not support tools.") @@ -196,8 +199,9 @@ async def aclose(self) -> None: class RepublicSession: """Serve the ``republic.`` calls of one ``run_code``; ``aclose`` closes streams left open.""" - def __init__(self, context: ToolContext) -> None: + def __init__(self, context: ToolContext, resolve_model: ModelResolver) -> None: self.context = context + self._resolve_model = resolve_model self._streams: dict[str, _StreamPump] = {} self._operations: dict[str, Callable[..., Awaitable[Any]]] = { "chat": self._chat, @@ -222,20 +226,26 @@ async def call(self, name: str, arguments: dict[str, Any]) -> Any: except Exception as exc: raise BubError(ErrorKind.TOOL, f"{name} failed: {type(exc).__name__}: {exc}") from exc + def _model_spec(self, spec: str | None) -> tuple[str, Mapping[str, Any]]: + try: + return self._resolve_model(spec, self.context) + except ValueError as exc: + raise BubError(ErrorKind.INVALID_INPUT, str(exc)) from exc + async def aclose(self) -> None: streams, self._streams = list(self._streams.values()), {} await asyncio.gather(*(stream.aclose() for stream in streams), return_exceptions=True) async def _chat(self, spec: str | None, messages: list[Any], options: dict[str, Any]) -> Any: messages = _chat_messages(messages, options) - model_spec, client_kwargs = _model_spec(self.context, spec) + model_spec, client_kwargs = self._model_spec(spec) async with republic.get_model(model_spec, **client_kwargs) as model: response = await model.chat(messages, **options) return _encode_response(response) async def _stream_open(self, spec: str | None, messages: list[Any], options: dict[str, Any]) -> Any: messages = _chat_messages(messages, options) - model_spec, client_kwargs = _model_spec(self.context, spec) + model_spec, client_kwargs = self._model_spec(spec) async with AsyncExitStack() as stack: model = await stack.enter_async_context(republic.get_model(model_spec, **client_kwargs)) stream = await stack.enter_async_context(model.stream(messages, **options)) @@ -257,7 +267,7 @@ async def _stream_close(self, stream: str) -> None: await pump.aclose() async def _embed(self, spec: str, texts: list[str], dimensions: int | None = None) -> Any: - model_spec, client_kwargs = _model_spec(self.context, spec) + model_spec, client_kwargs = self._model_spec(spec) async with republic.get_embedding_model(model_spec, **client_kwargs) as model: response = await model.embed_many(texts, dimensions=dimensions) return {"vectors": response.vectors, "model": response.model, "token_usage": asdict(response.token_usage)} @@ -265,7 +275,7 @@ async def _embed(self, spec: str, texts: list[str], dimensions: int | None = Non async def _decide(self, spec: str, state: Any, questions: dict[str, Question]) -> Any: if not all(isinstance(question, Noul | Choice | Score) for question in questions.values()): raise BubError(ErrorKind.INVALID_INPUT, "decide() takes republic.decisions questions.") - model_spec, client_kwargs = _model_spec(self.context, spec) + model_spec, client_kwargs = self._model_spec(spec) async with republic.get_decision_model(model_spec, **client_kwargs) as model: response = await model.decide(state, questions=questions) return { diff --git a/src/bub/builtin/codemode/code_runner.py b/src/bub/core/codemode/runner.py similarity index 96% rename from src/bub/builtin/codemode/code_runner.py rename to src/bub/core/codemode/runner.py index faffeb73..f9c86156 100644 --- a/src/bub/builtin/codemode/code_runner.py +++ b/src/bub/core/codemode/runner.py @@ -1,6 +1,6 @@ """Run ``Environment.run_code`` code in a Python process started with ``Environment.spawn``. -The process runs ``code_runner_child`` and talks to Bub over stdin/stdout with +The process runs ``runner_child`` and talks to Bub over stdin/stdout with JSON lines; see that module for the protocol. """ @@ -17,7 +17,7 @@ from pydantic_core import to_jsonable_python -from bub.builtin.codemode import code_runner_child +from bub.core.codemode import runner_child from bub.environment import CodeFailed from bub.errors import BubError, ErrorKind @@ -25,7 +25,7 @@ from bub.environment import CallTool, Environment, Process _STOP_TIMEOUT_SECONDS = 3.0 -_CHILD_SOURCE = Path(code_runner_child.__file__).read_text(encoding="utf-8") +_CHILD_SOURCE = Path(runner_child.__file__).read_text(encoding="utf-8") class _Session: diff --git a/src/bub/builtin/codemode/code_runner_child.py b/src/bub/core/codemode/runner_child.py similarity index 100% rename from src/bub/builtin/codemode/code_runner_child.py rename to src/bub/core/codemode/runner_child.py diff --git a/src/bub/core/codemode/stub.py b/src/bub/core/codemode/stub.py new file mode 100644 index 00000000..f57726bc --- /dev/null +++ b/src/bub/core/codemode/stub.py @@ -0,0 +1,277 @@ +"""Render tools as a Python stub that tells model-written code their signatures and result types.""" + +from __future__ import annotations + +import keyword +import re +from collections.abc import Iterable +from typing import Any + +from bub.core.tools import Tool, model_tools + +_STUB_HEADER = '''"""Tools available inside `run_code` as `tools.(...)`. + +Every tool is an async function: `await` it (top-level `await` is allowed) and pass keyword +arguments only. Each call returns the structured value described by its return type and +raises an exception when the tool fails. Use `asyncio.gather` to run independent calls +concurrently. + +The `republic` module is also available (preset as a global and importable); it is documented +at the end of this file. +""" + +from typing import Any, Literal, NotRequired, TypedDict + +import republic''' + +_REPUBLIC_STUB = """# --------------------------------------------------------------------------- +# `republic`: media and models inside `run_code`. Models are served by the host with +# its credentials and settings; every model method is async. Only the members below exist. +# +# class republic.Image / republic.Audio / republic.Video: +# __init__(media_type: str, data: bytes | None = None, url: str | None = None) +# media_type: str; data: bytes | None; url: str | None +# base64_data: str; data_url: str +# `tools.attach_image/audio/video` also take a file path in the environment, an http(s)/gs URL +# or a data URL instead, and load it for you. +# +# republic.system(text: str) -> Message +# republic.user(*content: str | Image | Audio | Video) -> Message +# republic.assistant(*content: str | Image | Audio | Video) -> Message +# Message has .role, .content (list of text and media) and .text. +# +# republic.get_model(spec: str | None = None) -> ChatModel +# A "provider:model" spec; None is the model running this session. +# await model.chat(prompt: str | Message | list[str | Message], **options) -> response +# options: max_tokens, temperature, top_p, top_k, stop, seed, reasoning_effort, extra_body... +# (no tools). A plain string is a user message. +# response.text, .reasoning, .refusal, .finish_reason, .model, +# .token_usage.input_tokens / .output_tokens, .message (append it to continue the conversation) +# model.stream(prompt, **options) -> Stream # same arguments as chat(); not awaited +# async with model.stream(...) as stream: +# async for event in stream: ... +# Events from republic.events: TextDelta(.chunk), ReasoningDelta(.chunk), RefusalDelta(.chunk), +# UsageDelta(.usage), ImageReady(.image), CitationAdded(.citation), Completed(.response, last). +# After iterating: stream.response (as returned by chat()), stream.text, .reasoning, .token_usage. +# republic.get_embedding_model(spec: str) -> EmbeddingModel +# await model.embed(text: str, *, dimensions: int | None = None) -> response # response.vector +# await model.embed_many(texts: list[str], *, dimensions: int | None = None) -> response # response.vectors +# republic.get_decision_model(spec: str) -> DecisionModel +# await model.decide(state, *, questions: dict[str, Question]) -> response +# Questions from republic.decisions: Noul(instructions, criteria=None) (yes/no), +# Choice(instructions, criteria) (pick one), Score(instructions, criteria) (ordered levels). +# response.answers[id] or response.: .noul | .choice/.probabilities/.confidence | .score/... +# +# Example: +# model = republic.get_model() +# chart = republic.Image("image/png", data=open("chart.png", "rb").read()) +# reply = await model.chat([republic.user("Describe this chart.", chart)]) +# print(reply.text) +# async with model.stream("Write a haiku.") as stream: +# async for event in stream: +# if isinstance(event, republic.events.TextDelta): +# print(event.chunk, end="")""" +_JSON_TYPES = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None"} + + +def _identifier(name: str) -> str: + ident = re.sub(r"\W", "_", name) + if not ident or ident[0].isdigit() or keyword.iskeyword(ident): + ident = f"_{ident}" + return ident + + +def _is_identifier(name: str) -> bool: + return name.isidentifier() and not keyword.iskeyword(name) + + +def _pascal(name: str) -> str: + return "".join(part[:1].upper() + part[1:] for part in re.split(r"[^0-9A-Za-z]+", name)) + + +_MEDIA_FIELDS = {"media_type", "data", "url"} + + +def _media_class(schema: dict[str, Any]) -> str | None: + """Recognize the JSON schema of ``republic.Image``, ``Audio`` and ``Video`` parameters.""" + title = schema.get("title") + properties = schema.get("properties") + if title in ("Image", "Audio", "Video") and isinstance(properties, dict) and set(properties) == _MEDIA_FIELDS: + return str(title) + return None + + +def _docstring(text: str, indent: str = " ") -> list[str]: + text = text.strip().replace("\\", "\\\\").replace('"""', '\\"\\"\\"') + if not text: + return [] + lines = text.splitlines() + if len(lines) == 1: + return [f'{indent}"""{lines[0]}"""'] + return [f'{indent}"""{lines[0]}', *(f"{indent}{line}".rstrip() for line in lines[1:]), f'{indent}"""'] + + +class _StubBuilder: + """Render tool JSON schemas as Python stub declarations.""" + + def __init__(self) -> None: + self.classes: dict[str, str] = {} + self._refs: dict[tuple[int, str], str] = {} + + def function(self, tool_item: Tool) -> str: + name = _identifier(tool_item.name) + schema = tool_item.parameters or {} + defs = self._defs(schema) + properties = schema.get("properties") or {} + required = set(schema.get("required") or []) + params: list[str] = [] + arg_docs: list[str] = [] + for key, prop in properties.items(): + hint = self.type_hint(prop, defs, f"{_pascal(name)}{_pascal(key)}") + if key in required: + params.append(f"{key}: {hint}") + elif isinstance(prop, dict) and "default" in prop: + params.append(f"{key}: {hint} = {prop['default']!r}") + else: + params.append(f"{key}: {hint} = ...") + description = prop.get("description") if isinstance(prop, dict) else None + if description: + arg_docs.append(f" {key}: {' '.join(description.split())}") + if not all(_is_identifier(key) for key in properties): + signature = "**kwargs: Any" + elif params: + signature = f"*, {', '.join(params)}" + else: + signature = "" + + output = tool_item.output_schema + returns = "Any" if output is None else self.type_hint(output, self._defs(output), f"{_pascal(name)}Result") + doc = tool_item.description.strip() + if arg_docs: + doc = f"{doc}\n\nArgs:\n" + "\n".join(arg_docs) if doc else "Args:\n" + "\n".join(arg_docs) + body = _docstring(doc) or [" ..."] + return "\n".join([f"async def {name}({signature}) -> {returns}:", *body]) + + def type_hint( # noqa: C901 + self, schema: Any, defs: dict[str, Any], name_hint: str, ref_key: tuple[int, str] | None = None + ) -> str: + if not isinstance(schema, dict) or not schema: + return "Any" + if (media := _media_class(schema)) is not None: + return f"republic.{media}" + if local_defs := self._defs(schema): + # Schemas of single parameters carry their own definitions. + defs = {**defs, **local_defs} + if isinstance(ref := schema.get("$ref"), str): + return self._ref(ref, defs) + for key in ("anyOf", "oneOf"): + if isinstance(options := schema.get(key), list): + return self._union(self.type_hint(option, defs, name_hint) for option in options) + if isinstance(all_of := schema.get("allOf"), list) and len(all_of) == 1: + return self.type_hint(all_of[0], defs, name_hint, ref_key) + if "const" in schema: + return f"Literal[{schema['const']!r}]" + if isinstance(enum := schema.get("enum"), list) and enum: + return f"Literal[{', '.join(repr(value) for value in enum)}]" + + schema_type = schema.get("type") + if isinstance(schema_type, list): + return self._union(self.type_hint({**schema, "type": item}, defs, name_hint) for item in schema_type) + if schema_type == "array": + if isinstance(prefix := schema.get("prefixItems"), list): + return f"tuple[{', '.join(self.type_hint(item, defs, name_hint) for item in prefix)}]" + return f"list[{self.type_hint(schema.get('items'), defs, f'{name_hint}Item')}]" + if schema_type == "object": + if isinstance(properties := schema.get("properties"), dict) and properties: + return self._typed_dict(schema, properties, defs, schema.get("title") or name_hint, ref_key) + extra = schema.get("additionalProperties") + if isinstance(extra, dict) and extra: + return f"dict[str, {self.type_hint(extra, defs, f'{name_hint}Value')}]" + return "dict[str, Any]" + return _JSON_TYPES.get(schema_type, "Any") if isinstance(schema_type, str) else "Any" + + @staticmethod + def _defs(schema: dict[str, Any]) -> dict[str, Any]: + defs = schema.get("$defs", schema.get("definitions")) + return defs if isinstance(defs, dict) else {} + + @staticmethod + def _union(hints: Iterable[str]) -> str: + unique = list(dict.fromkeys(hints)) + return "Any" if "Any" in unique else " | ".join(unique) + + def _ref(self, ref: str, defs: dict[str, Any]) -> str: + name = ref.rsplit("/", 1)[-1] + key = (id(defs), name) + if key in self._refs: + return self._refs[key] + target = defs.get(name) + if not isinstance(target, dict): + return "Any" + hint = self.type_hint(target, defs, name, ref_key=key) + self._refs[key] = hint + return hint + + def _typed_dict( + self, + schema: dict[str, Any], + properties: dict[str, Any], + defs: dict[str, Any], + title: str, + ref_key: tuple[int, str] | None, + ) -> str: + base = _identifier(_pascal(title) or "Result") + class_name = base + index = 2 + while class_name in self.classes: + class_name = f"{base}{index}" + index += 1 + # Reserve the name first so recursive references resolve to this class. + self.classes[class_name] = "" + if ref_key is not None: + self._refs[ref_key] = class_name + + required = set(schema.get("required") or []) + fields: list[tuple[str, str, str | None]] = [] + for key, prop in properties.items(): + hint = self.type_hint(prop, defs, f"{class_name}{_pascal(key)}") + if key not in required: + hint = f"NotRequired[{hint}]" + description = prop.get("description") if isinstance(prop, dict) else None + fields.append((key, hint, description)) + source = self._class_source(class_name, fields, schema.get("description")) + + # Reuse an identical class that was declared under the same title by another tool. + for existing, existing_source in self.classes.items(): + if ( + existing != class_name + and re.fullmatch(rf"{re.escape(base)}\d*", existing) + and existing_source == source.replace(class_name, existing) + ): + del self.classes[class_name] + if ref_key is not None: + self._refs[ref_key] = existing + return existing + self.classes[class_name] = source + return class_name + + @staticmethod + def _class_source(name: str, fields: list[tuple[str, str, str | None]], description: object) -> str: + if not all(_is_identifier(key) for key, _, _ in fields): + items = ", ".join(f"{key!r}: {hint}" for key, hint, _ in fields) + return f"{name} = TypedDict({name!r}, {{{items}}})" + lines = [f"class {name}(TypedDict):"] + if isinstance(description, str): + lines.extend(_docstring(description)) + for key, hint, field_description in fields: + if field_description: + lines.append(f" # {' '.join(field_description.split())}") + lines.append(f" {key}: {hint}") + return "\n".join(lines) + + +def render_tool_stub(tools: Iterable[Tool]) -> str: + """Render a Python stub declaring one function per model-facing tool.""" + builder = _StubBuilder() + functions = [builder.function(tool_item) for tool_item in model_tools(tools)] + return "\n\n\n".join([_STUB_HEADER, *builder.classes.values(), *functions, _REPUBLIC_STUB]) + "\n" diff --git a/src/bub/environment.py b/src/bub/environment.py index 6dab1024..242d5b3e 100644 --- a/src/bub/environment.py +++ b/src/bub/environment.py @@ -5,15 +5,20 @@ ``bash``, ``fs.*`` and ``run_code`` run on top of it. Tool semantics (background shells, timeouts, rendering, hooks) stay on the host; only execution moves into the environment. Plugins provide one per session through the ``provide_environment`` hook; Bub's -builtin hooks provide ``bub.builtin.environment.LocalEnvironment``, which runs -everything on the host. +builtin hooks provide :class:`LocalEnvironment`, which runs everything on the host. """ from __future__ import annotations import abc import asyncio +import contextlib +import os +import shutil +import signal +import sys from collections.abc import Awaitable, Callable, Mapping, Sequence +from pathlib import Path from typing import Any ENVIRONMENT_STATE_KEY = "_runtime_environment" @@ -140,16 +145,132 @@ async def run_code( The code may use top-level ``await``. For each name in ``tools``, ``tools.(**kwargs)`` must be an async function that returns ``await call_tool(name, kwargs)``; errors from ``call_tool`` surface in the code as exceptions. The code may also call ``call_tool`` with names - prefixed ``republic.``; ``bub.builtin.codemode.code_runner_child`` shows the ``republic`` module + prefixed ``republic.``; ``bub.core.codemode.runner_child`` shows the ``republic`` module that makes those calls. Pass everything the code writes to stdout to ``write`` as it happens, so output survives a timeout. Raise :class:`CodeFailed` when the code raises; any other exception reports that the runtime itself failed. When cancelled (for example on timeout), stop the code before returning. How the code runs is up to the environment: a Python process, an embedded interpreter or a - remote code interpreter. ``bub.builtin.codemode.code_runner.run_code_in_subprocess`` implements it + remote code interpreter. ``bub.core.codemode.runner.run_code_in_subprocess`` implements it on top of :meth:`spawn` for environments that have a Python interpreter. """ async def close(self) -> None: # noqa: B027 """Release the environment's resources. Called when the framework stops.""" + + +class LocalProcess(Process): + """A host process in its own session, so signals reach the whole process group.""" + + def __init__(self, process: asyncio.subprocess.Process) -> None: + self.process = process + + @property + def pid(self) -> int: + return self.process.pid + + @property + def stdout(self) -> asyncio.StreamReader: + assert self.process.stdout is not None # noqa: S101 + return self.process.stdout + + @property + def stderr(self) -> asyncio.StreamReader: + assert self.process.stderr is not None # noqa: S101 + return self.process.stderr + + @property + def returncode(self) -> int | None: + return self.process.returncode + + async def wait(self) -> int: + return await self.process.wait() + + async def write_stdin(self, data: bytes) -> None: + assert self.process.stdin is not None # noqa: S101 + self.process.stdin.write(data) + await self.process.stdin.drain() + + def close_stdin(self) -> None: + if self.process.stdin is not None: + self.process.stdin.close() + + def signal(self, *, kill: bool) -> None: + with contextlib.suppress(ProcessLookupError): + if os.name != "nt": + os.killpg(self.process.pid, signal.SIGKILL if kill else signal.SIGTERM) + elif self.returncode is None: + if kill: + self.process.kill() + else: + self.process.terminate() + + def is_running(self) -> bool: + if os.name == "nt": + return self.returncode is None + try: + os.killpg(self.process.pid, 0) + except ProcessLookupError: + return False + except PermissionError: + # EPERM does not establish that the group is gone (in particular + # while its leader is exiting on macOS). + return True + return True + + +class LocalEnvironment(Environment): + """Run processes and access files directly on the host.""" + + SHELL = shutil.which("bash") or shutil.which("sh") if os.name != "nt" else None + + def __init__(self, workspace: str | Path | None = None) -> None: + self.workspace = str(workspace) if workspace is not None else None + self.python = sys.executable + + async def spawn( + self, command: str | Sequence[str], *, cwd: str | None = None, env: Mapping[str, str] | None = None + ) -> LocalProcess: + options: dict[str, Any] = { + "cwd": cwd or self.workspace, + "env": {**os.environ, **env} if env else None, + "stdin": asyncio.subprocess.PIPE, + "stdout": asyncio.subprocess.PIPE, + "stderr": asyncio.subprocess.PIPE, + "start_new_session": os.name != "nt", + } + if isinstance(command, str): + process = await asyncio.create_subprocess_shell(command, executable=self.SHELL, **options) + else: + process = await asyncio.create_subprocess_exec(*command, **options) + return LocalProcess(process) + + async def read_text(self, path: str) -> str: + return await asyncio.to_thread(Path(path).read_text, encoding="utf-8") + + async def read_bytes(self, path: str) -> bytes: + return await asyncio.to_thread(Path(path).read_bytes) + + async def write_text(self, path: str, content: str) -> None: + def write() -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + await asyncio.to_thread(write) + + async def run_code( + self, code: str, *, tools: Sequence[str], call_tool: CallTool, write: Callable[[str], None] + ) -> None: + from bub.core.codemode.runner import run_code_in_subprocess + + await run_code_in_subprocess(self, code, tools=tools, call_tool=call_tool, write=write) + + def resolve_path(self, path: str) -> str: + expanded = Path(path).expanduser() + if expanded.is_absolute(): + return str(expanded) + if self.workspace is None: + raise ValueError(f"relative path '{path}' is not allowed without a workspace") + return str((Path(self.workspace) / expanded).resolve()) diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index af4f3b58..7bcd7c06 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -139,7 +139,7 @@ def provide_environment(self, session_id: str, workspace: Path) -> Environment | Called once per session and cached until ``framework.running()`` exits, which closes it with ``Environment.close()``. ``workspace`` is the host - workspace. Bub's builtin hooks provide a host environment (``bub.builtin.environment.LocalEnvironment``); + workspace. Bub's builtin hooks provide a host environment (``bub.environment.LocalEnvironment``); a higher-priority plugin replaces it. """ diff --git a/tests/test_codemode.py b/tests/test_codemode.py index ff7f4e15..29cb4ec1 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -22,12 +22,11 @@ set_code_mode, 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.environment import ENVIRONMENT_STATE_KEY, CallTool, CodeFailed, LocalEnvironment, LocalProcess from bub.errors import BubError, ErrorKind from bub.hooks.interception import ToolCallDecision @@ -588,8 +587,8 @@ async def test_run_code_republic_models_are_served_by_bub_settings( import republic from republic.decisions import Noul - from bub.builtin.codemode import republic_bridge from bub.builtin.settings import AgentSettings + from bub.core.codemode import republic_bridge _FakeChatModel.calls = [] for getter in ("get_model", "get_embedding_model", "get_decision_model"): @@ -673,8 +672,8 @@ def stream(self, messages: Any, **options: Any) -> _FakeStream: def _patch_streaming_model(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: - from bub.builtin.codemode import republic_bridge from bub.builtin.settings import AgentSettings + from bub.core.codemode import republic_bridge _FakeStream.closed = [] monkeypatch.setattr(republic_bridge.republic, "get_model", lambda spec, **kwargs: _FakeStreamingModel()) diff --git a/tests/test_core_codemode.py b/tests/test_core_codemode.py new file mode 100644 index 00000000..4e84c507 --- /dev/null +++ b/tests/test_core_codemode.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +import republic + +from bub.core import Agent +from bub.core.codemode import CodeMode, default_model_resolver +from bub.core.events import ToolCallStarted +from bub.core.store import InMemoryTapeStore +from bub.core.tools import Tool, ToolContext, ToolExecutor +from bub.environment import LocalEnvironment +from tests.model_fakes import ProviderService, chat_events, sse, tool_events +from tests.test_core_boundary import APP_MODULES + + +def lookup(order_id: str) -> dict[str, Any]: + """Look up an order.""" + return {"id": order_id, "total": 12.5} + + +def _run_code_call(code: str) -> object: + arguments = json.dumps({"code": code}) + return sse(tool_events([{"index": 0, "id": "call-1", "function": {"name": "run_code", "arguments": arguments}}])) + + +def test_importing_core_codemode_loads_no_app_dependencies() -> None: + script = ( + "import sys, bub.core.codemode\n" + "loaded = {name.split('.')[0] for name in sys.modules}\n" + f"print(sorted(loaded & set({APP_MODULES!r})))\n" + "print(sorted(name for name in sys.modules if name.startswith(('bub.builtin', 'bub.framework'))))\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=True) + + assert result.stdout.splitlines() == ["[]", "[]"] + + +@pytest.mark.asyncio +async def test_core_agent_calls_tools_from_code(provider_service: ProviderService, tmp_path: Path) -> None: + provider_service.reply(_run_code_call("order = await tools.orders_get(order_id='A1')\nprint(order['total'])")) + provider_service.reply(sse(chat_events("It costs 12.5."))) + code_mode = CodeMode([Tool.from_callable(lookup, name="orders.get")], environment=LocalEnvironment(tmp_path)) + calls: list[tuple[str, bool]] = [] + + async with provider_service.client() as client: + model = republic.get_model("openai:test", http_client=client, api_format="chat", api_key="test") + agent = Agent( + store=InMemoryTapeStore(), tools=[code_mode.run_code], system_prompt=["Be brief.", code_mode.prompt] + ) + agent.events.on( + ToolCallStarted, + lambda event: calls.append((event.call.tool, event.call.context.code_mode)), # type: ignore[union-attr] + ) + async with agent.run(["How much is order A1?"], model=model, tape="session") as stream: + events = [event async for event in stream] + + assert events[-1].data == {"ok": True, "text": "It costs 12.5."} + first, second = provider_service.body(0), provider_service.body(1) + assert [tool["function"]["name"] for tool in first["tools"]] == ["run_code"] + system_prompt = first["messages"][0]["content"] + assert system_prompt.startswith("Be brief.\n\n") + assert "async def orders_get(*, order_id: str) -> dict[str, Any]:" in system_prompt + assert "async def attach_image(" in system_prompt + assert second["messages"][-1] == {"role": "tool", "tool_call_id": "call-1", "content": "12.5\n"} + assert calls == [("run_code", False), ("orders_get", True)] + + +def test_code_mode_prompt_points_at_a_stub_file_in_stub_dir(tmp_path: Path) -> None: + code_mode = CodeMode([Tool.from_callable(lookup)], stub_dir=tmp_path / "stubs", media_tools=False) + + prompt = code_mode.prompt(_prompt_context()) + + assert prompt is not None + stub_path = next((tmp_path / "stubs").iterdir()) + assert f"in the stub file: {stub_path}\n" in prompt + assert "async def lookup(*, order_id: str)" in stub_path.read_text(encoding="utf-8") + assert "attach_image" not in stub_path.read_text(encoding="utf-8").split("# `republic`")[0] + + +@pytest.mark.asyncio +async def test_code_mode_selects_tools_per_run_from_state(tmp_path: Path) -> None: + code_mode = CodeMode(lambda state: state.get("code_tools"), environment=LocalEnvironment(tmp_path)) + tape = Agent().tape("session") + + assert code_mode.prompt(_prompt_context()) is None + execution = await ToolExecutor().execute_many( + [(code_mode.run_code, {"code": "print(1)"})], context=ToolContext(tape=tape) + ) + assert execution.error is not None + assert execution.error.message == "Code mode is not enabled for this run." + + state = {"code_tools": [Tool.from_callable(lookup)]} + output = await code_mode.run_code.run( + code="print((await tools.lookup(order_id='B2'))['id'])", context=ToolContext(tape=tape, state=state) + ) + assert output == "B2\n" + assert [tool.name for tool in code_mode.code_tools(state) or []] == [ + "lookup", + "attach_image", + "attach_audio", + "attach_video", + ] + + +def test_default_model_resolver_needs_a_spec_or_default() -> None: + context = ToolContext(tape=Agent().tape("session")) + + assert default_model_resolver("openai:gpt-x")(None, context) == ("openai:gpt-x", {}) + assert default_model_resolver("openai:gpt-x")("anthropic:claude", context) == ("anthropic:claude", {}) + with pytest.raises(ValueError, match="No default model"): + default_model_resolver()(None, context) + + +@pytest.mark.asyncio +async def test_code_republic_models_use_the_resolver(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def resolve(spec: str | None, context: ToolContext) -> tuple[str, dict[str, Any]]: + return f"resolved:{spec}", {"api_key": "secret"} + + def get_model(spec: str, **kwargs: Any) -> Any: + raise RuntimeError(f"{spec} {kwargs}") + + monkeypatch.setattr(republic, "get_model", get_model) + code_mode = CodeMode(environment=LocalEnvironment(tmp_path), models=resolve) + code = "try:\n await republic.get_model('x').chat('hi')\nexcept Exception as exc:\n print(exc)\n" + + output = await code_mode.run_code.run(code=code, context=ToolContext(tape=Agent().tape("session"))) + + assert output == "[tool] republic.chat failed: RuntimeError: resolved:x {'api_key': 'secret'}\n" + + +def _prompt_context() -> Any: + from bub.core import PromptContext + + agent = Agent() + return PromptContext(agent=agent, tape=agent.tape("session"), prompt=[], tools=[], model="openai:test") diff --git a/tests/test_environment.py b/tests/test_environment.py index 4d5b066d..77f0c5ae 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -6,12 +6,12 @@ import pytest import bub.builtin.tools as builtin_tools -from bub.builtin.environment import LocalEnvironment, LocalProcess, environment_from_state +from bub.builtin.environment import environment_from_state from bub.builtin.shell_manager import ShellManager from bub.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.environment import ENVIRONMENT_STATE_KEY, CallTool, Environment, LocalEnvironment, LocalProcess, Process from bub.framework import BubFramework from bub.hooks import hookimpl diff --git a/website/src/content/docs/docs/build/tools.mdx b/website/src/content/docs/docs/build/tools.mdx index 506883b9..80a145a8 100644 --- a/website/src/content/docs/docs/build/tools.mdx +++ b/website/src/content/docs/docs/build/tools.mdx @@ -77,6 +77,30 @@ Nested calls run with `ToolContext.code_mode` set to `True` (hooks see it as `ca `run_code` hands the code to the session [environment](#run-tools-in-an-environment)'s `run_code`, together with a `call_tool` callback. Tool calls always run on the host through that callback, so hooks still see every call. `timeout_seconds` cancels the environment call. The builtin `LocalEnvironment` starts a fresh Python process for every call (`sys.executable`, working directory is the workspace) and forwards tool calls as JSON lines over its stdin and stdout, so arguments and results must be JSON-serializable. When the code finishes, fails or times out, it kills the process and everything it started. That process runs on the host with the same permissions as `bash`, so enable code mode only where `bash` would be acceptable. The return annotation of a tool function (or `Tool.output_schema`, for tools built by hand) determines the result type shown in the stub. +#### Code mode in your own agent + +Code mode lives in the kernel (`bub.core.codemode`), so an agent built on `bub.core.Agent` can use it without the `app` extra. A `CodeMode` holds the tools callable from code and provides the `run_code` tool and a system prompt section: + +```python +from bub.core import Agent +from bub.core.codemode import CodeMode +from bub.environment import LocalEnvironment + +code_mode = CodeMode( + [search, fetch], # tools callable as tools.(...) inside run_code + environment=LocalEnvironment("/work"), # where the code runs; defaults to the current directory + models="openai:gpt-5", # what republic.get_model() in the code resolves to +) +agent = Agent(model=model, tools=[code_mode.run_code], system_prompt=["You are ...", code_mode.prompt]) +``` + +- `tools` can also be a function of the turn state returning the tools for that run, or `None` to turn code mode off; `environment` can likewise be a function of the state. +- `models` is a default `provider:model` spec, or a resolver `(spec, context) -> (republic_spec, client_kwargs)` that keeps credentials on the host. Without it the code must pass explicit specs and clients read credentials from the environment. +- The stub goes into the system prompt itself; pass `stub_dir=` to write it to a file and reference the path instead, for agents that can read files. +- `media_tools=True` (default) adds `attach_image`, `attach_audio` and `attach_video`. + +Calls made from code go through the agent's `ToolCallStarted`/`ToolCallFinished` events with `context.code_mode` set, so handlers registered with `agent.events.on` see them. Bub's own agents use the same class (`bub.builtin.codemode.CODE_MODE`), wired to session settings, environments and model settings. + ### Load tools on demand Mark a tool `deferred=True` (on `@tool`, `Tool.from_callable`, or with `dataclasses.replace(tool, deferred=True)`) to keep its definition out of model requests until it is needed. Tools without the flag are loaded directly. @@ -94,10 +118,10 @@ Mark a tool `deferred=True` (on `@tool`, `Tool.from_callable`, or with `dataclas - `spawn(command, *, cwd=None, env=None)` returns a `Process`: a string runs through the shell, a sequence runs as an argument vector. The process exposes `stdout`/`stderr` streams, `write_stdin`, `wait`, and `signal(kill=...)`, which must reach the whole process tree. - `read_text(path)` and `write_text(path, content)` access files inside the environment. `read_bytes(path)` reads binary files; its default runs the environment's `python` to print the file, so override it when the environment has direct file access. - `workspace` is the working directory inside the environment; `resolve_path` resolves relative paths against it. -- `run_code(code, *, tools, call_tool, write)` runs Python code in which `await tools.(**kwargs)` calls `call_tool(name, kwargs)`. The `republic` module calls `call_tool` with names prefixed `republic.`, and Republic values travel as JSON objects with a `"$republic"` key; the subprocess runner provides it. It passes the code's stdout to `write` as it arrives, raises `CodeFailed` when the code raises, and stops the code when cancelled. How it runs is up to the environment. An environment that has a Python interpreter can reuse `bub.builtin.codemode.code_runner.run_code_in_subprocess`, which runs the code in a process started with `spawn`. +- `run_code(code, *, tools, call_tool, write)` runs Python code in which `await tools.(**kwargs)` calls `call_tool(name, kwargs)`. The `republic` module calls `call_tool` with names prefixed `republic.`, and Republic values travel as JSON objects with a `"$republic"` key; the subprocess runner provides it. It passes the code's stdout to `write` as it arrives, raises `CodeFailed` when the code raises, and stops the code when cancelled. How it runs is up to the environment. An environment that has a Python interpreter can reuse `bub.core.codemode.runner.run_code_in_subprocess`, which runs the code in a process started with `spawn`. - `close()` releases the environment. -Provide one per session with the `provide_environment(session_id, workspace)` hook. Bub calls it on the session's first turn, puts the result in `state["_runtime_environment"]`, and closes it when `framework.running()` exits. Bub's builtin hooks provide `LocalEnvironment` (in [`bub.builtin.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/environment.py)), which runs everything on the host; a plugin's implementation takes precedence over it. Your own tools can use the same environment through `bub.builtin.environment.environment_from_state(context.state)`. +Provide one per session with the `provide_environment(session_id, workspace)` hook. Bub calls it on the session's first turn, puts the result in `state["_runtime_environment"]`, and closes it when `framework.running()` exits. Bub's builtin hooks provide `LocalEnvironment` (in [`bub.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/environment.py)), which runs everything on the host; a plugin's implementation takes precedence over it. Your own tools can use the same environment through `bub.builtin.environment.environment_from_state(context.state)`. ## How tools become available 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 cf675b49..fa009623 100644 --- a/website/src/content/docs/zh-cn/docs/build/tools.mdx +++ b/website/src/content/docs/zh-cn/docs/build/tools.mdx @@ -78,6 +78,30 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 `run_code` 会把代码和一个 `call_tool` 回调一起交给当前 session [执行环境](#在执行环境中运行工具)的 `run_code` 执行。工具调用始终经这个回调在宿主上执行,所以 hook 仍能看到每一次调用。超过 `timeout_seconds` 时会取消执行环境里的执行。builtin 的 `LocalEnvironment` 每次调用都会启动一个新的 Python 进程(使用 `sys.executable`,工作目录为 workspace),工具调用以 JSON 行的形式经进程的 stdin/stdout 转发,因此参数和结果都必须能 JSON 序列化。代码执行完、出错或超时后,它会杀掉该进程及其启动的所有子进程。这个进程直接跑在宿主上,权限与 `bash` 相同,因此只应在允许使用 `bash` 的场景下开启 code mode。stub 中的结果类型来自工具函数的返回注解(手动构造的工具则来自 `Tool.output_schema`)。 +#### 在自己的 agent 中使用 code mode + +Code mode 属于内核(`bub.core.codemode`),基于 `bub.core.Agent` 构建的 agent 不安装 `app` extra 也能使用。`CodeMode` 持有可在代码中调用的工具,并提供 `run_code` 工具和一个 system prompt 片段: + +```python +from bub.core import Agent +from bub.core.codemode import CodeMode +from bub.environment import LocalEnvironment + +code_mode = CodeMode( + [search, fetch], # 在 run_code 中以 tools.(...) 调用的工具 + environment=LocalEnvironment("/work"), # 代码运行的位置,默认是当前目录 + models="openai:gpt-5", # 代码里 republic.get_model() 对应的模型 +) +agent = Agent(model=model, tools=[code_mode.run_code], system_prompt=["You are ...", code_mode.prompt]) +``` + +- `tools` 也可以是以 turn state 为参数的函数,返回本次运行的工具,返回 `None` 表示关闭 code mode;`environment` 同样可以是 state 的函数。 +- `models` 是默认的 `provider:model` spec,或一个 resolver `(spec, context) -> (republic_spec, client_kwargs)`,凭据始终留在宿主侧。不传时代码必须显式给出 spec,客户端从环境变量读取凭据。 +- stub 默认直接放进 system prompt;传入 `stub_dir=` 则写入文件并在 prompt 中引用路径,适合能读文件的 agent。 +- `media_tools=True`(默认)会额外提供 `attach_image`、`attach_audio` 和 `attach_video`。 + +代码发起的调用同样经过 agent 的 `ToolCallStarted`/`ToolCallFinished` 事件,且 `context.code_mode` 为真,因此用 `agent.events.on` 注册的处理器都能看到。Bub 自己的 agent 使用的也是这个类(`bub.builtin.codemode.CODE_MODE`),只是接入了 session 设置、执行环境和模型配置。 + ### 按需加载工具 给工具标记 `deferred=True`(可用于 `@tool`、`Tool.from_callable`,或 `dataclasses.replace(tool, deferred=True)`),它的定义就不会进入模型请求,直到被加载。未标记的工具直接加载。 @@ -95,10 +119,10 @@ Code mode 让模型在 Python 中调用工具,而不必一次只发起一个 - `spawn(command, *, cwd=None, env=None)` 返回一个 `Process`:传字符串时经 shell 执行,传序列时作为参数列表直接执行。进程提供 `stdout`/`stderr` 流、`write_stdin`、`wait` 和 `signal(kill=...)`,其中 `signal` 必须作用到整个进程树。 - `read_text(path)` 和 `write_text(path, content)` 读写执行环境内的文件。`read_bytes(path)` 读取二进制文件;默认实现会用执行环境的 `python` 输出文件内容,能直接访问文件的执行环境应覆盖它。 - `workspace` 是执行环境内的工作目录,`resolve_path` 基于它解析相对路径。 -- `run_code(code, *, tools, call_tool, write)` 执行 Python 代码,代码里的 `await tools.(**kwargs)` 会调用 `call_tool(name, kwargs)`。`republic` 模块会以 `republic.` 前缀的名字调用 `call_tool`,Republic 值以带 `"$republic"` 键的 JSON 对象传输;子进程 runner 已经提供了这个模块。代码写到 stdout 的内容要实时交给 `write`;代码抛异常时抛出 `CodeFailed`;被取消时要停止代码。具体怎么执行由执行环境决定。有 Python 解释器的执行环境可以直接复用 `bub.builtin.codemode.code_runner.run_code_in_subprocess`,它会用 `spawn` 启动进程来执行代码。 +- `run_code(code, *, tools, call_tool, write)` 执行 Python 代码,代码里的 `await tools.(**kwargs)` 会调用 `call_tool(name, kwargs)`。`republic` 模块会以 `republic.` 前缀的名字调用 `call_tool`,Republic 值以带 `"$republic"` 键的 JSON 对象传输;子进程 runner 已经提供了这个模块。代码写到 stdout 的内容要实时交给 `write`;代码抛异常时抛出 `CodeFailed`;被取消时要停止代码。具体怎么执行由执行环境决定。有 Python 解释器的执行环境可以直接复用 `bub.core.codemode.runner.run_code_in_subprocess`,它会用 `spawn` 启动进程来执行代码。 - `close()` 释放执行环境。 -通过 `provide_environment(session_id, workspace)` hook 为每个 session 提供执行环境。Bub 在 session 的第一个 turn 调用它,把结果放进 `state["_runtime_environment"]`,并在 `framework.running()` 退出时关闭。Bub 的 builtin hook 提供 `LocalEnvironment`(见 [`bub.builtin.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/environment.py)),直接在宿主上运行;插件提供的实现会优先于它。自定义工具可以用 `bub.builtin.environment.environment_from_state(context.state)` 使用同一个环境。 +通过 `provide_environment(session_id, workspace)` hook 为每个 session 提供执行环境。Bub 在 session 的第一个 turn 调用它,把结果放进 `state["_runtime_environment"]`,并在 `framework.running()` 退出时关闭。Bub 的 builtin hook 提供 `LocalEnvironment`(见 [`bub.environment`](https://github.com/bubbuild/bub/blob/main/src/bub/environment.py)),直接在宿主上运行;插件提供的实现会优先于它。自定义工具可以用 `bub.builtin.environment.environment_from_state(context.state)` 使用同一个环境。 ## 工具如何变得可用 From f4339663eb489603c7d8592a2da203add0b5b11b Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 17 Oct 2026 19:43:07 +0800 Subject: [PATCH 16/16] feat: add OSC 7501 program status Report command, channel work, and prompt state through the native EventBus. Preserve agent execution, normal text output, and exit codes, and keep redirected output free of status sequences. --- src/bub/application.py | 31 ++++++ src/bub/builtin/cli.py | 2 +- src/bub/channels/manager.py | 53 +++++++-- src/bub/core/events.py | 24 ++-- src/bub/framework.py | 30 ++++- src/bub/inquirer.py | 69 +++++++----- src/bub/program_status.py | 36 ++++++ tests/test_builtin_cli.py | 62 ++++++++--- tests/test_channels.py | 103 ++++++++++-------- tests/test_program_status.py | 97 +++++++++++++++++ .../src/content/docs/docs/build/plugins.mdx | 2 + .../src/content/docs/docs/reference/cli.mdx | 2 + .../content/docs/zh-cn/docs/build/plugins.mdx | 2 + .../content/docs/zh-cn/docs/reference/cli.mdx | 2 + 14 files changed, 403 insertions(+), 112 deletions(-) create mode 100644 src/bub/application.py create mode 100644 src/bub/program_status.py create mode 100644 tests/test_program_status.py diff --git a/src/bub/application.py b/src/bub/application.py new file mode 100644 index 00000000..6cb3beeb --- /dev/null +++ b/src/bub/application.py @@ -0,0 +1,31 @@ +"""CLI, channel work and prompt events published by the application.""" + +from dataclasses import dataclass +from typing import Literal + +from bub.core.events import Event + +type Outcome = Literal["succeeded", "failed", "cancelled"] +type PromptKind = Literal["question", "permission", "auth"] + + +@dataclass(frozen=True) +class CommandChanged(Event): + """A CLI invocation started (None) or finished with an outcome.""" + + outcome: Outcome | None = None + + +@dataclass(frozen=True) +class WorkChanged(Event): + """A channel work snapshot, optionally including the message outcome that caused it.""" + + busy: bool + completed: Outcome | None = None + + +@dataclass(frozen=True) +class PromptChanged(Event): + """A modal prompt opened (kind) or closed (None).""" + + kind: PromptKind | None diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 3b832fd9..10fff3f8 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -173,7 +173,7 @@ def onboard( enabled_channels = str(config_data.get("enabled_channels", "")).strip() if not enabled_channels or not is_gateway_service_supported(): return - if not bub_inquirer.ask_confirm("Install gateway as a background service?", default=False): + if not bub_inquirer.ask_confirm("Install gateway as a background service?", default=False, kind="permission"): return try: diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index 06059e57..14bd6657 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -9,6 +9,7 @@ from pydantic_settings import SettingsConfigDict from bub import config +from bub.application import Outcome, WorkChanged from bub.channels.admission import AdmitDecision, SessionTurnController, TurnSnapshot from bub.channels.base import Channel, Interface, Lifecycle from bub.channels.contracts import MessageHandler @@ -68,6 +69,8 @@ def __init__( else: self._enabled_channels = self._settings.enabled_channels.split(",") self._messages = asyncio.Queue[ChannelMessage]() + self._admitting = False + self._work_publication: asyncio.Task[WorkChanged] | None = None self._started_channels: list[Channel] | None = None self._session_controllers: dict[str, SessionTurnController] = {} self._session_handlers: dict[str, MessageHandler] = {} @@ -82,16 +85,36 @@ async def on_receive(self, message: ChannelMessage) -> None: handler: MessageHandler if self._channels[channel].needs_debounce: handler = BufferedMessageHandler( - self._messages.put, + self._enqueue, active_time_window=self._settings.active_time_window, max_wait_seconds=self._settings.max_wait_seconds, debounce_seconds=self._settings.debounce_seconds, ) else: - handler = self._messages.put + handler = self._enqueue self._session_handlers[session_id] = handler await self._session_handlers[session_id](message) + async def _enqueue(self, message: ChannelMessage) -> None: + await self._messages.put(message) + self._admitting = True + self._notify_work() + + def _notify_work(self, completed: Outcome | None = None) -> None: + if self._started_channels is None: + return + busy = self._admitting or any(controller.active_tasks for controller in self._session_controllers.values()) + event = WorkChanged(busy, completed) + previous = self._work_publication + + async def publish() -> WorkChanged: + # Preserve snapshot order even when a subscriber awaits. + if previous is not None: + await previous + return await self.framework.events.emit(event) + + self._work_publication = asyncio.create_task(publish()) + def get_channel(self, name: str) -> Channel | None: return self._channels.get(name) @@ -199,16 +222,18 @@ def _drop_empty_controller(self, session_id: str) -> None: self._session_controllers.pop(session_id, None) def _on_task_done(self, session_id: str, task: asyncio.Task) -> None: + outcome: Outcome if task.cancelled(): logger.info("channel.manager task cancelled session_id={}", session_id) + outcome = "cancelled" else: - task.exception() # to log any exception + outcome = "failed" if task.exception() is not None else "succeeded" controller = self._session_controllers.get(session_id) - if controller is None: - return - controller.active_tasks.discard(task) - self._schedule_pending(session_id) - self._drop_empty_controller(session_id) + if controller is not None: + controller.active_tasks.discard(task) + self._schedule_pending(session_id) + self._drop_empty_controller(session_id) + self._notify_work(outcome) async def _admit_message(self, message: ChannelMessage) -> bool: try: @@ -349,13 +374,15 @@ async def listen_and_run(self) -> None: for channel in self.enabled_channels(): await channel.start(stop_event) started_channels.append(channel) + self._notify_work() logger.info("channel.manager started listening") try: while True: message = await wait_until_stopped(self._messages.get(), stop_event) - if not await self._admit_message(message): - continue - self._schedule_message(message) + if await self._admit_message(message): + self._schedule_message(message) + self._admitting = not self._messages.empty() + self._notify_work() except asyncio.CancelledError: logger.info("channel.manager received shutdown signal") except Exception: @@ -367,6 +394,10 @@ async def listen_and_run(self) -> None: await self.shutdown() finally: self._started_channels = None + publication = self._work_publication + self._work_publication = None + if publication is not None: + await publication logger.info("channel.manager stopped") async def shutdown(self) -> None: diff --git a/src/bub/core/events.py b/src/bub/core/events.py index b4a6cb38..8a40eeb9 100644 --- a/src/bub/core/events.py +++ b/src/bub/core/events.py @@ -120,15 +120,12 @@ class ToolCallResult: duration_ms: int = 0 -# Events. Each carries the turn ``state``; loop events also carry the run's ``tape``. ``Before*`` +# Agent events carry the turn ``state``; loop events also carry the run's ``tape``. ``Before*`` # events are mutable: handlers may replace the request or call, or set a decision, which stops delivery. -@dataclass(kw_only=True) -class AgentEvent: - """Base class of agent-loop events; a handler for it receives every event.""" - - state: TurnState +class Event: + """Base event shared by publishers using the event bus.""" @property def stopped(self) -> bool: @@ -136,6 +133,13 @@ def stopped(self) -> bool: return False +@dataclass(kw_only=True) +class AgentEvent(Event): + """Base class of agent-loop events; a handler for it receives every agent event.""" + + state: TurnState + + @dataclass(kw_only=True) class TapeEvent(AgentEvent): """An event of a run, carrying the run's tape (whose ``context.state`` is ``state``).""" @@ -269,20 +273,20 @@ class ChatFinished(TapeEvent): context_error: BubError | None = None -E = TypeVar("E", bound=AgentEvent) +E = TypeVar("E", bound=Event) EventHandler = Callable[[E], object] """A sync or async function of an event; its result is awaited if awaitable, then ignored.""" class EventBus: - """Deliver agent events to handlers registered by event type, in registration order. + """Deliver events to handlers registered by event type, in registration order. A handler registered for a base class receives its subclasses. Handler exceptions - propagate to the agent loop. + propagate to the publisher. """ def __init__(self) -> None: - self._handlers: list[tuple[type[AgentEvent], EventHandler[Any]]] = [] + self._handlers: list[tuple[type[Event], EventHandler[Any]]] = [] @overload def on(self, event_type: type[E]) -> Callable[[EventHandler[E]], EventHandler[E]]: ... diff --git a/src/bub/framework.py b/src/bub/framework.py index 4b6cbc40..3b0067dc 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -4,7 +4,7 @@ import asyncio import contextlib -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Iterator from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -14,9 +14,11 @@ from loguru import logger from bub import configure +from bub.application import CommandChanged, Outcome from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.contracts import ChannelRouter, MessageHandler from bub.configure import DEFAULT_HOME +from bub.core.events import Event, EventBus from bub.core.sidecars import TapeSidecar from bub.core.store import AsyncTapeStore, TapeStore from bub.core.tape import Tape, TapeContext @@ -59,6 +61,7 @@ def __init__(self, config_file: Path = DEFAULT_CONFIG_FILE) -> None: self._plugin_manager.add_hookspecs(BubHookSpecs) self._hook_runtime = HookRuntime(self._plugin_manager) self._agent_hooks = PluginAgentHooks(self._hook_runtime) + self.events = EventBus() self._plugin_status: dict[str, PluginStatus] = {} self._channel_router: ChannelRouter | None = None self._tape_store: TapeStore | AsyncTapeStore | None = None @@ -116,8 +119,29 @@ def load_hooks(self) -> None: def create_cli_app(self) -> typer.Typer: """Create CLI app by collecting commands from hooks. Can be used for custom CLI entry point.""" + import sys + + from bub.program_status import ProgramStatus + app = typer.Typer(name="bub", help="A tiny agent runtime, composable with plugins", add_completion=False) + @contextlib.contextmanager + def command_events() -> Iterator[None]: + outcome: Outcome = "failed" + asyncio.run(self.events.emit(CommandChanged())) + try: + yield + except (asyncio.CancelledError, KeyboardInterrupt, EOFError, typer.Abort): + outcome = "cancelled" + raise + except typer.Exit as exc: + outcome = "succeeded" if exc.exit_code == 0 else "failed" + raise + else: + outcome = "succeeded" + finally: + asyncio.run(self.events.emit(CommandChanged(outcome))) + @app.callback(invoke_without_command=True) def _main( ctx: typer.Context, @@ -126,6 +150,10 @@ def _main( if workspace: self.workspace = Path(workspace).resolve() ctx.obj = self + reporter = ProgramStatus(sys.stdout) + self.events.on(Event, reporter) + ctx.call_on_close(lambda: self.events.off(Event, reporter)) + ctx.with_resource(command_events()) self._hook_runtime.call_many_sync("register_cli_commands", app=app) return app diff --git a/src/bub/inquirer.py b/src/bub/inquirer.py index 63a96c5e..53c481ce 100644 --- a/src/bub/inquirer.py +++ b/src/bub/inquirer.py @@ -1,50 +1,67 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from typing import Any, cast import typer +from click import get_current_context from inquirer_textual import prompts from inquirer_textual.common.Choice import Choice from inquirer_textual.common.InquirerResult import InquirerResult from inquirer_textual.common.PromptSettings import PromptSettings from inquirer_textual.common.Shortcut import Shortcut +from bub.application import PromptChanged, PromptKind + CheckboxValidator = Callable[[list[str]], bool | str] CHECKBOX_HINT_SETTINGS = PromptSettings(shortcuts=[Shortcut("space", "toggle", "Space check/uncheck")]) -def ask_prompt(question: InquirerResult[Any]) -> Any: - if question.command in {"ctrl+c", "quit"}: - raise typer.Abort() - answer = question.value - if answer is None: - raise typer.Abort() - return answer +def ask_prompt( + prompt: Callable[..., InquirerResult[Any]], message: str, *, kind: PromptKind = "question", **kwargs: Any +) -> Any: + from bub.framework import BubFramework + + ctx = get_current_context(silent=True) + framework = ctx.find_root().obj if ctx else None + events = framework.events if isinstance(framework, BubFramework) else None + if events is not None: + asyncio.run(events.emit(PromptChanged(kind))) + try: + question = prompt(message, **kwargs) + if question.command in {"ctrl+c", "quit"}: + raise typer.Abort() + answer = question.value + if answer is None: + raise typer.Abort() + return answer + finally: + if events is not None: + asyncio.run(events.emit(PromptChanged(None))) def ask_text(message: str, default: str = "") -> str: - return cast("str", ask_prompt(prompts.text(message, default=default))) + return cast("str", ask_prompt(prompts.text, message, default=default)) def ask_secret(message: str) -> str: - return cast("str", ask_prompt(prompts.secret(message))) + return cast("str", ask_prompt(prompts.secret, message, kind="auth")) -def ask_confirm(message: str, default: bool = False) -> bool: - return cast("bool", ask_prompt(prompts.confirm(message, default=default))) +def ask_confirm(message: str, default: bool = False, *, kind: PromptKind = "question") -> bool: + return cast("bool", ask_prompt(prompts.confirm, message, default=default, kind=kind)) def ask_select(message: str, choices: list[str], default: str = "") -> str: return cast( "str", ask_prompt( - prompts.select( - message, - choices=cast("list[str | Choice]", choices), - default=default, - ) + prompts.select, + message, + choices=cast("list[str | Choice]", choices), + default=default, ), ) @@ -53,11 +70,10 @@ def ask_fuzzy(message: str, choices: list[str], default: str | None = None) -> s return cast( "str", ask_prompt( - prompts.fuzzy( - message, - choices=cast("list[str | Choice]", choices), - default=default, - ) + prompts.fuzzy, + message, + choices=cast("list[str | Choice]", choices), + default=default, ), ) @@ -70,12 +86,11 @@ def ask_checkbox( ) -> list[str]: while True: answer: list[str | Choice] = ask_prompt( - prompts.checkbox( - message, - choices=cast("list[str | Choice]", choices), - enabled=cast("list[str | Choice] | None", enabled), - settings=CHECKBOX_HINT_SETTINGS, - ) + prompts.checkbox, + message, + choices=cast("list[str | Choice]", choices), + enabled=cast("list[str | Choice] | None", enabled), + settings=CHECKBOX_HINT_SETTINGS, ) values = list(cast("list[str]", answer or [])) if validate is None: diff --git a/src/bub/program_status.py b/src/bub/program_status.py new file mode 100644 index 00000000..7a4804bc --- /dev/null +++ b/src/bub/program_status.py @@ -0,0 +1,36 @@ +"""Map application events to a single OSC 7501 root record.""" + +from contextlib import suppress +from typing import TextIO + +from bub.application import CommandChanged, PromptChanged, PromptKind, WorkChanged +from bub.core.events import Event + +_RESULTS = {None: "working", "succeeded": "done", "failed": "error", "cancelled": "idle"} + + +class ProgramStatus: + def __init__(self, output: TextIO | None) -> None: + self.output = output + self.listener = False + + def __call__(self, event: Event) -> None: + kind: PromptKind | None = None + match event: + case CommandChanged(outcome): + state = "idle" if outcome == "succeeded" and self.listener else _RESULTS[outcome] + case WorkChanged(busy, completed): + self.listener = True + state = "working" if busy else _RESULTS[completed] if completed else "idle" + case PromptChanged(kind): + state = "blocked" if kind else "working" + case _: + return + with suppress(OSError): + if self.output is None or not self.output.isatty(): + return + fields = f"state={state}:app=bub" + if kind is not None: + fields += f":kind={kind}" + self.output.write(f"\x1b]7501;{fields}\x1b\\") + self.output.flush() diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index 877651bf..a058bba0 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -2,6 +2,9 @@ import json import os +import re +import sys +from io import StringIO from pathlib import Path from types import SimpleNamespace from typing import Any @@ -11,6 +14,7 @@ import typer from inquirer_textual.common.InquirerResult import InquirerResult from inquirer_textual.common.PromptSettings import PromptSettings +from typer.main import get_command from typer.testing import CliRunner import bub.builtin.cli as cli @@ -26,6 +30,19 @@ def no_model_discovery_network(monkeypatch): monkeypatch.setattr(onboarding, "discover_models", lambda *args, **kwargs: []) +def _terminal_output(monkeypatch: pytest.MonkeyPatch) -> StringIO: + output = StringIO() + monkeypatch.setattr(output, "isatty", lambda: True) + monkeypatch.setattr(sys, "stdout", output) + monkeypatch.setattr(sys, "stderr", output) + return output + + +def _program_status(output: StringIO) -> dict[str, str]: + frames = re.findall(r"\x1b]7501;([^\x1b\x07]*)(?:\x1b\\|\x07)", output.getvalue()) + return dict(pair.split("=", 1) for pair in frames[-1].split(":")) + + def _fake_result(answer: Any, command: str | None = "enter") -> InquirerResult[Any]: return InquirerResult(None, answer, command) @@ -100,7 +117,7 @@ def onboard_config(self, current_config): monkeypatch.setattr( bub_inquirer, "ask_confirm", - lambda message, default=False: default, + lambda message, default=False, **kwargs: default, ) monkeypatch.setattr( bub_inquirer, @@ -153,7 +170,7 @@ def test_onboard_collects_builtin_runtime_config(tmp_path: Path, monkeypatch) -> monkeypatch.setattr( bub_inquirer, "ask_confirm", - lambda message, default=False: message == "Stream output", + lambda message, default=False, **kwargs: message == "Stream output", ) monkeypatch.setattr( bub_inquirer, @@ -211,6 +228,7 @@ def ask_confirm(message: str, default: bool = False) -> bool: def test_onboard_can_install_gateway_after_saving_config(tmp_path: Path, monkeypatch) -> None: + terminal_output = _terminal_output(monkeypatch) config_file = tmp_path / "config.yml" framework = BubFramework(config_file=config_file) framework.load_hooks() @@ -224,13 +242,15 @@ def test_onboard_can_install_gateway_after_saving_config(tmp_path: Path, monkeyp ) monkeypatch.setattr("bub.gateway_installer.is_gateway_service_supported", lambda: True) - def confirm_install(message: str, default: bool = False) -> bool: + def confirm_install(message: str, default: bool = False) -> InquirerResult[bool]: assert message == "Install gateway as a background service?" assert default is False assert configure.load(config_file)["enabled_channels"] == "telegram" - return True + current = _program_status(terminal_output) + assert current["state"] == "blocked" and current["kind"] == "permission" + return _fake_result(True) - monkeypatch.setattr(bub_inquirer, "ask_confirm", confirm_install) + monkeypatch.setattr(bub_inquirer.prompts, "confirm", confirm_install) def install_gateway(workspace: Path, channels: list[str]) -> SimpleNamespace: observed["workspace"] = workspace @@ -239,38 +259,44 @@ def install_gateway(workspace: Path, channels: list[str]) -> SimpleNamespace: monkeypatch.setattr("bub.gateway_installer.install_gateway", install_gateway) - result = CliRunner().invoke(app, ["--workspace", os.fspath(tmp_path), "onboard"]) + with pytest.raises(SystemExit) as stopped: + get_command(app).main(["--workspace", os.fspath(tmp_path), "onboard"]) - assert result.exit_code == 0 - assert f"Saved config to {config_file.resolve()}" in result.output - assert "Installed and started the gateway with systemd" in result.output + assert stopped.value.code == 0 + assert f"Saved config to {config_file.resolve()}" in terminal_output.getvalue() + assert "Installed and started the gateway with systemd" in terminal_output.getvalue() + assert _program_status(terminal_output)["state"] == "done" assert observed == {"workspace": tmp_path.resolve(), "channels": []} def test_onboard_keeps_saved_config_when_gateway_installation_fails(tmp_path: Path, monkeypatch) -> None: from bub.gateway_installer import GatewayServiceError + terminal_output = _terminal_output(monkeypatch) config_file = tmp_path / "config.yml" framework = BubFramework(config_file=config_file) framework.load_hooks() app = framework.create_cli_app() monkeypatch.setattr(framework, "collect_onboard_config", lambda *_: {"enabled_channels": "telegram"}) monkeypatch.setattr("bub.gateway_installer.is_gateway_service_supported", lambda: True) - monkeypatch.setattr(bub_inquirer, "ask_confirm", lambda message, default=False: True) + monkeypatch.setattr(bub_inquirer, "ask_confirm", lambda message, default=False, **kwargs: True) def fail_install(workspace: Path, channels: list[str]) -> None: raise GatewayServiceError("user service unavailable") monkeypatch.setattr("bub.gateway_installer.install_gateway", fail_install) - result = CliRunner().invoke(app, ["onboard"]) + with pytest.raises(SystemExit) as stopped: + get_command(app).main(["onboard"]) - assert result.exit_code == 1 - assert "Configuration remains saved" in result.output + assert stopped.value.code == 1 + assert "Configuration remains saved" in terminal_output.getvalue() assert configure.load(config_file)["enabled_channels"] == "telegram" + assert _program_status(terminal_output)["state"] == "error" def test_onboard_aborts_immediately_when_builtin_prompt_is_interrupted(tmp_path: Path, monkeypatch) -> None: + terminal_output = _terminal_output(monkeypatch) config_file = tmp_path / "config.yml" asked_messages: list[str] = [] @@ -317,10 +343,12 @@ def fake_secret(message: str) -> str: monkeypatch.setattr(bub_inquirer, "ask_text", fake_text) monkeypatch.setattr(bub_inquirer, "ask_secret", fake_secret) - result = CliRunner().invoke(app, ["onboard"]) + with pytest.raises(SystemExit) as stopped: + get_command(app).main(["onboard"]) - assert result.exit_code == 1 - assert _rendered_onboard_banner() in result.stdout + assert stopped.value.code == 1 + assert _rendered_onboard_banner() in terminal_output.getvalue() + assert _program_status(terminal_output)["state"] == "idle" assert asked_messages == [ "LLM provider", "API key (optional)", @@ -509,7 +537,7 @@ def test_onboard_collects_builtin_runtime_config_with_custom_provider(tmp_path: monkeypatch.setattr( bub_inquirer, "ask_confirm", - lambda message, default=False: False, + lambda message, default=False, **kwargs: False, ) monkeypatch.setattr( bub_inquirer, diff --git a/tests/test_channels.py b/tests/test_channels.py index 6ceced98..61932310 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -26,6 +26,7 @@ from bub.channels.manager import ChannelManager from bub.channels.message import ChannelMessage from bub.channels.telegram import BubMessageFilter, TelegramChannel, TelegramMessageParser +from bub.core.events import EventBus from bub.streaming import StreamEvent from bub.turn import TurnResult @@ -130,6 +131,7 @@ def __init__(self, channels: dict[str, Channel]) -> None: self.resolved_sessions: dict[str, str] = {} self.steering_inbox = None self._hook_runtime = SimpleNamespace(notify_error=self._notify_error) + self.events = EventBus() self.running_entries = 0 self.running_exits = 0 @@ -701,56 +703,22 @@ async def start(self, stop_event: asyncio.Event) -> None: @pytest.mark.asyncio -async def test_channel_manager_listen_and_run_passes_stream_output_setting( - monkeypatch: pytest.MonkeyPatch, load_config -) -> None: +async def test_channel_manager_listen_and_run_passes_stream_output_setting(load_config) -> None: _load_channel_config(load_config, enabled_channels="telegram", stream_output=True) - framework = FakeFramework({"telegram": FakeChannel("telegram")}) + channel = FakeChannel("telegram") + framework = FakeFramework({"telegram": channel}) + process_inbound = framework.process_inbound - import bub.channels.manager as manager_module + async def process(message, stream_output=False): + result = await process_inbound(message, stream_output) + channel.stop_event.set() + return result + framework.process_inbound = process manager = ChannelManager(framework) - calls = 0 - spawned_coroutines = [] - original_create_task = manager_module.asyncio.create_task - - class DummyTask: - def add_done_callback(self, callback) -> None: - return None - - def cancel(self) -> None: - return None - - def exception(self): - return None - - def create_task(coro): - spawned_coroutines.append(coro) - return DummyTask() - - async def wait_until_stopped(awaitable, current_stop_event): - nonlocal calls - calls += 1 - if calls == 1: - return await awaitable - close = getattr(awaitable, "close", None) - if callable(close): - close() - raise asyncio.CancelledError - - async def shutdown() -> None: - return None - - manager.shutdown = shutdown # type: ignore[method-assign] - monkeypatch.setattr(manager_module.asyncio, "create_task", create_task) - monkeypatch.setattr(manager_module, "wait_until_stopped", wait_until_stopped) - - listen_task = original_create_task(manager.listen_and_run()) - await asyncio.sleep(0) await manager.on_receive(_message("hello", channel="telegram")) - await listen_task - assert len(spawned_coroutines) == 1 - await spawned_coroutines[0] + async with asyncio.timeout(2): + await manager.listen_and_run() assert len(framework.process_calls) == 1 message, stream_output = framework.process_calls[0] @@ -1895,3 +1863,48 @@ async def runner(*args, **kwargs): return value return runner + + +@pytest.mark.parametrize("redirected", [False, True]) +def test_run_preserves_output_and_reports_status_only_on_a_terminal(redirected: bool) -> None: + script = textwrap.dedent( + """ + from bub.framework import BubFramework + from bub.hooks import hookimpl + from bub.core.events import LLMCallDecision + + class Reply: + @hookimpl + def before_llm_call(self, request, state): + return LLMCallDecision.finish("actual response") + + framework = BubFramework() + framework.load_builtin_hooks() + framework.plugin_manager.register(Reply()) + app = framework.create_cli_app() + app(["run", "request"]) + """ + ) + if redirected: + result = subprocess.run( + [sys.executable, "-c", script], stdin=subprocess.DEVNULL, capture_output=True, timeout=10 + ) + assert result.returncode == 0, result.stderr.decode(errors="replace") + assert result.stdout == b"[cli:local]\nactual response\n" + return + master_fd, slave_fd = pty.openpty() + process = subprocess.Popen([sys.executable, "-c", script], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd) + os.close(slave_fd) + try: + raw = _read_pty_until_exit(master_fd, process, timeout=10) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=1) + os.close(master_fd) + assert process.wait(timeout=1) == 0, raw.decode(errors="replace") + positions = {} + for frame in re.finditer(rb"\x1b]7501;([^\x1b\x07]*)(?:\x1b\\|\x07)", raw): + fields = dict(pair.split(b"=", 1) for pair in frame.group(1).split(b":")) + positions.setdefault(fields[b"state"], frame.start()) + assert positions[b"working"] < raw.index(b"actual response") < positions[b"done"] diff --git a/tests/test_program_status.py b/tests/test_program_status.py new file mode 100644 index 00000000..8ef3ef6e --- /dev/null +++ b/tests/test_program_status.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import asyncio +import re +import sys +from io import StringIO + +import pytest +import typer +from typer.main import get_command + +from bub.channels.base import Channel +from bub.channels.message import ChannelMessage +from bub.framework import BubFramework +from bub.hooks import hookimpl + + +class MessageChannel(Channel): + name = "test" + + def __init__(self, deliver): + self.deliver = deliver + + async def start(self, stop_event): + self.delivery = asyncio.create_task(self.deliver(stop_event)) + + async def stop(self): + await self.delivery + + async def send(self, message): + typer.echo(message.content) + + +def reports(output): + return [ + dict(pair.split("=", 1) for pair in body.split(":")) + for body in re.findall(r"\x1b]7501;(.*?)\x1b\\", output.getvalue()) + ] + + +@pytest.mark.parametrize("second_waits", ["model", "admission"]) +def test_listener_stays_working_until_accepted_messages_finish(tmp_path, monkeypatch, second_waits): + terminal = StringIO() + monkeypatch.setattr(terminal, "isatty", lambda: True) + monkeypatch.setattr(sys, "stdout", terminal) + ready = asyncio.Event() + failed = asyncio.Event() + resume = asyncio.Event() + framework = BubFramework(config_file=tmp_path / "config.yml") + framework.load_builtin_hooks() + + async def exchange(receive, stop_event): + try: + async with asyncio.timeout(2): + while reports(terminal)[-1]["state"] != "idle": + await asyncio.sleep(0) + await receive(ChannelMessage("first", "test", "fail")) + await receive(ChannelMessage("second", "test", "succeed")) + await failed.wait() + await asyncio.sleep(0) + assert reports(terminal)[-1]["state"] == "working" + resume.set() + while reports(terminal)[-1]["state"] != "done": + await asyncio.sleep(0) + assert "response" in terminal.getvalue() + finally: + resume.set() + stop_event.set() + + class Hooks: + @hookimpl + def provide_channels(self, message_handler): + return [MessageChannel(lambda stop_event: exchange(message_handler, stop_event))] + + @hookimpl + async def admit_message(self, session_id, message, turn): + if session_id == "second" and second_waits == "admission": + ready.set() + await resume.wait() + + @hookimpl + async def run_model(self, prompt, session_id, state): + if session_id == "first": + await ready.wait() + failed.set() + raise RuntimeError("failed") + ready.set() + await resume.wait() + return "response" + + framework.plugin_manager.register(Hooks()) + app = framework.create_cli_app() + + get_command(app).main(["--workspace", str(tmp_path), "gateway", "--enable-channel", "test"], standalone_mode=False) + + assert reports(terminal)[-1]["state"] == "idle" + assert all(item["state"] != "error" for item in reports(terminal)) diff --git a/website/src/content/docs/docs/build/plugins.mdx b/website/src/content/docs/docs/build/plugins.mdx index a3078950..69d290f7 100644 --- a/website/src/content/docs/docs/build/plugins.mdx +++ b/website/src/content/docs/docs/build/plugins.mdx @@ -127,6 +127,8 @@ class MyPlugin: Visual-base's [`EyeImpl`](https://github.com/oilbeater/visual-base/blob/main/src/bub_eye/plugin.py) uses the callable form for exactly this reason: it captures the framework so `provide_channels` can read the post-CLI workspace. +`framework.events` publishes command, channel work, and prompt changes through `CommandChanged`, `WorkChanged`, and `PromptChanged` from `bub.application`. Subscribe with `framework.events.on(EventType, handler)` and unsubscribe with `framework.events.off(EventType, handler)`. Agent-loop events remain on `agent.events`. + :::note Builtin is registered first; entry-point plugins are registered after. Inside the [`HookRuntime`](https://github.com/bubbuild/bub/blob/main/src/bub/hooks/runtime.py), later registrations have higher precedence — so your plugin's `firstresult` hooks override the builtin. ::: diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index 44150b5a..4f853ff6 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -18,6 +18,8 @@ bub [OPTIONS] COMMAND [ARGS]... The framework is created in `bub/__main__.py`, which calls `BubFramework().load_hooks()` and then `create_cli_app()`. The active framework is stored on `ctx.obj`. +Terminals that support [OSC 7501](https://www.superlogical.com/rex/docs/build/program-status) can show when Bub is working, waiting for input or approval, finished, or failed. Cancelling a command reports `idle`. `chat` and `gateway` report `idle` once listening, and remain busy until all accepted messages finish. Other terminals display normal command output. Redirected output contains no status sequences. + ## `bub run` Run one inbound `ChannelMessage` through `BubFramework.process_inbound` and print every outbound to stdout. 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 e56336c7..17be9e72 100644 --- a/website/src/content/docs/zh-cn/docs/build/plugins.mdx +++ b/website/src/content/docs/zh-cn/docs/build/plugins.mdx @@ -127,6 +127,8 @@ class MyPlugin: visual-base 的 [`EyeImpl`](https://github.com/oilbeater/visual-base/blob/main/src/bub_eye/plugin.py) 正是因为这个原因采用可调用形态:它捕获框架,从而 `provide_channels` 时能读到 CLI 处理后的工作区。 +`framework.events` 通过 `bub.application` 中的 `CommandChanged`、`WorkChanged` 和 `PromptChanged` 发布命令、通道工作和提示的变化。使用 `framework.events.on(EventType, handler)` 订阅,使用 `framework.events.off(EventType, handler)` 取消订阅。Agent 循环事件仍由 `agent.events` 发布。 + :::note 内置插件最先注册,入口点插件随后注册。在 [`HookRuntime`](https://github.com/bubbuild/bub/blob/main/src/bub/hooks/runtime.py) 中,注册越晚优先级越高 —— 因此你的 `firstresult` 钩子会覆盖内置实现。 ::: diff --git a/website/src/content/docs/zh-cn/docs/reference/cli.mdx b/website/src/content/docs/zh-cn/docs/reference/cli.mdx index d46edf11..2daf181f 100644 --- a/website/src/content/docs/zh-cn/docs/reference/cli.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/cli.mdx @@ -18,6 +18,8 @@ bub [OPTIONS] COMMAND [ARGS]... 框架在 `bub/__main__.py` 中创建:先 `BubFramework().load_hooks()`,再 `create_cli_app()`。当前 framework 实例存放在 `ctx.obj` 上。 +支持 [OSC 7501](https://www.superlogical.com/rex/docs/build/program-status) 的终端可以显示 Bub 的运行、等待输入或授权、完成和失败状态。取消命令后状态为 `idle`。`chat` 和 `gateway` 开始监听后显示 `idle`,接收的消息全部处理完毕后才显示完成。其他终端正常显示命令输出,重定向输出不包含状态控制序列。 + ## `bub run` 将一条 `ChannelMessage` 通过 `BubFramework.process_inbound` 跑完整条流水线,并把每个 outbound 打印到 stdout。