diff --git a/pyproject.toml b/pyproject.toml index 859f509e..5e385a7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "aiohttp>=3.13.3", "httpx[socks]>=0.28.1", "typing-extensions>=4.13.0", + "logfire>=4.31.0", ] [project.urls] @@ -45,11 +46,6 @@ Documentation = "https://bub.build" [project.scripts] bub = "bub.__main__:app" -[project.optional-dependencies] -logfire = [ - "logfire>=4.31.0", -] - [dependency-groups] dev = [ "pytest>=7.2.0", diff --git a/src/bub/__main__.py b/src/bub/__main__.py index 8ee17b49..671d81d5 100644 --- a/src/bub/__main__.py +++ b/src/bub/__main__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import sys import typer @@ -12,17 +13,20 @@ def _instrument_bub() -> None: from loguru import logger - logger.remove() - logger.add(sys.stderr, colorize=True) + from bub.builtin import telemetry - try: - import logfire - from logfire.integrations.loguru import LogfireHandler + level = os.environ.get("BUB_LOG_LEVEL", "WARNING").upper() + logger.remove() - logfire.configure() - logger.add(LogfireHandler(), format="{message}") - except Exception as exc: - logger.debug("logfire instrumentation disabled: {}", exc) + telemetry.configure_telemetry() + logfire_handler = telemetry.loguru_handler() + logfire_handler["level"] = "INFO" + logger.configure( + handlers=[ + {"sink": sys.stderr, "colorize": True, "level": level}, + logfire_handler, + ] + ) def create_cli_app() -> typer.Typer: diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 8743fa30..4358bb3f 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -16,6 +16,7 @@ from loguru import logger +from bub.builtin import telemetry from bub.builtin.model_runner import ( ModelRunner, is_context_length_error, @@ -102,7 +103,21 @@ async def run_stream( stack = AsyncExitStack() # The fork_tape context manager must not be exited until the last chunk of the stream is consumed. tape = await stack.enter_async_context(tape.fork_tape(merge_back=merge_back)) - await tape.ensure_bootstrap_anchor() + stack.enter_context( + telemetry.bub_span( + "bub.agent.run", + tape=tape.name, + store=getattr(tape, "store", None), + attributes={ + telemetry.GEN_AI_OPERATION_NAME: "invoke_agent", + telemetry.GEN_AI_AGENT_NAME: telemetry.BUB_AGENT_NAME, + telemetry.GEN_AI_REQUEST_MODEL: model or self.settings.model, + "bub.session.id": session_id, + "bub.tape.merge_back": merge_back, + }, + ) + ) + await self._ensure_bootstrap_anchor(tape) if isinstance(prompt, str) and prompt.strip().startswith(","): result = await self._run_command(tape=tape, line=prompt.strip()) events = self._events_from_iterable([ @@ -129,35 +144,54 @@ async def _run_command(self, tape: Tape, *, line: str) -> str: context = ToolContext(tape=tape, run_id="run_command", state=tape.context.state) output = "" status = "ok" - try: - if name not in REGISTRY: - output = await REGISTRY["bash"].run(context=context, cmd=line) + with telemetry.bub_span( + "bub.command", + tape=tape.name, + store=getattr(tape, "store", None), + attributes={ + telemetry.GEN_AI_OPERATION_NAME: "command", + telemetry.BUB_RUN_ID: "run_command", + "bub.command.name": name, + }, + ) as span: + try: + if name not in REGISTRY: + output = await REGISTRY["bash"].run(context=context, cmd=line) + else: + args = _parse_args(arg_tokens) + if REGISTRY[name].context: + args.kwargs["context"] = context + output = REGISTRY[name].run(*args.positional, **args.kwargs) + if inspect.isawaitable(output): + output = await output + except Exception as exc: + status = "error" + output = f"{exc!s}" + raise else: - args = _parse_args(arg_tokens) - if REGISTRY[name].context: - args.kwargs["context"] = context - output = REGISTRY[name].run(*args.positional, **args.kwargs) - if inspect.isawaitable(output): - output = await output - except Exception as exc: - status = "error" - output = f"{exc!s}" - raise - else: - return output if isinstance(output, str) else str(output) - finally: - elapsed_ms = int((time.monotonic() - start) * 1000) - output_text = output if isinstance(output, str) else str(output) - - event_payload = { - "raw": line, - "name": name, - "status": status, - "elapsed_ms": elapsed_ms, - "output": output_text, - "date": datetime.now(UTC).isoformat(), - } - await tape.append_event("command", event_payload) + return output if isinstance(output, str) else str(output) + finally: + elapsed_ms = int((time.monotonic() - start) * 1000) + output_text = output if isinstance(output, str) else str(output) + span.set_attribute("bub.command.status", status) + span.set_attribute("bub.command.elapsed_ms", elapsed_ms) + + event_payload = { + "raw": line, + "name": name, + "status": status, + "elapsed_ms": elapsed_ms, + "output": output_text, + "date": datetime.now(UTC).isoformat(), + } + await telemetry.record_tape_event(tape.store, tape.name, "command", event_payload) + raise RuntimeError("command execution did not produce a result") + + @staticmethod + async def _ensure_bootstrap_anchor(tape: Tape) -> None: + anchors = list(await tape.store.fetch_all(tape.query().kinds("anchor"))) + if not anchors: + await telemetry.record_tape_handoff(tape.store, tape.name, name="session/start", state={"owner": "human"}) async def _agent_loop( self, @@ -170,7 +204,9 @@ async def _agent_loop( ) -> AsyncStreamEvents: next_prompt: str | list[dict] = prompt display_model = model or self.settings.model - await tape.append_event( + await telemetry.record_tape_event( + tape.store, + tape.name, "loop.start", { "model": display_model, @@ -206,95 +242,127 @@ async def _stream_events_with_auto_handoff( start = time.monotonic() should_continue = False logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model) - await tape.append_event("loop.step.start", {"step": step, "prompt": next_prompt}) - try: - output = await self._run_once( - tape=tape, - prompt=next_prompt, - model=model, - allowed_skills=allowed_skills, - allowed_tools=allowed_tools, + with telemetry.bub_span( + "bub.agent.step", + tape=tape.name, + store=getattr(tape, "store", None), + attributes={ + telemetry.GEN_AI_OPERATION_NAME: "agent_step", + telemetry.GEN_AI_REQUEST_MODEL: display_model, + "bub.loop.step": step, + }, + ) as span: + await telemetry.record_tape_event( + tape.store, tape.name, "loop.step.start", {"step": step, "prompt": next_prompt} ) - async for event in output: - yield event - if event.kind == "error": - elapsed_ms = int((time.monotonic() - start) * 1000) - await tape.append_event( + try: + output = await self._run_once( + tape=tape, + prompt=next_prompt, + model=model, + allowed_skills=allowed_skills, + allowed_tools=allowed_tools, + ) + async for event in output: + yield event + if event.kind == "error": + elapsed_ms = int((time.monotonic() - start) * 1000) + span.set_attribute("bub.loop.status", "error") + span.set_attribute("bub.loop.elapsed_ms", elapsed_ms) + await telemetry.record_tape_event( + tape.store, + tape.name, + "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) + span.set_attribute("bub.loop.elapsed_ms", elapsed_ms) + if auto_handoff_remaining > 0 and is_context_length_error(error_message): + auto_handoff_remaining -= 1 + span.set_attribute("bub.loop.status", "auto_handoff") + logger.warning( + "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}", + tape.name, + step, + ) + await telemetry.record_tape_handoff( + tape.store, + tape.name, + name="auto_handoff/context_overflow", + state={"reason": "context_length_exceeded", "error": error_message}, + ) + await telemetry.record_tape_event( + tape.store, + tape.name, "loop.step", { "step": step, "elapsed_ms": elapsed_ms, - "status": "error", - "error": event.data.get("message", ""), + "status": "auto_handoff", + "error": error_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={}", + next_prompt = prompt + continue + + span.set_attribute("bub.loop.status", "error") + await telemetry.record_tape_event( + tape.store, 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", + "status": "error", "error": error_message, "date": datetime.now(UTC).isoformat(), }, ) - next_prompt = prompt - continue + raise - 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) + span.set_attribute("bub.loop.elapsed_ms", elapsed_ms) + if not should_continue: + span.set_attribute("bub.loop.status", "ok") + await telemetry.record_tape_event( + tape.store, + tape.name, + "loop.step", + { + "step": step, + "elapsed_ms": elapsed_ms, + "status": "ok", + "date": datetime.now(UTC).isoformat(), + }, + ) + return - state.error = output.error - state.usage = output.usage - elapsed_ms = int((time.monotonic() - start) * 1000) - if not should_continue: - await tape.append_event( + next_prompt = self._continue_prompt(tape) + span.set_attribute("bub.loop.status", "continue") + await telemetry.record_tape_event( + tape.store, + tape.name, "loop.step", { "step": step, "elapsed_ms": elapsed_ms, - "status": "ok", + "status": "continue", "date": datetime.now(UTC).isoformat(), }, ) - return - - next_prompt = self._continue_prompt(tape) - 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}") diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index 0684f8fc..e299f8f1 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -21,8 +21,9 @@ ParsedChatCompletion, ) from loguru import logger -from pydantic import TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError +from bub.builtin import telemetry from bub.builtin.settings import AgentSettings, ModelCandidate from bub.builtin.tape import Tape from bub.runtime import AsyncStreamEvents, BubError, ErrorKind, StreamEvent, StreamState @@ -98,10 +99,24 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: model=model, ) output = ModelOutputAccumulator() - async with asyncio.timeout(self.settings.model_timeout_seconds): - completion = await self.completion_response(model=model, messages=messages, tools=tools) - async for event in self._completion_events(completion, state, output): - yield event + with telemetry.bub_span( + "bub.model.request", + tape=tape.name, + store=getattr(tape, "store", None), + attributes={ + telemetry.BUB_RUN_ID: run_id, + telemetry.GEN_AI_OPERATION_NAME: "chat", + telemetry.GEN_AI_REQUEST_MODEL: model, + telemetry.GEN_AI_SYSTEM_INSTRUCTIONS: system_prompt or "", + }, + ) as span: + async with asyncio.timeout(self.settings.model_timeout_seconds): + completion = await self.completion_response(model=model, messages=messages, tools=tools) + async for event in self._completion_events(completion, state, output): + yield event + span.set_attribute(telemetry.GEN_AI_RESPONSE_MODEL, model) + if state.usage and isinstance(state.usage.get("total_tokens"), int): + span.set_attribute("gen_ai.usage.total_tokens", state.usage["total_tokens"]) tool_calls = output.tool_calls if tool_calls: @@ -214,8 +229,8 @@ async def record_chat( model: str | None = None, usage: dict[str, Any] | None = None, ) -> None: - await tape.record_chat( - run_id=run_id, + meta = {"run_id": run_id} + for kind, payload in _iter_chat_entries( system_prompt=system_prompt, new_messages=new_messages, response_text=response_text, @@ -223,10 +238,14 @@ async def record_chat( tool_calls=tool_calls, tool_results=tool_results, error=error, - response=response, - provider=provider, - model=model, - usage=usage, + ): + await telemetry.record_tape_entry(tape.store, tape.name, kind, payload, **meta) + await telemetry.record_tape_event( + tape.store, + tape.name, + "run", + _run_event_data(error=error, response=response, provider=provider, model=model, usage=usage), + **meta, ) async def _completion_events( @@ -236,7 +255,7 @@ async def _completion_events( output: ModelOutputAccumulator, ) -> AsyncGenerator[StreamEvent, None]: if isinstance(completion, ChatCompletion): - if usage := Tape._extract_usage(completion): + if usage := _extract_usage(completion): state.usage = usage output.response = completion message = completion.choices[0].message @@ -266,7 +285,7 @@ async def _completion_chunk_events( state: StreamState, output: ModelOutputAccumulator, ) -> AsyncGenerator[StreamEvent, None]: - if usage := Tape._extract_usage(chunk): + if usage := _extract_usage(chunk): state.usage = usage for choice in chunk.choices: delta = choice.delta @@ -284,6 +303,63 @@ def reasoning_text(reasoning: object) -> str: return "" if content is None else str(content) +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 + + +def _iter_chat_entries( + *, + 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, +) -> Iterator[tuple[str, dict[str, Any]]]: + if system_prompt: + yield "system", {"content": system_prompt} + if context_error is not None: + yield "error", context_error.as_dict() + for message in new_messages: + yield "message", message + if tool_calls: + yield "tool_call", {"calls": tool_calls} + if tool_results is not None: + yield "tool_result", {"results": tool_results} + if error is not None and error is not context_error: + yield "error", error.as_dict() + if response_text is not None: + yield "message", {"role": "assistant", "content": response_text} + + +def _run_event_data( + *, + error: BubError | None, + response: Any | None, + provider: str | None, + model: str | None, + usage: dict[str, Any] | None, +) -> dict[str, Any]: + data: dict[str, Any] = {"status": "error" if error is not None else "ok"} + resolved_usage = usage or _extract_usage(response) + if resolved_usage is not None: + data["usage"] = resolved_usage + if provider: + data["provider"] = provider + if model: + data["model"] = model + return data + + @dataclass class StreamToolCall: id: str | None = None diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py index c5f33adf..dca15393 100644 --- a/src/bub/builtin/store.py +++ b/src/bub/builtin/store.py @@ -81,6 +81,9 @@ def _redact_payload(payload: dict) -> None: payload["prompt"] = ForkTapeStore._redact_prompt(payload["prompt"]) async def append(self, tape: str, entry: TapeEntry) -> None: + self.append_nowait(tape, entry) + + def append_nowait(self, tape: str, entry: TapeEntry) -> None: self._redact_payload(entry.payload) self._store.append(tape, entry) diff --git a/src/bub/builtin/tape.py b/src/bub/builtin/tape.py index 22281c9b..7d2a2c68 100644 --- a/src/bub/builtin/tape.py +++ b/src/bub/builtin/tape.py @@ -10,10 +10,7 @@ from pathlib import Path from typing import Any -from pydantic import BaseModel - from bub.builtin.store import ForkTapeStore -from bub.runtime import BubError from bub.tape import ( AsyncTapeStore, TapeContext, @@ -45,7 +42,7 @@ class AnchorSummary: @dataclass(frozen=True) class Tape: - """Tape abstraction for recording agent interactions.""" + """Scoped tape store view for querying and session management.""" archive_path: Path store: AsyncTapeStore @@ -93,11 +90,6 @@ async def info(self) -> TapeInfo: last_token_usage=last_token_usage, ) - 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] = [] @@ -111,9 +103,6 @@ async def anchors(self, limit: int = 20) -> list[AnchorSummary]: 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: - await self.store.append(self.name, TapeEntry.event(name, payload, **meta)) - async def read_messages(self) -> list[dict[str, Any]]: query = self.context.build_query(self.query()) entries = await self.store.fetch_all(query) @@ -122,77 +111,6 @@ async def read_messages(self) -> list[dict[str, Any]]: 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 - entry = TapeEntry.anchor(name, state=state, **meta) - event = TapeEntry.event("handoff", {"name": name, "state": state or {}}, **meta) - await self.store.append(tape_name, entry) - await self.store.append(tape_name, event) - return [entry, event] - - async def record_chat( # noqa: C901 - self, - *, - run_id: str, - system_prompt: str | None, - new_messages: list[dict[str, Any]], - response_text: str | None, - context_error: BubError | None = None, - tool_calls: list[dict[str, Any]] | None = None, - tool_results: list[Any] | None = None, - error: BubError | None = None, - response: Any | None = None, - provider: str | None = None, - model: str | None = None, - usage: dict[str, Any] | None = None, - ) -> None: - tape_name = self.name - meta = {"run_id": run_id} - if system_prompt: - await self.store.append(tape_name, TapeEntry.system(system_prompt, **meta)) - if context_error is not None: - await self.store.append(tape_name, TapeEntry.error(context_error, **meta)) - for message in new_messages: - await self.store.append(tape_name, TapeEntry.message(message, **meta)) - if tool_calls: - await self.store.append(tape_name, TapeEntry.tool_call(tool_calls, **meta)) - if tool_results is not None: - await self.store.append(tape_name, TapeEntry.tool_result(tool_results, **meta)) - if error is not None and error is not context_error: - await self.store.append(tape_name, TapeEntry.error(error, **meta)) - if response_text is not None: - await self.store.append( - tape_name, TapeEntry.message({"role": "assistant", "content": response_text}, **meta) - ) - - data: dict[str, Any] = {"status": "error" if error is not None else "ok"} - resolved_usage = usage or self._extract_usage(response) - if resolved_usage is not None: - data["usage"] = resolved_usage - if provider: - data["provider"] = provider - if model: - data["model"] = model - await self.store.append(tape_name, TapeEntry.event("run", data, **meta)) - - @staticmethod - def _extract_usage(response: object) -> dict[str, Any] | None: - usage = getattr(response, "usage", None) - if usage is None: - return None - if isinstance(usage, dict): - return usage - if isinstance(usage, BaseModel): - payload = usage.model_dump(exclude_none=True) - return payload if isinstance(payload, dict) else None - return None - async def _archive(self) -> Path: tape_name = self.name stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") @@ -203,16 +121,12 @@ async def _archive(self) -> Path: f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n") return archive_path - async def reset(self, *, archive: bool = False) -> str: + async def reset(self, *, archive: bool = False) -> Path | None: archive_path: Path | None = None if archive: archive_path = await self._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) - return f"Archived: {archive_path}" if archive_path else "ok" + return archive_path 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] diff --git a/src/bub/builtin/telemetry.py b/src/bub/builtin/telemetry.py new file mode 100644 index 00000000..8b618731 --- /dev/null +++ b/src/bub/builtin/telemetry.py @@ -0,0 +1,216 @@ +"""Telemetry adapters for projecting runtime tape-entry spans into tape streams.""" + +from __future__ import annotations + +import asyncio +import inspect +from collections.abc import Mapping +from contextlib import AbstractContextManager +from functools import cache +from types import TracebackType +from typing import TYPE_CHECKING, Any, Literal + +import logfire +from opentelemetry import context as otel_context + +from bub.tape import TapeEntry + +if TYPE_CHECKING: + from opentelemetry.sdk.trace import SpanProcessor + +BUB_TAPE_NAME = "bub.tape.name" +BUB_RUN_ID = "bub.run.id" +BUB_AGENT_NAME = "bub" +GEN_AI_OPERATION_NAME = "gen_ai.operation.name" +GEN_AI_AGENT_NAME = "gen_ai.agent.name" +GEN_AI_REQUEST_MODEL = "gen_ai.request.model" +GEN_AI_RESPONSE_MODEL = "gen_ai.response.model" +GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions" +GEN_AI_TOOL_NAME = "gen_ai.tool.name" +GEN_AI_TOOL_CALL_ID = "gen_ai.tool.call.id" +BUB_TAPE_ENTRY_KIND = "bub.tape.entry.kind" + +_TAPE_STORE_KEY = otel_context.create_key("bub.tape.store") +_TAPE_ENTRY_KEY = otel_context.create_key("bub.tape.entry") +_pending_tape_writes: set[asyncio.Future[Any]] = set() + + +class BubSpanContext: + def __init__( + self, + name: str, + attributes: dict[str, Any], + store: object | None = None, + entry: TapeEntry | None = None, + ) -> None: + self._name = name + self._attributes = attributes + self._store = store + self._entry = entry + self._manager: AbstractContextManager[Any] | None = None + self._store_token: Any = None + + def __enter__(self) -> Any: + ensure_telemetry_configured() + span_context = otel_context.get_current() + if self._store is not None and self._entry is not None: + span_context = otel_context.set_value(_TAPE_STORE_KEY, self._store, span_context) + span_context = otel_context.set_value(_TAPE_ENTRY_KEY, self._entry, span_context) + self._store_token = otel_context.attach(span_context) + self._manager = logfire.span(self._name, _span_name=self._name, **self._attributes) + return self._manager.__enter__() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> Literal[False]: + try: + if self._manager is not None: + self._manager.__exit__(exc_type, exc, traceback) + finally: + if self._store_token is not None: + otel_context.detach(self._store_token) + return False + + +class TapeSpanExporter: + """Project tape-entry spans into a tape store.""" + + def __init__(self, store: object) -> None: + self._store = store + + def export_entry(self, tape: str, entry: TapeEntry) -> None: + append_nowait = getattr(self._store, "append_nowait", None) + if callable(append_nowait): + append_nowait(tape, entry) + return + + append = getattr(self._store, "append", None) + if not callable(append): + return + + result = append(tape, entry) + if inspect.isawaitable(result): + task: asyncio.Future[Any] = asyncio.ensure_future(result) + _pending_tape_writes.add(task) + task.add_done_callback(_pending_tape_writes.discard) + + +def bub_span( + name: str, + *, + tape: str | None = None, + store: object | None = None, + entry: TapeEntry | None = None, + attributes: dict[str, Any] | None = None, +) -> BubSpanContext: + span_attributes = dict(attributes or {}) + if tape: + span_attributes[BUB_TAPE_NAME] = tape + return BubSpanContext(name, span_attributes, store=store, entry=entry) + + +def tape_span_processor() -> SpanProcessor: + """Return an OTel processor that writes Bub tape-entry spans into tapes.""" + + from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor + + class TapeSpanProcessor(SpanProcessor): + def on_start(self, span: object, parent_context: object | None = None) -> None: + return + + def on_end(self, span: ReadableSpan) -> None: + store = otel_context.get_value(_TAPE_STORE_KEY) + entry = otel_context.get_value(_TAPE_ENTRY_KEY) + tape = _span_tape_name(span) + if store is not None and isinstance(entry, TapeEntry) and tape is not None: + TapeSpanExporter(store).export_entry(tape, entry) + + def shutdown(self) -> None: + return + + def force_flush(self, timeout_millis: int = 30000) -> bool: + return True + + return TapeSpanProcessor() + + +@cache +def configure_telemetry() -> None: + """Configure Logfire with Bub's tape span processor.""" + + logfire.configure( + send_to_logfire="if-token-present", + console=False, + inspect_arguments=False, + additional_span_processors=[tape_span_processor()], + ) + + +def ensure_telemetry_configured() -> None: + configure_telemetry() + + +def loguru_handler() -> Any: + return logfire.loguru_handler() + + +async def record_tape_entry( + store: object, + tape: str, + kind: str, + payload: dict[str, Any] | None = None, + **meta: Any, +) -> None: + entry = TapeEntry(id=0, kind=kind, payload=dict(payload or {}), meta=dict(meta)) + attributes: dict[str, Any] = { + BUB_TAPE_ENTRY_KIND: kind, + } + if run_id := meta.get("run_id"): + attributes[BUB_RUN_ID] = str(run_id) + with bub_span(f"bub.tape.{kind}", tape=tape, store=store, entry=entry, attributes=attributes): + pass + await drain_tape_writes() + + +async def record_tape_event( + store: object, + tape: str, + name: str, + data: dict[str, Any] | None = None, + **meta: Any, +) -> None: + payload: dict[str, Any] = {"name": name} + if data is not None: + payload["data"] = dict(data) + await record_tape_entry(store, tape, "event", payload, **meta) + + +async def record_tape_handoff( + store: object, + tape: str, + *, + name: str, + state: dict[str, Any] | None = None, + **meta: Any, +) -> None: + anchor_payload: dict[str, Any] = {"name": name} + if state is not None: + anchor_payload["state"] = dict(state) + await record_tape_entry(store, tape, "anchor", anchor_payload, **meta) + await record_tape_event(store, tape, "handoff", {"name": name, "state": state or {}}, **meta) + + +async def drain_tape_writes() -> None: + if _pending_tape_writes: + await asyncio.gather(*list(_pending_tape_writes)) + + +def _span_tape_name(span: object) -> str | None: + attributes = getattr(span, "attributes", {}) + if not isinstance(attributes, Mapping): + return None + tape = attributes.get(BUB_TAPE_NAME) + return tape if isinstance(tape, str) and tape else None diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 9c2dffe0..53640491 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -11,6 +11,7 @@ from openai.types.chat import ChatCompletionToolParam from pydantic import BaseModel, Field +from bub.builtin import telemetry from bub.builtin.shell_manager import shell_manager from bub.skills import discover_skills from bub.tools import REGISTRY, Tool, ToolContext, tool @@ -305,14 +306,18 @@ async def tape_search(param: SearchInput, *, context: ToolContext) -> str: @tool(context=True, name="tape.reset") async def tape_reset(archive: bool = False, *, context: ToolContext) -> str: """Reset the current tape, optionally archiving it.""" - result = await context.tape.reset(archive=archive) - return result + archive_path = await context.tape.reset(archive=archive) + state = {"owner": "human"} + if archive_path is not None: + state["archived"] = str(archive_path) + await telemetry.record_tape_handoff(context.tape.store, context.tape.name, name="session/start", state=state) + return f"Archived: {archive_path}" if archive_path else "ok" @tool(context=True, name="tape.handoff") async def tape_handoff(name: str = "handoff", summary: str = "", *, context: ToolContext) -> str: """Add a handoff anchor to the current tape.""" - await context.tape.handoff(name=name, state={"summary": summary}) + await telemetry.record_tape_handoff(context.tape.store, context.tape.name, name=name, state={"summary": summary}) return f"anchor added: {name}" @@ -415,7 +420,7 @@ async def set_model(model_id: str, *, context: ToolContext) -> str: context.state["model"] = model_id # Persist on the session tape (merged back at end of turn); load_state # recovers the latest `model_switch` event next turn / after restart. - await context.tape.append_event("model_switch", {"model": model_id}) + await telemetry.record_tape_event(context.tape.store, context.tape.name, "model_switch", {"model": model_id}) return f"Session model set to {model_id} (applies from the next turn)." diff --git a/src/bub/tape.py b/src/bub/tape.py index de973f3c..d9034472 100644 --- a/src/bub/tape.py +++ b/src/bub/tape.py @@ -22,7 +22,7 @@ def utc_now() -> str: @dataclass(frozen=True) class TapeEntry: - """A single append-only entry in a tape.""" + """A single append-only stream entry in a tape.""" id: int kind: str @@ -33,40 +33,6 @@ class TapeEntry: 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]], **meta: Any) -> TapeEntry: - return cls(id=0, kind="tool_call", payload={"calls": calls}, 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 TapeStore(Protocol): """Append-only tape storage interface.""" diff --git a/src/bub/tools.py b/src/bub/tools.py index 5735deee..94fb0521 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -13,6 +13,7 @@ from loguru import logger from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, validate_call +from bub.builtin import telemetry from bub.builtin.tape import Tape from bub.runtime import BubError, ErrorKind @@ -206,31 +207,49 @@ async def _handle_tool_response_async( context: ToolContext | None, ) -> Any: tool_name = tool_obj.name - try: - result = self._invoke_tool( - tool_name=tool_name, - tool_obj=tool_obj, - tool_args=tool_args, - context=context, - ) - if inspect.isawaitable(result): - return await result - 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 - else: - return result + tape_name = context.tape.name if context is not None else None + tape_store = context.tape.store if context is not None else None + span_attributes: dict[str, object] = { + telemetry.GEN_AI_OPERATION_NAME: "execute_tool", + telemetry.GEN_AI_TOOL_NAME: tool_name, + } + if context is not None and context.run_id: + span_attributes[telemetry.BUB_RUN_ID] = context.run_id + with telemetry.bub_span("bub.tool.execute", tape=tape_name, store=tape_store, attributes=span_attributes) as span: + try: + result = self._invoke_tool( + tool_name=tool_name, + tool_obj=tool_obj, + tool_args=tool_args, + context=context, + ) + if inspect.isawaitable(result): + result = await result + except BubError as exc: + span.record_exception(exc) + span.set_attribute("bub.tool.status", "error") + raise + except ValidationError as exc: + error = BubError( + ErrorKind.INVALID_INPUT, + f"Tool '{tool_name}' argument validation failed.", + details={"errors": json.loads(exc.json())}, + ) + span.record_exception(error) + span.set_attribute("bub.tool.status", "error") + raise error from exc + except Exception as exc: + error = BubError( + ErrorKind.TOOL, + f"Tool '{tool_name}' execution failed.", + details={"error": repr(exc)}, + ) + span.record_exception(error) + span.set_attribute("bub.tool.status", "error") + raise error from exc + else: + span.set_attribute("bub.tool.status", "ok") + return result # Central registry for tools. Tools defined with the @tool decorator are automatically added here. diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index b7de505a..aee0b90f 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -12,8 +12,7 @@ from bub.builtin.agent import Agent from bub.builtin.model_runner import ModelRunner from bub.builtin.settings import AgentSettings -from bub.runtime import BubError -from bub.tape import TapeContext +from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, TapeContext, TapeQuery from bub.tools import REGISTRY, tool # --------------------------------------------------------------------------- @@ -93,12 +92,13 @@ class _FakeTape: def __init__(self, fork_capture: _ForkCapture) -> None: self._fork = fork_capture self.name = "test-tape" + self.store = AsyncTapeStoreAdapter(InMemoryTapeStore()) self.context = TapeContext(state={}) self.messages: list[dict[str, Any]] = [] self.events: list[tuple[str, str, dict[str, Any]]] = [] - async def ensure_bootstrap_anchor(self) -> None: - pass + def query(self) -> TapeQuery: + return TapeQuery(tape=self.name, store=self.store) @contextlib.asynccontextmanager async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[_FakeTape, None]: @@ -108,40 +108,6 @@ async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[_FakeTape, async def read_messages(self) -> list[dict[str, Any]]: return list(self.messages) - async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None: - self.events.append((self.name, name, payload)) - - async def record_chat( - self, - *, - run_id: str, - system_prompt: str | None, - new_messages: list[dict[str, Any]], - response_text: str | None, - context_error: BubError | None = None, - tool_calls: list[dict[str, Any]] | None = None, - tool_results: list[Any] | None = None, - error: BubError | None = None, - response: Any | None = None, - provider: str | None = None, - model: str | None = None, - usage: dict[str, Any] | None = None, - ) -> None: - if system_prompt: - self.events.append((self.name, "system", {"content": system_prompt})) - if context_error is not None: - self.events.append((self.name, "error", context_error.as_dict())) - self.messages.extend(new_messages) - if tool_calls: - self.events.append((self.name, "tool_call", {"calls": tool_calls})) - if tool_results is not None: - self.events.append((self.name, "tool_result", {"results": tool_results})) - if error is not None and error is not context_error: - self.events.append((self.name, "error", error.as_dict())) - if response_text is not None: - self.messages.append({"role": "assistant", "content": response_text}) - self.events.append((self.name, "run", {"run_id": run_id, "model": model, "error": error is not None})) - class _FakeTapeFactory: """Minimal tape factory stand-in for testing Agent.run().""" diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index b1bc1300..48741b7a 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -10,6 +10,7 @@ from bub.builtin.hook_impl import AGENTS_FILE_NAME, DEFAULT_SYSTEM_PROMPT, BuiltinImpl from bub.builtin.store import FileTapeStore from bub.builtin.tape import Tape +from bub.builtin.telemetry import record_tape_event from bub.channels.message import ChannelMessage from bub.framework import BubFramework from bub.runtime import AsyncStreamEvents, StreamEvent @@ -133,7 +134,7 @@ async def test_load_state_injects_model_recorded_on_session_tape(tmp_path: Path) """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) - await session.append_event("model_switch", {"model": "openai:gpt-4o"}) + await record_tape_event(session.store, session.name, "model_switch", {"model": "openai:gpt-4o"}) message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello") @@ -159,8 +160,8 @@ async def test_recover_session_model_returns_latest_recorded(tmp_path: Path) -> """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) - await session.append_event("model_switch", {"model": "openai:gpt-4o"}) - await session.append_event("model_switch", {"model": "anthropic:claude-3"}) + await record_tape_event(session.store, session.name, "model_switch", {"model": "openai:gpt-4o"}) + await record_tape_event(session.store, session.name, "model_switch", {"model": "anthropic:claude-3"}) assert await impl._recover_session_model("resolved-session") == "anthropic:claude-3" diff --git a/tests/test_builtin_tape.py b/tests/test_builtin_tape.py index 236cf9a1..9e954834 100644 --- a/tests/test_builtin_tape.py +++ b/tests/test_builtin_tape.py @@ -6,6 +6,7 @@ from bub.builtin.store import ForkTapeStore from bub.builtin.tape import Tape +from bub.builtin.telemetry import record_tape_event from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, TapeContext @@ -20,14 +21,14 @@ async def test_tape_fork_binds_temporary_fork_store_to_scoped_tape(tmp_path: Pat assert isinstance(first_store, ForkTapeStore) assert first_store is not root.store - await forked.append_event("step", {"value": 1}) + await record_tape_event(forked.store, forked.name, "step", {"value": 1}) assert parent.read("test-tape") is None assert [entry.payload["name"] for entry in parent.read("test-tape") or []] == ["step"] async with root.fork_tape(merge_back=False) as forked: second_store = forked.store - await forked.append_event("step", {"value": 2}) + await record_tape_event(forked.store, forked.name, "step", {"value": 2}) assert isinstance(second_store, ForkTapeStore) assert second_store is not first_store diff --git a/tests/test_builtin_telemetry.py b/tests/test_builtin_telemetry.py new file mode 100644 index 00000000..ddabc918 --- /dev/null +++ b/tests/test_builtin_telemetry.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import pytest + +from bub.builtin.store import ForkTapeStore +from bub.builtin.telemetry import TapeSpanExporter, record_tape_entry +from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, TapeEntry + + +@pytest.mark.asyncio +async def test_tape_span_exporter_writes_entry_to_tape() -> None: + parent = InMemoryTapeStore() + store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "ops") + entry = TapeEntry(id=0, kind="event", payload={"name": "step", "data": {"value": 1}}, meta={"run_id": "run-1"}) + + TapeSpanExporter(store).export_entry("ops", entry) + + await store.merge_back() + + entries = parent.read("ops") or [] + assert [(item.kind, item.payload, item.meta) for item in entries] == [ + ("event", {"name": "step", "data": {"value": 1}}, {"run_id": "run-1"}) + ] + + +@pytest.mark.asyncio +async def test_record_tape_entry_writes_stream_entry_through_logfire_processor() -> None: + parent = InMemoryTapeStore() + store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "ops") + + await record_tape_entry(store, "ops", "event", {"name": "step", "data": {"value": 1}}, run_id="run-1") + + await store.merge_back() + + entries = parent.read("ops") or [] + assert [(entry.kind, entry.payload, entry.meta) for entry in entries] == [ + ("event", {"name": "step", "data": {"value": 1}}, {"run_id": "run-1"}) + ] + + +@pytest.mark.asyncio +async def test_tape_entry_payload_is_not_scrubbed_by_logfire_attributes() -> None: + parent = InMemoryTapeStore() + store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "ops") + + await record_tape_entry( + store, + "ops", + "event", + {"name": "command", "data": {"output": "session id and secret must stay in tape"}}, + ) + + await store.merge_back() + + entries = parent.read("ops") or [] + assert entries[0].payload["data"]["output"] == "session id and secret must stay in tape" diff --git a/tests/test_file_tape_store_entry_ids.py b/tests/test_file_tape_store_entry_ids.py index d891de97..39846ab1 100644 --- a/tests/test_file_tape_store_entry_ids.py +++ b/tests/test_file_tape_store_entry_ids.py @@ -6,18 +6,22 @@ from bub.tape import AsyncTapeStoreAdapter, TapeEntry +def stream_entry(value: int) -> TapeEntry: + return TapeEntry(id=0, kind="record", payload={"value": value}) + + @pytest.mark.asyncio async def test_file_tape_store_assigns_monotonic_ids_when_merging_forked_entries(tmp_path) -> None: parent = FileTapeStore(directory=tmp_path) store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "tape") - await store.append("tape", TapeEntry.event(name="first", data={"n": 1})) + await store.append("tape", stream_entry(1)) await store.merge_back() store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "tape") - await store.append("tape", TapeEntry.event(name="second", data={"n": 2})) + await store.append("tape", stream_entry(2)) await store.merge_back() entries = parent.read("tape") or [] assert [entry.id for entry in entries] == [1, 2] - assert [entry.payload.get("name") for entry in entries] == ["first", "second"] + assert [entry.payload["value"] for entry in entries] == [1, 2] diff --git a/tests/test_fork_store_merge_back.py b/tests/test_fork_store_merge_back.py index c0f3d157..22f131e7 100644 --- a/tests/test_fork_store_merge_back.py +++ b/tests/test_fork_store_merge_back.py @@ -6,14 +6,18 @@ from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, TapeEntry, TapeQuery +def stream_entry(value: int) -> TapeEntry: + return TapeEntry(id=0, kind="record", payload={"value": value}) + + @pytest.mark.asyncio async def test_fork_merge_back_true_merges_entries() -> None: """With merge_back=True (default), forked entries are merged into the parent.""" parent = InMemoryTapeStore() store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "test-tape") - await store.append("test-tape", TapeEntry.event(name="step", data={"x": 1})) - await store.append("test-tape", TapeEntry.event(name="step", data={"x": 2})) + await store.append("test-tape", stream_entry(1)) + await store.append("test-tape", stream_entry(2)) await store.merge_back() entries = parent.read("test-tape") @@ -27,7 +31,7 @@ async def test_fork_merge_back_false_discards_entries() -> None: parent = InMemoryTapeStore() store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "test-tape") - await store.append("test-tape", TapeEntry.event(name="step", data={"x": 1})) + await store.append("test-tape", stream_entry(1)) entries = parent.read("test-tape") # No entries should have been merged @@ -49,51 +53,51 @@ async def test_merge_back_can_be_called_without_entries() -> None: async def test_fork_reset_with_merge_back_false_preserves_parent_entries() -> None: parent = InMemoryTapeStore() store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "test-tape") - parent.append("test-tape", TapeEntry.event(name="before", data={"x": 1})) + parent.append("test-tape", stream_entry(1)) await store.reset("test-tape") - await store.append("test-tape", TapeEntry.event(name="inside", data={"x": 2})) + await store.append("test-tape", stream_entry(2)) entries = parent.read("test-tape") assert entries is not None - assert [entry.payload["name"] for entry in entries] == ["before"] + assert [entry.payload["value"] for entry in entries] == [1] @pytest.mark.asyncio async def test_fork_reset_with_merge_back_true_replaces_parent_entries() -> None: parent = InMemoryTapeStore() store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "test-tape") - parent.append("test-tape", TapeEntry.event(name="before", data={"x": 1})) + parent.append("test-tape", stream_entry(1)) await store.reset("test-tape") - await store.append("test-tape", TapeEntry.event(name="inside", data={"x": 2})) + await store.append("test-tape", stream_entry(2)) await store.merge_back() entries = parent.read("test-tape") assert entries is not None - assert [entry.payload["name"] for entry in entries] == ["inside"] + assert [entry.payload["value"] for entry in entries] == [2] @pytest.mark.asyncio async def test_fork_reset_hides_parent_entries_during_fetch() -> None: parent = InMemoryTapeStore() store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "test-tape") - parent.append("test-tape", TapeEntry.event(name="before", data={"x": 1})) + parent.append("test-tape", stream_entry(1)) await store.reset("test-tape") - await store.append("test-tape", TapeEntry.event(name="inside", data={"x": 2})) + await store.append("test-tape", stream_entry(2)) query = TapeQuery(tape="test-tape", store=store) entries = list(await store.fetch_all(query)) - assert [entry.payload["name"] for entry in entries] == ["inside"] + assert [entry.payload["value"] for entry in entries] == [2] @pytest.mark.asyncio async def test_reset_for_unbound_tape_resets_parent_immediately() -> None: parent = InMemoryTapeStore() store = ForkTapeStore(AsyncTapeStoreAdapter(parent), "other-tape") - parent.append("test-tape", TapeEntry.event(name="before", data={"x": 1})) + parent.append("test-tape", stream_entry(1)) await store.reset("test-tape") diff --git a/uv.lock b/uv.lock index 68c87778..7c7431f0 100644 --- a/uv.lock +++ b/uv.lock @@ -213,6 +213,7 @@ dependencies = [ { name = "any-llm-sdk" }, { name = "httpx", extra = ["socks"] }, { name = "inquirer-textual" }, + { name = "logfire" }, { name = "loguru" }, { name = "pluggy" }, { name = "prompt-toolkit" }, @@ -226,11 +227,6 @@ dependencies = [ { name = "typing-extensions" }, ] -[package.optional-dependencies] -logfire = [ - { name = "logfire" }, -] - [package.dev-dependencies] dev = [ { name = "mypy" }, @@ -248,7 +244,7 @@ requires-dist = [ { name = "any-llm-sdk", extras = ["anthropic"] }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "inquirer-textual", specifier = ">=0.5.1" }, - { name = "logfire", marker = "extra == 'logfire'", specifier = ">=4.31.0" }, + { name = "logfire", specifier = ">=4.31.0" }, { name = "loguru", specifier = ">=0.7.2" }, { name = "pluggy", specifier = ">=1.6.0" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, @@ -261,7 +257,6 @@ requires-dist = [ { name = "typer", specifier = ">=0.9.0" }, { name = "typing-extensions", specifier = ">=4.13.0" }, ] -provides-extras = ["logfire"] [package.metadata.requires-dev] dev = [