From ce4c48a84cd267a1f8aae33aa93b53d215e0eb4a Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:57:47 +0800 Subject: [PATCH 1/6] fix(bub-tapestore-otel): skip pydantic validation on entries to survive TapeEntry class drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When bub resolves to git HEAD instead of the workspace-locked commit, TapeEntry instances fail pydantic's isinstance check against the TapeEntry class captured at schema-build time, causing every append to raise a ValidationError that the exporter silently swallows — resulting in zero exported spans. Use SkipValidation[list[TapeEntry]] on TraceProjection.entries so pydantic bypasses the isinstance check entirely. The field is internal-only and never needs runtime coercion, so skipping validation is safe and is the explicit recommendation from the pydantic docs for this class of problem. Adds a regression test that feeds build_tape_trace entries whose class object differs from republic.TapeEntry to guard against future drift. Closes #47 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EcGvz588KPpJVkEP2eaDby --- .../src/bub_tapestore_otel/exporter.py | 4 +-- .../bub-tapestore-otel/tests/test_exporter.py | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index 79d44ea..96bad9a 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -8,7 +8,7 @@ from typing import Any from loguru import logger -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, SkipValidation from republic import TapeEntry FORCE_FLUSH_TIMEOUT_MS = 3_000 @@ -48,7 +48,7 @@ class TraceMessage(TapeProjectionModel): class TraceProjection(TapeProjectionModel): tape: str - entries: list[TapeEntry] + entries: SkipValidation[list[TapeEntry]] input_messages: list[TraceMessage] output_messages: list[TraceMessage] tool_calls: list[ToolCall] diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index 6f9b474..56d6e37 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -7,6 +7,32 @@ from republic import TapeEntry +def test_build_tape_trace_tolerates_tape_entry_from_different_class_identity() -> None: + """Regression test for issue #47: bub version drift causes pydantic ValidationError. + + When bub resolves to git HEAD rather than the workspace-locked commit, + TapeEntry instances may fail pydantic's isinstance check against the + TapeEntry class captured at schema-build time. SkipValidation on the + entries field must prevent that rejection regardless of class identity. + """ + real_entry = TapeEntry.event("loop.step", data={"status": "ok"}) + + # Simulate a TapeEntry produced by a different bub version: same shape, + # different class object — exactly what causes the ValidationError in prod. + class FakeTapeEntry: + def __init__(self, entry: TapeEntry) -> None: + self.id = entry.id + self.kind = entry.kind + self.date = entry.date + self.payload = entry.payload + + fake_entry = FakeTapeEntry(real_entry) + + # Must not raise pydantic.ValidationError + trace = build_tape_trace("drift__1", [fake_entry]) # type: ignore[list-item] + assert len(trace.entries) == 1 + + def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> None: entries = [ TapeEntry.system("system rules"), From cadec1caff684e1003a97a26b24a8b835ab17349 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:02:58 +0800 Subject: [PATCH 2/6] fix(bub-tapestore-sqlite): lazily build embedding client to remove _build_llm dependency SQLiteTapeStore.__init__ imported and called bub.builtin.agent._build_llm, a private helper that was removed in bub >= 0.3.10.dev2. This caused an ImportError before any tape operation could run, even when the store was used without semantic search. Fixes: - Remove the _build_llm call from __init__; initialize self._llm = None - Add a module-level _build_embedding_client() that uses the public AnyLLM.create() and bub.ensure_config(AgentSettings) API, with a model_client_kwargs() fallback for newer bub versions - Build the client lazily in _compute_embedding(), only when semantic search is actually invoked - Update tests to mock _build_embedding_client in bub_tapestore_sqlite.store instead of the removed bub.builtin.agent._build_llm Non-semantic store operations (append, fetch_all without query, list_tapes, reset) now work against any bub version without touching the LLM layer. Closes #43 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EcGvz588KPpJVkEP2eaDby --- .../src/bub_tapestore_sqlite/store.py | 24 ++++++++++++------- .../bub-tapestore-sqlite/tests/test_store.py | 14 +++++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py b/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py index a36326e..def91ea 100644 --- a/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py +++ b/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py @@ -10,7 +10,7 @@ import bub import sqlite_vec from any_llm import AnyLLM -from republic import RepublicError, TapeContext, TapeEntry, TapeQuery +from republic import RepublicError, TapeEntry, TapeQuery from republic.core.errors import ErrorKind ALLOWED_JOURNAL_MODES = {"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"} @@ -50,15 +50,8 @@ def __init__( synchronous: str = "NORMAL", embedding_model: str | None = None, ) -> None: - from bub.builtin.agent import _build_llm - from bub.builtin.settings import AgentSettings - self._path = Path(path) - self._llm = _build_llm( # type: ignore[arg-type] - bub.ensure_config(AgentSettings), - self, - TapeContext(), - ) + self._llm: Any = None self._path.parent.mkdir(parents=True, exist_ok=True) self._busy_timeout_ms = busy_timeout_ms self._journal_mode = normalize_journal_mode(journal_mode) @@ -201,6 +194,8 @@ async def _compute_embedding(self, texts: list[str]) -> list[list[float]]: if self._embedding_model is None: raise RuntimeError("No embedding model configured for tape store.") provider, model = self._embedding_model.split(":", 1) + if self._llm is None: + self._llm = _build_embedding_client() llm: AnyLLM = self._llm._core.get_client(provider) response = await llm.aembedding(model, texts) return self._embedding_response_to_vectors(response) @@ -796,3 +791,14 @@ def _raise_missing_for_query(query: TapeQuery) -> None: raise RepublicError( ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found." ) + + +def _build_embedding_client() -> Any: + """Build an LLM client for embedding using public bub settings.""" + from any_llm import AnyLLM + from bub.builtin.settings import AgentSettings + + settings = bub.ensure_config(AgentSettings) + if hasattr(settings, "model_client_kwargs"): + return AnyLLM.create(**settings.model_client_kwargs()) + return AnyLLM.create() diff --git a/packages/bub-tapestore-sqlite/tests/test_store.py b/packages/bub-tapestore-sqlite/tests/test_store.py index 4912ebf..3f595af 100644 --- a/packages/bub-tapestore-sqlite/tests/test_store.py +++ b/packages/bub-tapestore-sqlite/tests/test_store.py @@ -6,7 +6,7 @@ from types import SimpleNamespace import pytest -from republic import RepublicError, TapeContext, TapeEntry, TapeQuery +from republic import RepublicError, TapeEntry, TapeQuery from bub_tapestore_sqlite.store import SQLiteTapeStore @@ -29,14 +29,12 @@ def _embedding_response(vectors: list[list[float]]) -> SimpleNamespace: def _mock_embedding_client(monkeypatch, fake_aembedding): - from bub.builtin import agent as agent_module + import bub_tapestore_sqlite.store as store_module - build_calls: list[TapeContext] = [] + build_calls: list[None] = [] - def fake_build_llm(settings, tape_store, tape_context): - del settings, tape_store - assert isinstance(tape_context, TapeContext) - build_calls.append(tape_context) + def fake_build_embedding_client(): + build_calls.append(None) return SimpleNamespace( _core=SimpleNamespace( get_client=lambda provider: SimpleNamespace( @@ -45,7 +43,7 @@ def fake_build_llm(settings, tape_store, tape_context): ) ) - monkeypatch.setattr(agent_module, "_build_llm", fake_build_llm) + monkeypatch.setattr(store_module, "_build_embedding_client", fake_build_embedding_client) return build_calls From bab3d9d5d854fdac610b7479ef300d7fa2197225 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:17:43 +0800 Subject: [PATCH 3/6] fix: migrate plugins from republic to bub.tape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bub replaced republic with its own tape implementation (commit 04960b1). Plugins still imported from republic, so bub.tape.TapeEntry instances passed at runtime failed pydantic's isinstance check against republic.TapeEntry — causing ValidationError in otel and ImportError in sqlite (from the now-deleted _build_llm). Changes across both plugins: - Replace all `republic` imports with `bub.tape` / `bub.runtime` - bub-tapestore-otel: remove SkipValidation workaround (no longer needed now that the TapeEntry class identity is correct) - bub-tapestore-sqlite: replace RepublicError→BubError, ErrorKind from bub.runtime; rewrite _build_embedding_client to use the public AnyLLM.create(provider, **settings.model_client_kwargs(provider)) API; type _embedding_client as AnyLLM|None; simplify _compute_embedding to call client.aembedding directly without _core.get_client - Remove republic>=0.5.7 from both pyproject.toml; add bub to sqlite deps Closes #43 Closes #47 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EcGvz588KPpJVkEP2eaDby --- packages/bub-tapestore-otel/pyproject.toml | 1 - .../src/bub_tapestore_otel/exporter.py | 6 +-- .../src/bub_tapestore_otel/store.py | 4 +- .../bub-tapestore-otel/tests/test_exporter.py | 28 +----------- packages/bub-tapestore-sqlite/pyproject.toml | 2 +- .../src/bub_tapestore_sqlite/store.py | 43 ++++++++----------- .../bub-tapestore-sqlite/tests/test_store.py | 19 +++----- 7 files changed, 32 insertions(+), 71 deletions(-) diff --git a/packages/bub-tapestore-otel/pyproject.toml b/packages/bub-tapestore-otel/pyproject.toml index 282c7d3..b316096 100644 --- a/packages/bub-tapestore-otel/pyproject.toml +++ b/packages/bub-tapestore-otel/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "bub", "opentelemetry-exporter-otlp-proto-http>=1.39.0", "opentelemetry-sdk>=1.39.0", - "republic>=0.5.7", ] [project.entry-points.bub] diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index 96bad9a..d15c144 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -8,8 +8,8 @@ from typing import Any from loguru import logger -from pydantic import BaseModel, ConfigDict, Field, SkipValidation -from republic import TapeEntry +from bub.tape import TapeEntry +from pydantic import BaseModel, ConfigDict, Field FORCE_FLUSH_TIMEOUT_MS = 3_000 DEFAULT_AGENT_NAME = "bub" @@ -48,7 +48,7 @@ class TraceMessage(TapeProjectionModel): class TraceProjection(TapeProjectionModel): tape: str - entries: SkipValidation[list[TapeEntry]] + entries: list[TapeEntry] input_messages: list[TraceMessage] output_messages: list[TraceMessage] tool_calls: list[ToolCall] diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py index 5c8be4f..725f6aa 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py @@ -4,9 +4,7 @@ from typing import Protocol from loguru import logger -from republic import AsyncTapeStore, TapeEntry, TapeQuery -from republic.tape import TapeStore -from republic.tape.store import is_async_tape_store +from bub.tape import AsyncTapeStore, TapeEntry, TapeQuery, TapeStore, is_async_tape_store class TapeExporter(Protocol): diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index 56d6e37..91ce5a2 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -4,33 +4,7 @@ import bub_tapestore_otel.exporter as exporter from bub_tapestore_otel.exporter import OTelTapeExporter, _instrument_trace, _should_flush_batch, build_tape_trace -from republic import TapeEntry - - -def test_build_tape_trace_tolerates_tape_entry_from_different_class_identity() -> None: - """Regression test for issue #47: bub version drift causes pydantic ValidationError. - - When bub resolves to git HEAD rather than the workspace-locked commit, - TapeEntry instances may fail pydantic's isinstance check against the - TapeEntry class captured at schema-build time. SkipValidation on the - entries field must prevent that rejection regardless of class identity. - """ - real_entry = TapeEntry.event("loop.step", data={"status": "ok"}) - - # Simulate a TapeEntry produced by a different bub version: same shape, - # different class object — exactly what causes the ValidationError in prod. - class FakeTapeEntry: - def __init__(self, entry: TapeEntry) -> None: - self.id = entry.id - self.kind = entry.kind - self.date = entry.date - self.payload = entry.payload - - fake_entry = FakeTapeEntry(real_entry) - - # Must not raise pydantic.ValidationError - trace = build_tape_trace("drift__1", [fake_entry]) # type: ignore[list-item] - assert len(trace.entries) == 1 +from bub.tape import TapeEntry def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> None: diff --git a/packages/bub-tapestore-sqlite/pyproject.toml b/packages/bub-tapestore-sqlite/pyproject.toml index b67c2b4..1a23fe7 100644 --- a/packages/bub-tapestore-sqlite/pyproject.toml +++ b/packages/bub-tapestore-sqlite/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.12" dependencies = [ "aiosqlite>=0.20.0", "any-llm-sdk", - "republic>=0.5.7", + "bub", "sqlite-vec>=0.1.6", ] diff --git a/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py b/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py index def91ea..4bcf4d1 100644 --- a/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py +++ b/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py @@ -7,11 +7,10 @@ from typing import Any import aiosqlite -import bub import sqlite_vec from any_llm import AnyLLM -from republic import RepublicError, TapeEntry, TapeQuery -from republic.core.errors import ErrorKind +from bub.runtime import BubError, ErrorKind +from bub.tape import TapeEntry, TapeQuery ALLOWED_JOURNAL_MODES = {"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"} ALLOWED_SYNCHRONOUS_MODES = {"OFF", "NORMAL", "FULL", "EXTRA"} @@ -51,7 +50,7 @@ def __init__( embedding_model: str | None = None, ) -> None: self._path = Path(path) - self._llm: Any = None + self._embedding_client: AnyLLM | None = None self._path.parent.mkdir(parents=True, exist_ok=True) self._busy_timeout_ms = busy_timeout_ms self._journal_mode = normalize_journal_mode(journal_mode) @@ -193,11 +192,10 @@ async def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: async def _compute_embedding(self, texts: list[str]) -> list[list[float]]: if self._embedding_model is None: raise RuntimeError("No embedding model configured for tape store.") - provider, model = self._embedding_model.split(":", 1) - if self._llm is None: - self._llm = _build_embedding_client() - llm: AnyLLM = self._llm._core.get_client(provider) - response = await llm.aembedding(model, texts) + if self._embedding_client is None: + self._embedding_client = _build_embedding_client(self._embedding_model) + _, model = AnyLLM.split_model_provider(self._embedding_model) + response = await self._embedding_client.aembedding(model, texts) return self._embedding_response_to_vectors(response) async def _fetch_by_semantic_query( @@ -557,7 +555,7 @@ async def _resolve_bounds( forward=False, ) if start_id is None: - raise RepublicError( + raise BubError( ErrorKind.NOT_FOUND, f"Anchor '{start_name}' was not found." ) end_id = await self._find_anchor_id( @@ -568,7 +566,7 @@ async def _resolve_bounds( after_entry_id=start_id, ) if end_id is None: - raise RepublicError( + raise BubError( ErrorKind.NOT_FOUND, f"Anchor '{end_name}' was not found." ) return start_id, end_id @@ -581,7 +579,7 @@ async def _resolve_bounds( forward=False, ) if anchor_id is None: - raise RepublicError(ErrorKind.NOT_FOUND, "No anchors found in tape.") + raise BubError(ErrorKind.NOT_FOUND, "No anchors found in tape.") return anchor_id, None if query._after_anchor is not None: @@ -592,7 +590,7 @@ async def _resolve_bounds( forward=False, ) if anchor_id is None: - raise RepublicError( + raise BubError( ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found.", ) @@ -782,23 +780,20 @@ def _iter_text_fragments(value: object) -> Iterable[str]: def _raise_missing_for_query(query: TapeQuery) -> None: if query._between_anchors is not None: start_name, _ = query._between_anchors - raise RepublicError( + raise BubError( ErrorKind.NOT_FOUND, f"Anchor '{start_name}' was not found." ) if query._after_last: - raise RepublicError(ErrorKind.NOT_FOUND, "No anchors found in tape.") + raise BubError(ErrorKind.NOT_FOUND, "No anchors found in tape.") if query._after_anchor is not None: - raise RepublicError( + raise BubError( ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found." ) -def _build_embedding_client() -> Any: - """Build an LLM client for embedding using public bub settings.""" - from any_llm import AnyLLM - from bub.builtin.settings import AgentSettings +def _build_embedding_client(embedding_model: str) -> AnyLLM: + from bub.builtin.settings import load_settings - settings = bub.ensure_config(AgentSettings) - if hasattr(settings, "model_client_kwargs"): - return AnyLLM.create(**settings.model_client_kwargs()) - return AnyLLM.create() + provider, _ = AnyLLM.split_model_provider(embedding_model) + settings = load_settings() + return AnyLLM.create(provider, **settings.model_client_kwargs(provider)) diff --git a/packages/bub-tapestore-sqlite/tests/test_store.py b/packages/bub-tapestore-sqlite/tests/test_store.py index 3f595af..f649ffc 100644 --- a/packages/bub-tapestore-sqlite/tests/test_store.py +++ b/packages/bub-tapestore-sqlite/tests/test_store.py @@ -6,7 +6,8 @@ from types import SimpleNamespace import pytest -from republic import RepublicError, TapeEntry, TapeQuery +from bub.runtime import BubError +from bub.tape import TapeEntry, TapeQuery from bub_tapestore_sqlite.store import SQLiteTapeStore @@ -31,17 +32,11 @@ def _embedding_response(vectors: list[list[float]]) -> SimpleNamespace: def _mock_embedding_client(monkeypatch, fake_aembedding): import bub_tapestore_sqlite.store as store_module - build_calls: list[None] = [] + build_calls: list[str] = [] - def fake_build_embedding_client(): - build_calls.append(None) - return SimpleNamespace( - _core=SimpleNamespace( - get_client=lambda provider: SimpleNamespace( - aembedding=fake_aembedding, - ) - ) - ) + def fake_build_embedding_client(embedding_model: str): + build_calls.append(embedding_model) + return SimpleNamespace(aembedding=fake_aembedding) monkeypatch.setattr(store_module, "_build_embedding_client", fake_build_embedding_client) return build_calls @@ -171,7 +166,7 @@ async def scenario() -> None: tape = "session__3" await store.append(tape, TapeEntry.message({"content": "hello"})) - with pytest.raises(RepublicError, match="Anchor 'missing' was not found."): + with pytest.raises(BubError, match="Anchor 'missing' was not found."): await TapeQuery(tape, store).after_anchor("missing").all() await store.close() From 8b2c89e18fcef929392c6c92577bafbb14684327 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:44:52 +0800 Subject: [PATCH 4/6] fix: update tapestore packages for republic API --- packages/bub-tapestore-otel/pyproject.toml | 1 + .../src/bub_tapestore_otel/exporter.py | 2 +- .../src/bub_tapestore_otel/store.py | 16 +++++++++++----- .../bub-tapestore-otel/tests/test_exporter.py | 2 +- packages/bub-tapestore-sqlite/pyproject.toml | 2 +- .../src/bub_tapestore_sqlite/store.py | 18 +++++++++--------- .../bub-tapestore-sqlite/tests/test_store.py | 5 ++--- 7 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/bub-tapestore-otel/pyproject.toml b/packages/bub-tapestore-otel/pyproject.toml index b316096..282c7d3 100644 --- a/packages/bub-tapestore-otel/pyproject.toml +++ b/packages/bub-tapestore-otel/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "bub", "opentelemetry-exporter-otlp-proto-http>=1.39.0", "opentelemetry-sdk>=1.39.0", + "republic>=0.5.7", ] [project.entry-points.bub] diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index d15c144..79d44ea 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -8,8 +8,8 @@ from typing import Any from loguru import logger -from bub.tape import TapeEntry from pydantic import BaseModel, ConfigDict, Field +from republic import TapeEntry FORCE_FLUSH_TIMEOUT_MS = 3_000 DEFAULT_AGENT_NAME = "bub" diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py index 725f6aa..9a9d888 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py @@ -1,10 +1,12 @@ from __future__ import annotations +import inspect from collections.abc import Iterable from typing import Protocol from loguru import logger -from bub.tape import AsyncTapeStore, TapeEntry, TapeQuery, TapeStore, is_async_tape_store +from republic import TapeEntry, TapeQuery +from republic.tape import AsyncTapeStore, TapeStore class TapeExporter(Protocol): @@ -21,17 +23,17 @@ def __init__(self, inner: TapeStore | AsyncTapeStore, exporter: TapeExporter) -> self._exporter = exporter async def list_tapes(self) -> list[str]: - if is_async_tape_store(self._inner): + if _is_async_tape_store(self._inner): return await self._inner.list_tapes() return self._inner.list_tapes() async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: - if is_async_tape_store(self._inner): + if _is_async_tape_store(self._inner): return await self._inner.fetch_all(query) return self._inner.fetch_all(query) async def append(self, tape: str, entry: TapeEntry) -> None: - if is_async_tape_store(self._inner): + if _is_async_tape_store(self._inner): await self._inner.append(tape, entry) else: self._inner.append(tape, entry) @@ -41,7 +43,7 @@ async def append(self, tape: str, entry: TapeEntry) -> None: logger.opt(exception=True).warning("tapestore.otel.export_failed action=append tape={}", tape) async def reset(self, tape: str) -> None: - if is_async_tape_store(self._inner): + if _is_async_tape_store(self._inner): await self._inner.reset(tape) else: self._inner.reset(tape) @@ -49,3 +51,7 @@ async def reset(self, tape: str) -> None: self._exporter.reset(tape) except Exception: logger.opt(exception=True).warning("tapestore.otel.export_failed action=reset tape={}", tape) + + +def _is_async_tape_store(store: TapeStore | AsyncTapeStore) -> bool: + return inspect.iscoroutinefunction(store.append) diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index 91ce5a2..6f9b474 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -4,7 +4,7 @@ import bub_tapestore_otel.exporter as exporter from bub_tapestore_otel.exporter import OTelTapeExporter, _instrument_trace, _should_flush_batch, build_tape_trace -from bub.tape import TapeEntry +from republic import TapeEntry def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> None: diff --git a/packages/bub-tapestore-sqlite/pyproject.toml b/packages/bub-tapestore-sqlite/pyproject.toml index 1a23fe7..b67c2b4 100644 --- a/packages/bub-tapestore-sqlite/pyproject.toml +++ b/packages/bub-tapestore-sqlite/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.12" dependencies = [ "aiosqlite>=0.20.0", "any-llm-sdk", - "bub", + "republic>=0.5.7", "sqlite-vec>=0.1.6", ] diff --git a/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py b/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py index 4bcf4d1..97ca7f2 100644 --- a/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py +++ b/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/store.py @@ -9,8 +9,8 @@ import aiosqlite import sqlite_vec from any_llm import AnyLLM -from bub.runtime import BubError, ErrorKind -from bub.tape import TapeEntry, TapeQuery +from republic import RepublicError, TapeEntry, TapeQuery +from republic.core.errors import ErrorKind ALLOWED_JOURNAL_MODES = {"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"} ALLOWED_SYNCHRONOUS_MODES = {"OFF", "NORMAL", "FULL", "EXTRA"} @@ -555,7 +555,7 @@ async def _resolve_bounds( forward=False, ) if start_id is None: - raise BubError( + raise RepublicError( ErrorKind.NOT_FOUND, f"Anchor '{start_name}' was not found." ) end_id = await self._find_anchor_id( @@ -566,7 +566,7 @@ async def _resolve_bounds( after_entry_id=start_id, ) if end_id is None: - raise BubError( + raise RepublicError( ErrorKind.NOT_FOUND, f"Anchor '{end_name}' was not found." ) return start_id, end_id @@ -579,7 +579,7 @@ async def _resolve_bounds( forward=False, ) if anchor_id is None: - raise BubError(ErrorKind.NOT_FOUND, "No anchors found in tape.") + raise RepublicError(ErrorKind.NOT_FOUND, "No anchors found in tape.") return anchor_id, None if query._after_anchor is not None: @@ -590,7 +590,7 @@ async def _resolve_bounds( forward=False, ) if anchor_id is None: - raise BubError( + raise RepublicError( ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found.", ) @@ -780,13 +780,13 @@ def _iter_text_fragments(value: object) -> Iterable[str]: def _raise_missing_for_query(query: TapeQuery) -> None: if query._between_anchors is not None: start_name, _ = query._between_anchors - raise BubError( + raise RepublicError( ErrorKind.NOT_FOUND, f"Anchor '{start_name}' was not found." ) if query._after_last: - raise BubError(ErrorKind.NOT_FOUND, "No anchors found in tape.") + raise RepublicError(ErrorKind.NOT_FOUND, "No anchors found in tape.") if query._after_anchor is not None: - raise BubError( + raise RepublicError( ErrorKind.NOT_FOUND, f"Anchor '{query._after_anchor}' was not found." ) diff --git a/packages/bub-tapestore-sqlite/tests/test_store.py b/packages/bub-tapestore-sqlite/tests/test_store.py index f649ffc..70b3e0b 100644 --- a/packages/bub-tapestore-sqlite/tests/test_store.py +++ b/packages/bub-tapestore-sqlite/tests/test_store.py @@ -6,8 +6,7 @@ from types import SimpleNamespace import pytest -from bub.runtime import BubError -from bub.tape import TapeEntry, TapeQuery +from republic import RepublicError, TapeEntry, TapeQuery from bub_tapestore_sqlite.store import SQLiteTapeStore @@ -166,7 +165,7 @@ async def scenario() -> None: tape = "session__3" await store.append(tape, TapeEntry.message({"content": "hello"})) - with pytest.raises(BubError, match="Anchor 'missing' was not found."): + with pytest.raises(RepublicError, match="Anchor 'missing' was not found."): await TapeQuery(tape, store).after_anchor("missing").all() await store.close() From 7c84cc74170b0b6ffbdf5cac4a05252e9b11e4dc Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:31:26 +0800 Subject: [PATCH 5/6] fix(bub-tapestore-otel): survive TapeEntry class-identity drift (republic vs bub.tape) bub >= 0.3.10 vendors the tape module as bub.tape and constructs TapeEntry instances from it, while the plugin validated entries against republic's TapeEntry. The pydantic dataclass isinstance check then rejected every entry, and OTelTapeStore.append swallowed the ValidationError, so the exporter silently emitted zero spans. - import TapeEntry (and tape-store types) from bub.tape first, falling back to republic for older bub - type TraceProjection.entries as list[Any]: entries are an internal payload, and validating their class identity turns any future drift back into the same silent zero-span failure Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DeHZzZ8Xows9kMUHb5RkqZ --- .../src/bub_tapestore_otel/exporter.py | 11 +++++++++-- .../src/bub_tapestore_otel/store.py | 8 ++++++-- packages/bub-tapestore-otel/tests/test_exporter.py | 6 +++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index 79d44ea..f8df919 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -9,7 +9,11 @@ from loguru import logger from pydantic import BaseModel, ConfigDict, Field -from republic import TapeEntry + +try: # bub >= 0.3.10 vendors the tape module as bub.tape and constructs entries from it + from bub.tape import TapeEntry +except ImportError: # older bub sources tapes from republic + from republic import TapeEntry FORCE_FLUSH_TIMEOUT_MS = 3_000 DEFAULT_AGENT_NAME = "bub" @@ -48,7 +52,10 @@ class TraceMessage(TapeProjectionModel): class TraceProjection(TapeProjectionModel): tape: str - entries: list[TapeEntry] + # entries are an internal payload; skip pydantic validation so the plugin + # survives TapeEntry class-identity drift across bub versions (republic vs + # bub.tape) instead of silently dropping every span on a mismatch. + entries: list[Any] input_messages: list[TraceMessage] output_messages: list[TraceMessage] tool_calls: list[ToolCall] diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py index 9a9d888..95b331f 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py @@ -5,8 +5,12 @@ from typing import Protocol from loguru import logger -from republic import TapeEntry, TapeQuery -from republic.tape import AsyncTapeStore, TapeStore + +try: # bub >= 0.3.10 vendors the tape module as bub.tape and constructs entries from it + from bub.tape import AsyncTapeStore, TapeEntry, TapeQuery, TapeStore +except ImportError: # older bub sources tapes from republic + from republic import TapeEntry, TapeQuery + from republic.tape import AsyncTapeStore, TapeStore class TapeExporter(Protocol): diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index 6f9b474..32d5bcd 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -4,7 +4,11 @@ import bub_tapestore_otel.exporter as exporter from bub_tapestore_otel.exporter import OTelTapeExporter, _instrument_trace, _should_flush_batch, build_tape_trace -from republic import TapeEntry + +try: # mirror the runtime import: bub >= 0.3.10 constructs entries from bub.tape + from bub.tape import TapeEntry +except ImportError: + from republic import TapeEntry def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> None: From 9639a5d4f6a73d208966fd29fd8aa3af2774acba Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:15:07 +0800 Subject: [PATCH 6/6] refactor(bub-tapestore-otel): centralize tape-type compat imports and drop list[Any] - move the bub.tape/republic fallback imports into a single _compat module instead of repeating the try/except in exporter, store, and tests - type TraceProjection.entries as list[SkipValidation[TapeEntry]]: keeps the static type while still skipping pydantic's class-identity check, which is what the drift fix requires - add a regression test that entries from a foreign TapeEntry class still project into a trace instead of being dropped Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ZBsYdsdb8K2pvXKjFeWZY --- .../src/bub_tapestore_otel/_compat.py | 16 ++++++++++++ .../src/bub_tapestore_otel/exporter.py | 14 ++++------- .../src/bub_tapestore_otel/store.py | 6 +---- .../bub-tapestore-otel/tests/test_exporter.py | 25 +++++++++++++++---- .../bub-tapestore-otel/tests/test_store.py | 2 +- 5 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 packages/bub-tapestore-otel/src/bub_tapestore_otel/_compat.py diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/_compat.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/_compat.py new file mode 100644 index 0000000..e3be81b --- /dev/null +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/_compat.py @@ -0,0 +1,16 @@ +"""Tape types resolved across bub versions. + +bub >= 0.3.10 vendors the tape module as ``bub.tape`` and constructs entries +from it; older bub sources tapes from ``republic``. Import tape types from +this module so the whole package agrees on a single resolution order. +""" + +from __future__ import annotations + +try: + from bub.tape import AsyncTapeStore, TapeEntry, TapeQuery, TapeStore +except ImportError: + from republic import TapeEntry, TapeQuery + from republic.tape import AsyncTapeStore, TapeStore + +__all__ = ["AsyncTapeStore", "TapeEntry", "TapeQuery", "TapeStore"] diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index f8df919..b9f4308 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -8,12 +8,9 @@ from typing import Any from loguru import logger -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, SkipValidation -try: # bub >= 0.3.10 vendors the tape module as bub.tape and constructs entries from it - from bub.tape import TapeEntry -except ImportError: # older bub sources tapes from republic - from republic import TapeEntry +from bub_tapestore_otel._compat import TapeEntry FORCE_FLUSH_TIMEOUT_MS = 3_000 DEFAULT_AGENT_NAME = "bub" @@ -52,10 +49,9 @@ class TraceMessage(TapeProjectionModel): class TraceProjection(TapeProjectionModel): tape: str - # entries are an internal payload; skip pydantic validation so the plugin - # survives TapeEntry class-identity drift across bub versions (republic vs - # bub.tape) instead of silently dropping every span on a mismatch. - entries: list[Any] + # entries may be instances of either bub.tape's or republic's TapeEntry; + # validating class identity would silently drop every span on a mismatch. + entries: list[SkipValidation[TapeEntry]] input_messages: list[TraceMessage] output_messages: list[TraceMessage] tool_calls: list[ToolCall] diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py index 95b331f..7b3de19 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py @@ -6,11 +6,7 @@ from loguru import logger -try: # bub >= 0.3.10 vendors the tape module as bub.tape and constructs entries from it - from bub.tape import AsyncTapeStore, TapeEntry, TapeQuery, TapeStore -except ImportError: # older bub sources tapes from republic - from republic import TapeEntry, TapeQuery - from republic.tape import AsyncTapeStore, TapeStore +from bub_tapestore_otel._compat import AsyncTapeStore, TapeEntry, TapeQuery, TapeStore class TapeExporter(Protocol): diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index 32d5bcd..d2025ee 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -3,13 +3,9 @@ from types import SimpleNamespace import bub_tapestore_otel.exporter as exporter +from bub_tapestore_otel._compat import TapeEntry from bub_tapestore_otel.exporter import OTelTapeExporter, _instrument_trace, _should_flush_batch, build_tape_trace -try: # mirror the runtime import: bub >= 0.3.10 constructs entries from bub.tape - from bub.tape import TapeEntry -except ImportError: - from republic import TapeEntry - def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> None: entries = [ @@ -133,6 +129,25 @@ def test_build_tape_trace_falls_back_to_prompt_when_messages_are_missing() -> No assert trace.llm_attributes["llm.input_messages.0.message.content"] == "plain prompt" +def test_build_tape_trace_accepts_entries_from_a_foreign_tape_entry_class() -> None: + # bub >= 0.3.10 constructs entries from bub.tape while older installs use + # republic's TapeEntry; validating class identity here would silently drop + # every span on a mismatch. + entry = SimpleNamespace( + kind="event", + payload={"name": "loop.step", "data": {"status": "ok", "elapsed_ms": 42}}, + id="entry-1", + date="2026-07-07T00:00:00Z", + ) + + trace = build_tape_trace("drift__1", [entry]) + + assert trace.entries == [entry] + assert trace.status == "ok" + assert trace.duration_ms == 42 + assert exporter._step_span_attributes(trace.steps[0])["bub.tape.entry.first_id"] == "entry-1" + + def test_batch_flushes_on_completed_tape_turn_markers() -> None: assert _should_flush_batch(TapeEntry.event("loop.step", data={"status": "ok"})) assert _should_flush_batch(TapeEntry.event("loop.step", data={"status": "error"})) diff --git a/packages/bub-tapestore-otel/tests/test_store.py b/packages/bub-tapestore-otel/tests/test_store.py index ba43563..7794d25 100644 --- a/packages/bub-tapestore-otel/tests/test_store.py +++ b/packages/bub-tapestore-otel/tests/test_store.py @@ -3,8 +3,8 @@ from collections.abc import Iterable import pytest +from bub_tapestore_otel._compat import TapeEntry, TapeQuery from bub_tapestore_otel.store import OTelTapeStore -from republic import TapeEntry, TapeQuery class MemoryStore: