diff --git a/pyproject.toml b/pyproject.toml index cc05424f..859f509e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "pluggy>=1.6.0", "inquirer-textual>=0.5.1", "typer>=0.9.0", - "republic>=0.5.4", "any-llm-sdk[anthropic]", "rich>=13.0.0", "prompt-toolkit>=3.0.0", @@ -35,6 +34,7 @@ dependencies = [ "rapidfuzz>=3.14.3", "aiohttp>=3.13.3", "httpx[socks]>=0.28.1", + "typing-extensions>=4.13.0", ] [project.urls] diff --git a/src/bub/__main__.py b/src/bub/__main__.py index f43ce44e..8ee17b49 100644 --- a/src/bub/__main__.py +++ b/src/bub/__main__.py @@ -21,8 +21,8 @@ def _instrument_bub() -> None: logfire.configure() logger.add(LogfireHandler(), format="{message}") - except ImportError: - pass + except Exception as exc: + logger.debug("logfire instrumentation disabled: {}", exc) def create_cli_app() -> typer.Typer: diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index a7c3311d..202df609 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -1,4 +1,4 @@ -"""Republic-driven runtime engine to process prompts.""" +"""Runtime engine to process prompts with any-llm-sdk.""" from __future__ import annotations @@ -13,29 +13,32 @@ from datetime import UTC, datetime from functools import cached_property from pathlib import Path -from typing import Any, Literal, overload - -from loguru import logger -from republic import ( - LLM, - AsyncStreamEvents, - AsyncTapeStore, - RepublicError, - StreamEvent, - StreamState, - TapeContext, - Tool, - ToolAutoResult, - ToolContext, +from typing import Any, Literal + +from any_llm import AnyLLM +from any_llm.types.completion import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + ChoiceDeltaToolCall, ) -from republic.tape import InMemoryTapeStore, Tape +from loguru import logger +from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall +from openai.types.chat.chat_completion_message_function_tool_call import ChatCompletionMessageFunctionToolCall, Function -from bub.builtin.settings import AgentSettings, load_settings +from bub.builtin.settings import ModelCandidate, load_settings from bub.builtin.store import ForkTapeStore from bub.builtin.tape import TapeService from bub.framework import BubFramework +from bub.runtime import AsyncStreamEvents, BubError, StreamEvent, StreamState from bub.skills import discover_skills, render_skills_prompt -from bub.tools import REGISTRY, model_tools, render_tools_prompt, resolve_tool_names +from bub.tape import InMemoryTapeStore, Tape +from bub.tools import ( + REGISTRY, + Tool, + ToolContext, + ToolExecutor, +) from bub.types import State from bub.utils import workspace_from_state @@ -49,7 +52,7 @@ class Agent: - """Agent that processes prompts using hooks and tools. Backed by republic.""" + """Agent that processes prompts using hooks, tools, tape, and any-llm-sdk.""" def __init__(self, framework: BubFramework) -> None: self.settings = load_settings() @@ -63,8 +66,7 @@ def tapes(self) -> TapeService: if tape_store is None: tape_store = InMemoryTapeStore() tape_store = ForkTapeStore(tape_store) - llm = _build_llm(self.settings, tape_store, self.framework.build_tape_context()) - return TapeService(llm, bub.home / "tapes", tape_store) + return TapeService(bub.home / "tapes", tape_store, self.framework.build_tape_context()) @staticmethod def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents: @@ -79,9 +81,11 @@ def _events_with_callback( events: AsyncStreamEvents, callback: Callable[[], Coroutine[Any, Any, Any]] ) -> AsyncStreamEvents: async def generator() -> AsyncIterator[StreamEvent]: - async for event in events: - yield event - await callback() + try: + async for event in events: + yield event + finally: + await callback() return AsyncStreamEvents(generator(), state=events._state) @@ -95,18 +99,19 @@ async def run( allowed_skills: Collection[str] | None = None, allowed_tools: Collection[str] | None = None, ) -> str: - if not prompt: - return "error: empty prompt" - tape = self.tapes.session_tape(session_id, workspace_from_state(state)) - tape.context = replace(tape.context, state=state) - merge_back = not session_id.startswith("temp/") - async with self.tapes.fork_tape(tape.name, merge_back=merge_back): - await self.tapes.ensure_bootstrap_anchor(tape.name) - if isinstance(prompt, str) and prompt.strip().startswith(","): - return await self._run_command(tape=tape, line=prompt.strip()) - return await self._agent_loop( - tape=tape, prompt=prompt, model=model, allowed_skills=allowed_skills, allowed_tools=allowed_tools - ) + output: list[str] = [] + stream = await self.run_stream( + session_id=session_id, + prompt=prompt, + state=state, + model=model, + allowed_skills=allowed_skills, + allowed_tools=allowed_tools, + ) + async for event in stream: + if event.kind == "text": + output.append(str(event.data.get("delta", ""))) + return "".join(output) async def run_stream( self, @@ -119,18 +124,16 @@ async def run_stream( allowed_tools: Collection[str] | None = None, ) -> AsyncStreamEvents: if not prompt: - events = [ + return self._events_from_iterable([ StreamEvent("text", {"delta": "error: empty prompt"}), StreamEvent("final", {"text": "error: empty prompt", "ok": False}), - ] - return self._events_from_iterable(events) + ]) tape = self.tapes.session_tape(session_id, workspace_from_state(state)) tape.context = replace(tape.context, state=state) merge_back = not session_id.startswith("temp/") stack = AsyncExitStack() - # the fork_tape context manager must not be exited until the last chunk of the stream is consumed. - # So we use an AsyncExitStack and inject a callback to the iterator. + # The fork_tape context manager must not be exited until the last chunk of the stream is consumed. await stack.enter_async_context(self.tapes.fork_tape(tape.name, merge_back=merge_back)) await self.tapes.ensure_bootstrap_anchor(tape.name) if isinstance(prompt, str) and prompt.strip().startswith(","): @@ -146,7 +149,6 @@ async def run_stream( model=model, allowed_skills=allowed_skills, allowed_tools=allowed_tools, - stream_output=True, ) return self._events_with_callback(events, callback=stack.aclose) @@ -190,30 +192,6 @@ async def _run_command(self, tape: Tape, *, line: str) -> str: } await self.tapes.append_event(tape.name, "command", event_payload) - @overload - async def _agent_loop( - self, - *, - tape: Tape, - prompt: str | list[dict], - model: str | None = ..., - allowed_skills: Collection[str] | None = ..., - allowed_tools: Collection[str] | None = ..., - stream_output: Literal[False] = ..., - ) -> str: ... - - @overload - async def _agent_loop( - self, - *, - tape: Tape, - prompt: str | list[dict], - model: str | None = ..., - allowed_skills: Collection[str] | None = ..., - allowed_tools: Collection[str] | None = ..., - stream_output: Literal[True] = ..., - ) -> AsyncStreamEvents: ... - async def _agent_loop( self, *, @@ -222,8 +200,7 @@ async def _agent_loop( model: str | None = None, allowed_skills: Collection[str] | None = None, allowed_tools: Collection[str] | None = None, - stream_output: bool = False, - ) -> AsyncStreamEvents | str: + ) -> AsyncStreamEvents: next_prompt: str | list[dict] = prompt display_model = model or self.settings.model await self.tapes.append_event( @@ -236,39 +213,32 @@ async def _agent_loop( "allowed_tools": list(allowed_tools) if allowed_tools else None, }, ) - if stream_output: - state = StreamState() - iterator = self._stream_events_with_auto_handoff( - tape=tape, - prompt=next_prompt, - state=state, - model=model, - allowed_skills=allowed_skills, - allowed_tools=allowed_tools, - ) - return AsyncStreamEvents(iterator, state=state) - else: - return await self._run_tools_with_auto_handoff( - tape=tape, - prompt=next_prompt, - model=model, - allowed_skills=allowed_skills, - allowed_tools=allowed_tools, - ) + state = StreamState() + iterator = self._stream_events_with_auto_handoff( + tape=tape, + prompt=next_prompt, + state=state, + model=model, + allowed_skills=allowed_skills, + allowed_tools=allowed_tools, + ) + return AsyncStreamEvents(iterator, state=state) - async def _run_tools_with_auto_handoff( + async def _stream_events_with_auto_handoff( self, tape: Tape, prompt: str | list[dict], + state: StreamState, model: str | None = None, allowed_skills: Collection[str] | None = None, allowed_tools: Collection[str] | None = None, - ) -> str: + ) -> AsyncGenerator[StreamEvent, None]: auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES display_model = model or self.settings.model next_prompt = 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 self.tapes.append_event(tape.name, "loop.step.start", {"step": step, "prompt": next_prompt}) try: @@ -279,210 +249,92 @@ async def _run_tools_with_auto_handoff( 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) + await self.tapes.append_event( + 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) - await self.tapes.append_event( - tape.name, - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "error", - "error": f"{exc!s}", - "date": datetime.now(UTC).isoformat(), - }, - ) - raise - - outcome = _resolve_tool_auto_result(output) - elapsed_ms = int((time.monotonic() - start) * 1000) - if outcome.kind == "text": - await self.tapes.append_event( - tape.name, - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "ok", - "date": datetime.now(UTC).isoformat(), - }, - ) - return outcome.text - if outcome.kind == "continue": - if "context" in tape.context.state: - next_prompt = f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]" - else: - next_prompt = CONTINUE_PROMPT - await self.tapes.append_event( - tape.name, - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "continue", - "date": datetime.now(UTC).isoformat(), - }, - ) - continue - - # Check if this is a context-length error that can be recovered via auto-handoff - if auto_handoff_remaining > 0 and _is_context_length_error(outcome.error): - auto_handoff_remaining -= 1 - logger.warning( - "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}", - tape.name, - step, - ) - await self.tapes.handoff( - tape.name, - name="auto_handoff/context_overflow", - state={"reason": "context_length_exceeded", "error": outcome.error}, - ) - await self.tapes.append_event( - tape.name, - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "auto_handoff", - "error": outcome.error, - "date": datetime.now(UTC).isoformat(), - }, - ) - # Retry with original prompt — the handoff anchor will truncate history - next_prompt = prompt - continue - - await self.tapes.append_event( - tape.name, - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "error", - "error": outcome.error, - "date": datetime.now(UTC).isoformat(), - }, - ) - raise RuntimeError(outcome.error) - - raise RuntimeError(f"max_steps_reached={self.settings.max_steps}") - - async def _stream_events_with_auto_handoff( - self, - tape: Tape, - prompt: str | list[dict], - 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 = prompt - for step in range(1, self.settings.max_steps + 1): - start = time.monotonic() - outcome = _ToolAutoOutcome(kind="text", text="", error="") - logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model) - await self.tapes.append_event(tape.name, "loop.step.start", {"step": step, "prompt": next_prompt}) - output = await self._run_once( - tape=tape, - prompt=next_prompt, - model=model, - allowed_skills=allowed_skills, - allowed_tools=allowed_tools, - stream_output=True, - ) - async for event in output: - yield event - if event.kind == "error": - 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 self.tapes.handoff( + tape.name, + name="auto_handoff/context_overflow", + state={"reason": "context_length_exceeded", "error": error_message}, + ) await self.tapes.append_event( 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": - outcome = _resolve_final_data(event.data, output.error) + next_prompt = prompt + continue - state.error = output.error - state.usage = output.usage - elapsed_ms = int((time.monotonic() - start) * 1000) - if outcome.kind == "text": await self.tapes.append_event( tape.name, "loop.step", { "step": step, "elapsed_ms": elapsed_ms, - "status": "ok", - "date": datetime.now(UTC).isoformat(), - }, - ) - return - if outcome.kind == "continue": - if "context" in tape.context.state: - next_prompt = f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]" - else: - next_prompt = CONTINUE_PROMPT - await self.tapes.append_event( - tape.name, - "loop.step", - { - "step": step, - "elapsed_ms": elapsed_ms, - "status": "continue", + "status": "error", + "error": error_message, "date": datetime.now(UTC).isoformat(), }, ) - continue + raise - # Check if this is a context-length error that can be recovered via auto-handoff - if auto_handoff_remaining > 0 and _is_context_length_error(outcome.error): - auto_handoff_remaining -= 1 - logger.warning( - "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}", - tape.name, - step, - ) - await self.tapes.handoff( - tape.name, - name="auto_handoff/context_overflow", - state={"reason": "context_length_exceeded", "error": outcome.error}, - ) + state.error = output.error + state.usage = output.usage + elapsed_ms = int((time.monotonic() - start) * 1000) + if not should_continue: await self.tapes.append_event( tape.name, "loop.step", { "step": step, "elapsed_ms": elapsed_ms, - "status": "auto_handoff", - "error": outcome.error, + "status": "ok", "date": datetime.now(UTC).isoformat(), }, ) - # Retry with original prompt — the handoff anchor will truncate history - next_prompt = prompt - continue + return + next_prompt = self._continue_prompt(tape) await self.tapes.append_event( tape.name, "loop.step", { "step": step, "elapsed_ms": elapsed_ms, - "status": "error", - "error": outcome.error, + "status": "continue", "date": datetime.now(UTC).isoformat(), }, ) - raise RuntimeError(outcome.error) raise RuntimeError(f"max_steps_reached={self.settings.max_steps}") @@ -495,30 +347,6 @@ def _load_skills_prompt(self, prompt: str, workspace: Path, allowed_skills: set[ expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys()) return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills) - @overload - async def _run_once( - self, - *, - tape: Tape, - prompt: str | list[dict], - model: str | None = ..., - allowed_skills: Collection[str] | None = ..., - allowed_tools: Collection[str] | None = ..., - stream_output: Literal[False] = ..., - ) -> ToolAutoResult: ... - - @overload - async def _run_once( - self, - *, - tape: Tape, - prompt: str | list[dict], - model: str | None = ..., - allowed_skills: Collection[str] | None = ..., - allowed_tools: Collection[str] | None = ..., - stream_output: Literal[True] = ..., - ) -> AsyncStreamEvents: ... - async def _run_once( self, *, @@ -527,10 +355,11 @@ async def _run_once( model: str | None = None, allowed_tools: Collection[str] | None = None, allowed_skills: Collection[str] | None = None, - stream_output: bool = False, - ) -> AsyncStreamEvents | ToolAutoResult: + ) -> AsyncStreamEvents: prompt_text = prompt if isinstance(prompt, str) else _extract_text_from_parts(prompt) if allowed_tools is not None: + from bub.builtin.tools import resolve_tool_names + allowed_tools = resolve_tool_names(allowed_tools) if allowed_skills is not None: allowed_skills = {name.casefold() for name in allowed_skills} @@ -539,31 +368,146 @@ async def _run_once( tools = [tool for tool in REGISTRY.values() if tool.name in allowed_tools] else: tools = list(REGISTRY.values()) - async with asyncio.timeout(self.settings.model_timeout_seconds): - if stream_output: - return await tape.stream_events_async( - prompt=prompt, - system_prompt=self._system_prompt( - prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools - ), - max_tokens=self.settings.max_tokens, - tools=model_tools(tools), - model=model, + return await self._run_once_stream( + tape=tape, + prompt=prompt, + prompt_text=prompt_text, + model=model, + allowed_skills=allowed_skills, + tools=tools, + ) + + async def _run_once_stream( + self, + *, + tape: Tape, + prompt: str | list[dict], + prompt_text: str, + model: str | None, + allowed_skills: set[str] | None, + tools: list[Tool], + ) -> AsyncStreamEvents: + state = StreamState() + + async def iterator() -> AsyncGenerator[StreamEvent, None]: + system_prompt = self._system_prompt( + prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools + ) + prompt_message: dict[str, Any] = {"role": "user", "content": prompt} + run_id = f"run-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}" + try: + messages = await self.tapes.read_messages(tape) + except BubError as exc: + await self.tapes.record_chat( + tape=tape.name, + run_id=run_id, + system_prompt=system_prompt, + context_error=exc, + new_messages=[], + response_text=None, + error=exc, + model=model or self.settings.model, ) - else: - return await tape.run_tools_async( - prompt=prompt, - system_prompt=self._system_prompt( - prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools - ), + raise + if system_prompt: + messages = [{"role": "system", "content": system_prompt}, *messages] + messages.append(prompt_message) + + from bub.builtin.tools import model_tools + + model_tools_for_call = model_tools(tools) + text_parts: list[str] = [] + tool_calls = _ToolCallAccumulator() + response: ChatCompletion | None = None + async with asyncio.timeout(self.settings.model_timeout_seconds): + completion = await self._completion_response( + model=model or self.settings.model, + messages=messages, + tools=model_tools_for_call, + ) + if isinstance(completion, ChatCompletion): + response = completion + async for event in _completion_events(completion, state, text_parts, tool_calls): + yield event + + text = "".join(text_parts) + resolved_tool_calls = tool_calls.as_list() + if resolved_tool_calls: + context = ToolContext(tape=tape.name, run_id=run_id, state=tape.context.state) + execution = await ToolExecutor().execute_async( + resolved_tool_calls, + tools=model_tools_for_call, + context=context, + ) + await self.tapes.record_chat( + tape=tape.name, + run_id=run_id, + system_prompt=system_prompt, + new_messages=[prompt_message], + response_text=None, + tool_calls=execution.tool_calls, + tool_results=execution.tool_results, + response=response, + model=model or self.settings.model, + usage=state.usage, + ) + yield StreamEvent("tool_call", {"tool_calls": execution.tool_calls}) + yield StreamEvent("tool_result", {"tool_results": execution.tool_results}) + yield StreamEvent( + "final", {"ok": True, "tool_calls": execution.tool_calls, "tool_results": execution.tool_results} + ) + return + + await self.tapes.record_chat( + tape=tape.name, + run_id=run_id, + system_prompt=system_prompt, + new_messages=[prompt_message], + response_text=text, + response=response, + model=model or self.settings.model, + usage=state.usage, + ) + yield StreamEvent("final", {"ok": True, "text": text}) + + return AsyncStreamEvents(iterator(), state=state) + + def _build_llm(self, candidate: ModelCandidate) -> AnyLLM: + return AnyLLM.create( + candidate.provider, + **self.settings.model_client_kwargs(candidate.provider), + ) + + async def _completion_response( + self, *, model: str, messages: list[dict[str, Any]], tools: list[Tool] + ) -> ChatCompletion | AsyncIterator[ChatCompletionChunk]: + from bub.builtin.tools import completion_tools + + tool_payloads = completion_tools(tools) or None + completion_messages: list[dict[str, Any] | ChatCompletionMessage] = list(messages) + candidates = self.settings.model_candidates(model) + for index, candidate in enumerate(candidates): + try: + llm = self._build_llm(candidate) + return await llm.acompletion( + model=candidate.model_id, + messages=completion_messages, + tools=tool_payloads, max_tokens=self.settings.max_tokens, - tools=model_tools(tools), - model=model, + stream=llm.SUPPORTS_COMPLETION_STREAMING, ) + except Exception as exc: + if index == len(candidates) - 1: + raise + logger.warning("model candidate failed; trying fallback model={} error={}", candidate.name, exc) + + raise RuntimeError("no model candidates available") def _system_prompt( self, prompt: str, state: State, allowed_skills: set[str] | None = None, tools: Iterable[Tool] | None = None ) -> str: + from bub.builtin.tools import render_tools_prompt + blocks: list[str] = [] if result := self.framework.get_system_prompt(prompt=prompt, state=state): blocks.append(result) @@ -575,49 +519,88 @@ def _system_prompt( blocks.append(skills_prompt) return "\n\n".join(blocks) + def _continue_prompt(self, tape: Tape) -> str: + if "context" in tape.context.state: + return f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]" + return CONTINUE_PROMPT + + +@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 "{}"), + ) + -@dataclass(frozen=True) -class _ToolAutoOutcome: - kind: str - text: str = "" - error: str = "" - - -def _resolve_final_data(final_data: dict[str, Any], error: RepublicError | None) -> _ToolAutoOutcome: - if final_data.get("tool_calls") or final_data.get("tool_results"): - return _ToolAutoOutcome(kind="continue") - if (text := final_data.get("text")) is not None: - return _ToolAutoOutcome(kind="text", text=text) - error_message = error.message if error else "" - return _ToolAutoOutcome(kind="error", error=error_message or "unknown error") - - -def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome: - if output.kind == "text": - return _ToolAutoOutcome(kind="text", text=output.text or "") - if output.kind == "tools" or output.tool_calls or output.tool_results: - return _ToolAutoOutcome(kind="continue") - if output.error is None: - return _ToolAutoOutcome(kind="error", error="tool_auto_error: unknown") - error_kind = getattr(output.error.kind, "value", str(output.error.kind)) - return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}") - - -def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore, tape_context: TapeContext) -> LLM: - from republic.auth.openai_codex import openai_codex_oauth_resolver - - return LLM( - settings.model, - api_key=settings.api_key, - api_base=settings.api_base, - fallback_models=settings.fallback_models, - api_key_resolver=openai_codex_oauth_resolver(), - tape_store=tape_store, - client_args=settings.client_args, - api_format=settings.api_format, - context=tape_context, - verbose=settings.verbose, - ) +class _ToolCallAccumulator: + def __init__(self) -> None: + self._calls: list[ChatCompletionMessageFunctionToolCall | ChatCompletionMessageCustomToolCall] = [] + self._stream_calls: dict[int, _StreamToolCall] = {} + + def add_message_call( + self, call: ChatCompletionMessageFunctionToolCall | ChatCompletionMessageCustomToolCall + ) -> None: + self._calls.append(call) + + def merge_delta_calls(self, deltas: Iterable[ChoiceDeltaToolCall]) -> None: + for delta in deltas: + self._stream_calls.setdefault(delta.index, _StreamToolCall()).merge(delta) + + def as_list(self) -> list[dict[str, Any]]: + calls = self._calls or [self._stream_calls[index].as_tool_call(index) for index in sorted(self._stream_calls)] + return [call.model_dump(exclude_none=True) for call in calls] + + +async def _completion_events( + completion: ChatCompletion | AsyncIterator[ChatCompletionChunk], + state: StreamState, + text_parts: list[str], + tool_calls: _ToolCallAccumulator, +) -> AsyncGenerator[StreamEvent, None]: + if isinstance(completion, ChatCompletion): + if usage := TapeService._extract_usage(completion): + state.usage = usage + message = completion.choices[0].message + if message.content: + text_parts.append(message.content) + yield StreamEvent("text", {"delta": message.content}) + for tool_call in message.tool_calls or []: + tool_calls.add_message_call(tool_call) + return + + async for chunk in completion: + if usage := TapeService._extract_usage(chunk): + state.usage = usage + for choice in chunk.choices: + delta = choice.delta + if delta.content: + text_parts.append(delta.content) + yield StreamEvent("text", {"delta": delta.content}) + if delta.tool_calls: + tool_calls.merge_delta_calls(delta.tool_calls) @dataclass(frozen=True) diff --git a/src/bub/builtin/auth.py b/src/bub/builtin/auth.py deleted file mode 100644 index 8f5236b2..00000000 --- a/src/bub/builtin/auth.py +++ /dev/null @@ -1,59 +0,0 @@ -import os -from pathlib import Path - -import typer -from republic.auth.openai_codex import CodexOAuthLoginError, OpenAICodexOAuthTokens, login_openai_codex_oauth - -DEFAULT_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback" -app = typer.Typer(name="login", help="Authentication related commands") - - -def _render_codex_login_result(tokens: OpenAICodexOAuthTokens, auth_path: Path) -> 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:gpt-5-codex and omit BUB_API_KEY") - - -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 _resolve_codex_home(codex_home: Path | None) -> Path: - if codex_home is not None: - return codex_home.expanduser() - return Path(os.getenv("CODEX_HOME", "~/.codex")).expanduser() - - -@app.command() -def openai( - codex_home: Path | None = typer.Option(None, "--codex-home", help="Directory to store Codex OAuth credentials"), # noqa: B008 - 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") diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 48725103..ae2b5d23 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -17,7 +17,6 @@ import typer from bub import __version__, configure -from bub.builtin.auth import app as login_app # noqa: F401 from bub.channels.message import ChannelMessage from bub.envelope import field_of from bub.framework import BubFramework diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py index a58248b8..11059983 100644 --- a/src/bub/builtin/context.py +++ b/src/bub/builtin/context.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from typing import Any -from republic import TapeContext, TapeEntry +from bub.tape import TapeContext, TapeEntry def default_tape_context() -> TapeContext: diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 63f3a74a..0ed75721 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -5,8 +5,6 @@ import typer from loguru import logger -from republic import AsyncStreamEvents, TapeContext -from republic.tape import TapeStore from bub import inquirer as bub_inquirer from bub.builtin.agent import Agent @@ -17,6 +15,8 @@ from bub.envelope import content_of, field_of from bub.framework import BubFramework from bub.hookspecs import hookimpl +from bub.runtime import AsyncStreamEvents +from bub.tape import TapeContext, TapeStore from bub.types import Envelope, MessageHandler, State AGENTS_FILE_NAME = "AGENTS.md" @@ -32,7 +32,6 @@ "mistral", "deepseek", ) -API_FORMAT_CHOICES: tuple[str, ...] = ("completion", "responses", "messages") DEFAULT_SYSTEM_PROMPT = """\ Call tools or skills to finish the task. @@ -171,7 +170,6 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("run")(cli.run) app.command("chat")(cli.chat) app.command("onboard")(cli.onboard) - app.add_typer(cli.login_app) app.command("hooks", hidden=True)(cli.list_hooks) app.command("gateway")(cli.gateway) app.command("install")(cli.install) @@ -203,14 +201,6 @@ def onboard_config(self, current_config: dict[str, object]) -> dict[str, object] api_base_default = str(current_api_base) if isinstance(current_api_base, str) else "" api_base = bub_inquirer.ask_text("API base (optional)", default=api_base_default) - current_api_format = current_config.get("api_format") - api_format_default = ( - str(current_api_format) - if isinstance(current_api_format, str) and current_api_format in API_FORMAT_CHOICES - else API_FORMAT_CHOICES[0] - ) - api_format = bub_inquirer.ask_select("API format", choices=list(API_FORMAT_CHOICES), default=api_format_default) - available_channels = self._channel_choices() default_channels = self._default_enabled_channels(current_config.get("enabled_channels"), available_channels) enabled_channels = bub_inquirer.ask_checkbox( @@ -223,7 +213,6 @@ def onboard_config(self, current_config: dict[str, object]) -> dict[str, object] stream_output = bub_inquirer.ask_confirm("Stream output", default=bool(current_config.get("stream_output"))) config: dict[str, object] = { "model": model, - "api_format": api_format, "enabled_channels": ",".join(enabled_channels), "stream_output": stream_output, } diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 2345db99..49d88389 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -3,11 +3,14 @@ import os import pathlib import re -from collections.abc import Callable -from typing import Any, Literal +from dataclasses import dataclass +from typing import Any -from pydantic import Field -from pydantic_settings import SettingsConfigDict +from any_llm import AnyLLM +from any_llm.constants import LLMProvider +from pydantic import Field, field_validator +from pydantic.fields import FieldInfo +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict from bub import Settings, config, ensure_config @@ -15,20 +18,33 @@ DEFAULT_MAX_TOKENS = 16384 -def provider_specific(setting_name: str) -> Callable[[], dict[str, str] | None]: - def default_factory() -> dict[str, str] | None: +@dataclass(frozen=True) +class ModelCandidate: + provider: LLMProvider + model_id: str + name: str + + +class ProviderSpecificEnvSource(PydanticBaseSettingsSource): + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + return None, field_name, False + + def __call__(self) -> dict[str, Any]: + result: dict[str, Any] = {} + for field_name, setting_name in (("api_key", "api_key"), ("api_base", "api_base")): + values = self._provider_specific(setting_name) + if values: + result[field_name] = values + return result + + @staticmethod + def _provider_specific(setting_name: str) -> dict[str, str]: setting_regex = re.compile(rf"^BUB_(.+)_{setting_name.upper()}$") - loaded_env = os.environ result: dict[str, str] = {} - for key, value in loaded_env.items(): - if value is None: - continue + for key, value in os.environ.items(): if match := setting_regex.match(key): - provider = match.group(1).lower() - result[provider] = value - return result or None - - return default_factory + result[match.group(1).lower()] = value + return result @config() @@ -38,15 +54,60 @@ class AgentSettings(Settings): model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore") model: str = DEFAULT_MODEL fallback_models: list[str] | None = None - api_key: str | dict[str, str] | None = Field(default_factory=provider_specific("api_key")) - api_base: str | dict[str, str] | None = Field(default_factory=provider_specific("api_base")) - api_format: Literal["completion", "responses", "messages"] = "completion" + api_key: str | dict[str, str] | None = None + api_base: str | dict[str, str] | None = None max_steps: int = 50 max_tokens: int = DEFAULT_MAX_TOKENS model_timeout_seconds: int | None = None - client_args: dict[str, Any] | None = None + client_args: dict[str, Any] = Field(default_factory=dict) verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2) + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + return ( + env_settings, + dotenv_settings, + init_settings, + ProviderSpecificEnvSource(settings_cls), + file_secret_settings, + ) + + @field_validator("client_args", mode="before") + @classmethod + def default_client_args(cls, value: Any) -> Any: + return {} if value is None else value + + 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, model_id = AnyLLM.split_model_provider(candidate) + candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=candidate)) + return candidates + + def model_client_kwargs(self, provider: LLMProvider) -> dict[str, Any]: + return { + **self.client_args, + "api_key": self._provider_value(self.api_key, provider), + "api_base": self._provider_value(self.api_base, provider), + } + + @staticmethod + def _provider_value(value: str | dict[str, str] | None, provider: LLMProvider) -> str | None: + if isinstance(value, dict): + return value.get(provider.value) + return value + @property def home(self) -> pathlib.Path: import warnings diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py index b30dd751..8873ce75 100644 --- a/src/bub/builtin/store.py +++ b/src/bub/builtin/store.py @@ -10,13 +10,21 @@ from dataclasses import asdict, replace from datetime import UTC, datetime from pathlib import Path -from typing import Any, cast +from typing import Any from loguru import logger -from republic import AsyncTapeStore, TapeEntry, TapeQuery -from republic.tape import AsyncTapeStoreAdapter, InMemoryQueryMixin, InMemoryTapeStore, TapeStore -from republic.tape.store import is_async_tape_store +from bub.tape import ( + AsyncTapeStore, + AsyncTapeStoreAdapter, + InMemoryQueryMixin, + InMemoryTapeStore, + TapeEntry, + TapeQuery, + TapeStore, + is_async_tape_store, + is_tape_entry_kind, +) from bub.utils import get_entry_text current_store: contextvars.ContextVar[TapeStore] = contextvars.ContextVar("current_store") @@ -48,7 +56,7 @@ def _current_was_reset(self) -> bool: return current_tape_was_reset.get() async def list_tapes(self) -> list[str]: - return cast(list[str], await self._parent.list_tapes()) + return await self._parent.list_tapes() async def reset(self, tape: str) -> None: self._current.reset(tape) @@ -65,8 +73,8 @@ async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntr except Exception: parent_entries = [] this_entries: list[TapeEntry] = [] - if hasattr(self._current, "read"): - for entry in cast(list[TapeEntry], self._current.read(query.tape) or []): + if isinstance(self._current, InMemoryQueryMixin): + for entry in self._current.read(query.tape) or []: if query._kinds and entry.kind not in query._kinds: continue if entry.kind == "anchor": # noqa: SIM102 @@ -257,7 +265,7 @@ def __init__(self, path: Path) -> None: def _next_id(self) -> int: if self._read_entries: - return cast(int, self._read_entries[-1].id + 1) + return self._read_entries[-1].id + 1 return 1 def _reset(self) -> None: @@ -311,7 +319,7 @@ def entry_from_payload(payload: object) -> TapeEntry | None: meta = payload.get("meta") if not isinstance(entry_id, int): return None - if not isinstance(kind, str): + if not is_tape_entry_kind(kind): return None if not isinstance(entry_payload, dict): return None diff --git a/src/bub/builtin/tape.py b/src/bub/builtin/tape.py index f89619e9..ca992521 100644 --- a/src/bub/builtin/tape.py +++ b/src/bub/builtin/tape.py @@ -1,16 +1,18 @@ import contextlib import hashlib +import inspect import json from collections.abc import AsyncGenerator -from dataclasses import asdict +from dataclasses import asdict, dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any, cast +from typing import Any -from pydantic.dataclasses import dataclass -from republic import LLM, AsyncTapeStore, Tape, TapeEntry, TapeQuery +from pydantic import BaseModel from bub.builtin.store import ForkTapeStore +from bub.runtime import BubError +from bub.tape import AsyncTapeStore, Tape, TapeContext, TapeEntry, TapeQuery, build_messages @dataclass(frozen=True) @@ -34,14 +36,16 @@ class AnchorSummary: class TapeService: - def __init__(self, llm: LLM, archive_path: Path, store: ForkTapeStore) -> None: - self._llm = llm + def __init__(self, archive_path: Path, store: ForkTapeStore, context: TapeContext | None = None) -> None: self._archive_path = archive_path self._store = store + self._context = context or TapeContext() + + def query(self, tape_name: str) -> TapeQuery[AsyncTapeStore]: + return TapeQuery(tape=tape_name, store=self._store) async def info(self, tape_name: str) -> TapeInfo: - tape = self._llm.tape(tape_name) - entries = list(await tape.query_async.all()) + entries = list(await self._store.fetch_all(self.query(tape_name))) anchors = [(i, entry) for i, entry in enumerate(entries) if entry.kind == "anchor"] if anchors: last_anchor = anchors[-1][1].payload.get("name") @@ -58,7 +62,7 @@ async def info(self, tape_name: str) -> TapeInfo: last_token_usage = token_usage break return TapeInfo( - name=tape.name, + name=tape_name, entries=len(entries), anchors=len(anchors), last_anchor=str(last_anchor) if last_anchor else None, @@ -67,14 +71,12 @@ async def info(self, tape_name: str) -> TapeInfo: ) async def ensure_bootstrap_anchor(self, tape_name: str) -> None: - tape = self._llm.tape(tape_name) - anchors = list(await tape.query_async.kinds("anchor").all()) + anchors = list(await self._store.fetch_all(self.query(tape_name).kinds("anchor"))) if not anchors: - await tape.handoff_async("session/start", state={"owner": "human"}) + await self.handoff(tape_name, name="session/start", state={"owner": "human"}) async def anchors(self, tape_name: str, limit: int = 20) -> list[AnchorSummary]: - tape = self._llm.tape(tape_name) - entries = list(await tape.query_async.kinds("anchor").all()) + entries = list(await self._store.fetch_all(self.query(tape_name).kinds("anchor"))) results: list[AnchorSummary] = [] for entry in entries[-limit:]: name = str(entry.payload.get("name", "-")) @@ -83,46 +85,115 @@ async def anchors(self, tape_name: str, limit: int = 20) -> list[AnchorSummary]: 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, tape_name: str, name: str, payload: dict[str, Any], **meta: Any) -> None: + await self._store.append(tape_name, TapeEntry.event(name, payload, **meta)) + + async def read_messages(self, tape: Tape) -> list[dict[str, Any]]: + query = tape.context.build_query(self.query(tape.name)) + entries = await self._store.fetch_all(query) + messages = build_messages(entries, tape.context) + if inspect.isawaitable(messages): + messages = await messages + return messages + + async def handoff( + self, + tape_name: str, + *, + name: str, + state: dict[str, Any] | None = None, + **meta: Any, + ) -> list[TapeEntry]: + 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, + *, + tape: str, + 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: + meta = {"run_id": run_id} + if system_prompt: + await self._store.append(tape, TapeEntry.system(system_prompt, **meta)) + if context_error is not None: + await self._store.append(tape, TapeEntry.error(context_error, **meta)) + for message in new_messages: + await self._store.append(tape, TapeEntry.message(message, **meta)) + if tool_calls: + await self._store.append(tape, TapeEntry.tool_call(tool_calls, **meta)) + if tool_results is not None: + await self._store.append(tape, TapeEntry.tool_result(tool_results, **meta)) + if error is not None and error is not context_error: + await self._store.append(tape, TapeEntry.error(error, **meta)) + if response_text is not None: + await self._store.append(tape, 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, 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, tape_name: str) -> Path: - tape = self._llm.tape(tape_name) stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") self._archive_path.mkdir(parents=True, exist_ok=True) - archive_path = self._archive_path / f"{tape.name}.jsonl.{stamp}.bak" + archive_path = self._archive_path / f"{tape_name}.jsonl.{stamp}.bak" with archive_path.open("w", encoding="utf-8") as f: - for entry in await tape.query_async.all(): + for entry in await self._store.fetch_all(self.query(tape_name)): f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n") return archive_path async def reset(self, tape_name: str, *, archive: bool = False) -> str: - tape = self._llm.tape(tape_name) archive_path: Path | None = None if archive: archive_path = await self._archive(tape_name) - await tape.reset_async() + await self._store.reset(tape_name) state = {"owner": "human"} if archive_path is not None: state["archived"] = str(archive_path) - await tape.handoff_async("session/start", state=state) + await self.handoff(tape_name, name="session/start", state=state) return f"Archived: {archive_path}" if archive_path else "ok" - async def handoff(self, tape_name: str, *, name: str, state: dict[str, Any] | None = None) -> list[TapeEntry]: - tape = self._llm.tape(tape_name) - entries = await tape.handoff_async(name, state=state) - return cast(list[TapeEntry], entries) - - async def search(self, query: TapeQuery[AsyncTapeStore]) -> list[TapeEntry]: - return list(await self._store.fetch_all(query)) - - async def append_event(self, tape_name: str, name: str, payload: dict[str, Any], **meta: Any) -> None: - tape = self._llm.tape(tape_name) - await tape.append_async(TapeEntry.event(name=name, data=payload, **meta)) - def session_tape(self, session_id: str, workspace: Path) -> 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._llm.tape(tape_name) + return Tape(name=tape_name, context=self._context) @contextlib.asynccontextmanager async def fork_tape(self, tape_name: str, merge_back: bool = True) -> AsyncGenerator[None, None]: diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 8a0db44d..95858e0a 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -3,26 +3,120 @@ import asyncio import json import uuid +from collections.abc import Iterable +from dataclasses import replace from pathlib import Path -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, cast +from openai.types.chat import ChatCompletionToolParam from pydantic import BaseModel, Field -from republic import AsyncTapeStore, TapeQuery, ToolContext from bub.builtin.shell_manager import shell_manager from bub.skills import discover_skills -from bub.tools import resolve_tool_names, tool +from bub.tape import TapeEntryKind +from bub.tools import REGISTRY, Tool, ToolContext, tool if TYPE_CHECKING: from bub.builtin.agent import Agent -type EntryKind = Literal["event", "anchor", "system", "message", "tool_call", "tool_result"] - DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 DEFAULT_HEADERS = {"accept": "text/markdown"} DEFAULT_REQUEST_TIMEOUT_SECONDS = 10 +def _to_model_name(name: str) -> str: + return name.replace(".", "_") + + +def _tool_name_index() -> dict[str, str]: + real_names = {tool_name.casefold(): tool_name for tool_name in REGISTRY} + alias_names = {_to_model_name(tool_name).casefold(): tool_name for tool_name in REGISTRY} + return {**alias_names, **real_names} + + +def resolve_tool_name(name: str) -> str | None: + """Resolve a user/model-provided tool name to the runtime registry name.""" + key = name.strip().casefold() + if not key: + return None + return _tool_name_index().get(key) + + +def _resolve_explicit_tool_names(names: Iterable[str]) -> tuple[set[str], set[str]]: + resolved: set[str] = set() + unknown: set[str] = set() + for name in names: + normalized_name = name.strip() + if resolved_name := resolve_tool_name(normalized_name): + resolved.add(resolved_name) + else: + unknown.add(normalized_name) + return resolved, unknown + + +def _raise_unknown_tool_names(names: set[str]) -> None: + formatted = ", ".join(sorted(repr(name) for name in names)) + raise ValueError(f"unknown tool name(s): {formatted}") + + +def resolve_tool_names(names: Iterable[str] | None = None, *, exclude: Iterable[str] = ()) -> set[str]: + """Resolve tool names from either runtime names or model-facing aliases.""" + excluded, unknown_excluded = _resolve_explicit_tool_names(exclude) + if unknown_excluded: + _raise_unknown_tool_names(unknown_excluded) + if names is None: + return set(REGISTRY) - excluded + + resolved, unknown = _resolve_explicit_tool_names(names) + if unknown: + _raise_unknown_tool_names(unknown) + return resolved - excluded + + +def model_tools(tools: Iterable[Tool]) -> list[Tool]: + """Convert runtime tool names into model-safe aliases.""" + return [replace(tool_item, name=_to_model_name(tool_item.name)) for tool_item in tools] + + +def _tool_signature(tool_item: Tool) -> str: + properties = tool_item.parameters.get("properties", {}) + if not isinstance(properties, dict) or not properties: + return f"{_to_model_name(tool_item.name)}()" + + required = tool_item.parameters.get("required", []) + required_names = set(required) if isinstance(required, list) else set() + params = [name if name in required_names else f"{name}?" for name in properties] + return f"{_to_model_name(tool_item.name)}({', '.join(params)})" + + +def render_tools_prompt(tools: Iterable[Tool]) -> str: + """Render a human-readable description of tools for builtin agent prompts.""" + if not tools: + return "" + lines = [] + for tool_item in tools: + line = f"- {_tool_signature(tool_item)}" + if tool_item.description: + line += f": {tool_item.description}" + lines.append(line) + return f"\n{'\n'.join(lines)}\n" + + +def completion_tools(tools: Iterable[Tool]) -> list[ChatCompletionToolParam]: + """Build any-llm completion tool payloads from Bub tools.""" + return [ + { + "type": "function", + "function": { + "name": tool_item.name, + "description": tool_item.description, + "parameters": tool_item.parameters, + }, + } + for tool_item in tools + ] + + def _raise_for_failed_shell(returncode: int | None, output: str) -> None: if returncode in (None, 0): return @@ -42,9 +136,9 @@ class SearchInput(BaseModel): limit: int = Field(20, description="Maximum number of search results to return.") start: str | None = Field(None, description="Optional start date to filter entries (ISO format).") end: str | None = Field(None, description="Optional end date to filter entries (ISO format).") - kinds: list[str] = Field( + kinds: list[TapeEntryKind] = Field( default=["message", "tool_result"], - description="Optional list of entry kinds to filter search results. Can include 'event', 'anchor', 'system', 'message', 'tool_call', 'tool_result'.", + description="Optional list of entry kinds to filter search results. Can include 'event', 'anchor', 'system', 'message', 'tool_call', 'tool_result', 'error'.", ) @@ -195,12 +289,7 @@ async def tape_info(context: ToolContext) -> str: async def tape_search(param: SearchInput, *, context: ToolContext) -> str: """Search for entries in the current tape that match the query. Returns a list of matching entries.""" agent = _get_agent(context) - query = ( - TapeQuery[AsyncTapeStore](tape=context.tape or "", store=agent.tapes._store) - .query(param.query) - .kinds(*param.kinds) - .limit(param.limit) - ) + query = agent.tapes.query(context.tape or "").query(param.query).kinds(*param.kinds).limit(param.limit) if param.start or param.end: query = query.between_dates(param.start or "", param.end or "") diff --git a/src/bub/channels/base.py b/src/bub/channels/base.py index 5b87cba8..920469ec 100644 --- a/src/bub/channels/base.py +++ b/src/bub/channels/base.py @@ -3,9 +3,8 @@ from collections.abc import AsyncIterable from typing import ClassVar -from republic import StreamEvent - from bub.channels.message import ChannelMessage +from bub.runtime import StreamEvent class Channel(ABC): diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 3496ded2..10b526f9 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -12,7 +12,6 @@ from prompt_toolkit.history import FileHistory from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout -from republic import StreamEvent from rich import get_console import bub @@ -22,6 +21,7 @@ from bub.channels.cli.renderer import CliRenderer from bub.channels.message import ChannelMessage from bub.envelope import field_of +from bub.runtime import StreamEvent from bub.tools import REGISTRY from bub.types import MessageHandler diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index 3339ba85..313cc2ce 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -6,7 +6,6 @@ from loguru import logger from pydantic import Field from pydantic_settings import SettingsConfigDict -from republic import StreamEvent from bub import config from bub.channels.base import Channel, Interface, Lifecycle @@ -15,6 +14,7 @@ from bub.configure import Settings, ensure_config from bub.envelope import content_of, field_of from bub.framework import BubFramework +from bub.runtime import StreamEvent from bub.turn_admission import AdmitDecision, SessionTurnController from bub.types import Envelope, MessageHandler from bub.utils import wait_until_stopped diff --git a/src/bub/configure.py b/src/bub/configure.py index eb6c6329..b01321b7 100644 --- a/src/bub/configure.py +++ b/src/bub/configure.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from functools import cache from pathlib import Path from typing import Any @@ -65,10 +66,33 @@ def validate(config_data: dict[str, Any]) -> dict[str, Any]: for section, config_classes in CONFIG_MAP.items(): section_data = config_data if section == ROOT else config_data.get(section, {}) for config_cls in config_classes: - config_cls.model_validate(section_data) + _validation_config(config_cls)(**section_data) return config_data +@cache +def _validation_config(config_cls: type[BaseSettings]) -> type[BaseSettings]: + def settings_customise_sources( + cls: type[BaseSettings], + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + del cls, settings_cls, env_settings, dotenv_settings, file_secret_settings + return (init_settings,) + + return type( + f"{config_cls.__name__}Validation", + (config_cls,), + { + "__module__": config_cls.__module__, + "settings_customise_sources": classmethod(settings_customise_sources), + }, + ) + + def save(config_file: Path, config_data: dict[str, Any]) -> None: """Validate and persist config data to a YAML file.""" import yaml diff --git a/src/bub/framework.py b/src/bub/framework.py index a95a4ba1..cbece9d5 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -6,20 +6,19 @@ from collections.abc import AsyncGenerator, AsyncIterator, Iterator 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 from dotenv import load_dotenv from loguru import logger -from republic import AsyncTapeStore, RepublicError, TapeContext -from republic.core.errors import ErrorKind -from republic.tape import TapeStore from bub import configure from bub.envelope import content_of, field_of, unpack_batch from bub.hook_runtime import _SKIP_VALUE, HookRuntime from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs +from bub.runtime import BubError, ErrorKind +from bub.tape import AsyncTapeStore, TapeContext, TapeStore from bub.turn_admission import AdmitDecision, SteeringBuffer, TurnSnapshot from bub.types import Envelope, MessageHandler, OutboundChannelRouter, TurnResult @@ -180,20 +179,17 @@ async def _run_model( return prompt if isinstance(prompt, str) else content_of(inbound) else: parts: list[str] = [] - if self._outbound_router is not None: - stream = self._outbound_router.wrap_stream(inbound, stream) - async for event in stream: + events = self._outbound_router.wrap_stream(inbound, stream) if self._outbound_router is not None else stream + async for event in events: if event.kind == "text": parts.append(str(event.data.get("delta", ""))) elif event.kind == "error": - # Turn "kind" to enum type otherwise the RepublicError's __str__ won't work well + # Turn "kind" to enum type otherwise BubError's __str__ won't work well. data = { **event.data, "kind": ErrorKind(event.data.get("kind", "unknown")), } - await self._hook_runtime.notify_error( - stage="run_model", error=RepublicError(**data), message=inbound - ) + await self._hook_runtime.notify_error(stage="run_model", error=BubError(**data), message=inbound) return "".join(parts) def hook_report(self) -> dict[str, list[str]]: @@ -214,15 +210,15 @@ async def quit_via_router(self, session_id: str) -> None: await self._outbound_router.quit(session_id) async def admit_message(self, *, session_id: str, message: Envelope, turn: TurnSnapshot) -> AdmitDecision | None: - return cast( - "AdmitDecision | None", - await self._hook_runtime.call_first( - "admit_message", - session_id=session_id, - message=message, - turn=turn, - ), + decision = await self._hook_runtime.call_first( + "admit_message", + session_id=session_id, + message=message, + turn=turn, ) + if decision is None or isinstance(decision, AdmitDecision): + return decision + raise TypeError("hook.admit_message must return AdmitDecision or None") def steering(self, session_id: str) -> SteeringBuffer: buffer = self._steering_buffers.get(session_id) @@ -310,7 +306,10 @@ def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) -> ) def build_tape_context(self) -> TapeContext: - return self._hook_runtime.call_first_sync("build_tape_context") + context = self._hook_runtime.call_first_sync("build_tape_context") + if isinstance(context, TapeContext): + return context + raise TypeError("hook.build_tape_context must return TapeContext") def collect_onboard_config(self) -> dict[str, Any]: current_config: dict[str, Any] = {} diff --git a/src/bub/hook_runtime.py b/src/bub/hook_runtime.py index 3d58712c..bdb0eaf7 100644 --- a/src/bub/hook_runtime.py +++ b/src/bub/hook_runtime.py @@ -4,12 +4,12 @@ import inspect from collections.abc import AsyncGenerator -from typing import Any, cast +from typing import Any import pluggy from loguru import logger -from republic import AsyncStreamEvents, StreamEvent, StreamState +from bub.runtime import AsyncStreamEvents, StreamEvent, StreamState from bub.types import Envelope @@ -165,7 +165,9 @@ async def run_model(self, prompt: str | list[dict], session_id: str, state: dict for _, plugin in reversed(self._plugin_manager.list_name_plugin()): if hasattr(plugin, "run_model"): output = await self.call_first("run_model", prompt=prompt, session_id=session_id, state=state) - return cast(str, output) + if output is None or isinstance(output, str): + return output + raise TypeError("hook.run_model must return str or None") elif hasattr(plugin, "run_model_stream"): stream = await self.call_first("run_model_stream", prompt=prompt, session_id=session_id, state=state) text = "" @@ -181,7 +183,10 @@ async def run_model_stream( """Run the first `run_model_stream` hook found and fallback to `run_model` hook.""" for _, plugin in reversed(self._plugin_manager.list_name_plugin()): if hasattr(plugin, "run_model_stream"): - return await self.call_first("run_model_stream", prompt=prompt, session_id=session_id, state=state) + stream = await self.call_first("run_model_stream", prompt=prompt, session_id=session_id, state=state) + if stream is None or isinstance(stream, AsyncStreamEvents): + return stream + raise TypeError("hook.run_model_stream must return AsyncStreamEvents or None") elif hasattr(plugin, "run_model"): async def iterator() -> AsyncGenerator[StreamEvent, None]: diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py index 35d9b1b6..6bb5da2b 100644 --- a/src/bub/hookspecs.py +++ b/src/bub/hookspecs.py @@ -5,9 +5,9 @@ from typing import TYPE_CHECKING, Any import pluggy -from republic import AsyncStreamEvents, AsyncTapeStore, TapeContext -from republic.tape import TapeStore +from bub.runtime import AsyncStreamEvents +from bub.tape import AsyncTapeStore, TapeContext, TapeStore from bub.turn_admission import AdmitDecision, TurnSnapshot from bub.types import Envelope, MessageHandler, State @@ -97,7 +97,7 @@ def system_prompt(self, prompt: str | list[dict], state: State) -> str: @hookspec(firstresult=True) def provide_tape_store(self) -> TapeStore | AsyncTapeStore: """Provide a tape store instance for Bub's conversation recording feature.""" - ... + raise NotImplementedError @hookspec def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: diff --git a/src/bub/runtime.py b/src/bub/runtime.py new file mode 100644 index 00000000..2089396a --- /dev/null +++ b/src/bub/runtime.py @@ -0,0 +1,70 @@ +"""Small runtime primitives shared by Bub core and channels.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, Literal + + +class ErrorKind(StrEnum): + """Stable error kinds for runtime decisions.""" + + INVALID_INPUT = "invalid_input" + CONFIG = "config" + PROVIDER = "provider" + TOOL = "tool" + TEMPORARY = "temporary" + NOT_FOUND = "not_found" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class BubError(Exception): + """Public error type for Bub runtime failures.""" + + kind: ErrorKind + message: str + details: dict[str, Any] | None = None + + def __str__(self) -> str: + return f"[{self.kind.value}] {self.message}" + + def as_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "kind": self.kind.value, + "message": self.message, + } + if self.details: + payload["details"] = self.details + return payload + + +@dataclass +class StreamState: + error: BubError | None = None + usage: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class StreamEvent: + kind: Literal["text", "tool_call", "tool_result", "usage", "error", "final"] + data: dict[str, Any] + + +class AsyncStreamEvents: + def __init__(self, iterator: AsyncIterator[StreamEvent], *, state: StreamState | None = None) -> None: + self._iterator = iterator + self._state = state or StreamState() + + def __aiter__(self) -> AsyncIterator[StreamEvent]: + return self._iterator + + @property + def error(self) -> BubError | None: + return self._state.error + + @property + def usage(self) -> dict[str, Any] | None: + return self._state.usage diff --git a/src/bub/tape.py b/src/bub/tape.py new file mode 100644 index 00000000..7333da11 --- /dev/null +++ b/src/bub/tape.py @@ -0,0 +1,380 @@ +"""Append-only tape primitives owned by Bub.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +from collections.abc import Callable, Coroutine, Iterable, Sequence +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime, time +from datetime import date as date_type +from typing import Any, Literal, NoReturn, Protocol, Self, get_args, overload + +from typing_extensions import TypeIs + +from bub.runtime import BubError, ErrorKind + +type TapeEntryKind = Literal["event", "anchor", "system", "message", "tool_call", "tool_result", "error"] + + +def utc_now() -> str: + return datetime.now(UTC).isoformat() + + +def is_tape_entry_kind(value: object) -> TypeIs[TapeEntryKind]: + return isinstance(value, str) and value in get_args(TapeEntryKind) + + +@dataclass(frozen=True) +class TapeEntry: + """A single append-only entry in a tape.""" + + id: int + kind: TapeEntryKind + 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]], **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.""" + + 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[TapeEntryKind, ...] = 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: TapeEntryKind) -> 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) + + +class _LastAnchor: + def __repr__(self) -> str: + return "LAST_ANCHOR" + + +LAST_ANCHOR = _LastAnchor() +type AnchorSelector = str | None | _LastAnchor +type SelectedMessages = list[dict[str, Any]] | Coroutine[Any, Any, list[dict[str, Any]]] +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) + + +@dataclass +class Tape: + """A scoped conversation tape used by the agent runtime.""" + + name: str + context: TapeContext + + +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[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 _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() diff --git a/src/bub/tools.py b/src/bub/tools.py index 7cdbd799..bba37040 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -1,22 +1,250 @@ +from __future__ import annotations + +import asyncio import inspect import json import time -from collections.abc import Callable, Iterable -from dataclasses import replace +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field, replace from typing import Any, overload from loguru import logger -from pydantic import BaseModel -from republic import Tool -from republic import tool as republic_tool +from pydantic import BaseModel, TypeAdapter, ValidationError, validate_call + +from bub.runtime import BubError, ErrorKind + + +@dataclass(frozen=True) +class ToolContext: + """Runtime context passed to tools that opt into context.""" + + tape: str | None = None + run_id: str | None = None + state: dict[str, Any] = field(default_factory=dict) + + +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 + properties[param.name] = _schema_from_annotation(param.annotation) + if param.default is param.empty: + required.append(param.name) + schema: dict[str, Any] = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return schema + + +@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 + + def run(self, *args: Any, **kwargs: Any) -> Any: + return self.handler(*args, **kwargs) + + @classmethod + def from_callable( + cls, + func: Callable[..., Any], + *, + name: str | None = None, + description: str | None = None, + context: 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) + validated = validate_call(func) + return cls( + name=tool_name, + description=tool_description, + parameters=parameters, + handler=validated, + context=context, + ) + + +@dataclass(frozen=True) +class ToolExecution: + tool_calls: list[dict[str, Any]] = field(default_factory=list) + tool_results: list[Any] = field(default_factory=list) + error: BubError | None = None + + +class ToolExecutor: + """Execute model tool calls with predictable validation and serialization.""" + + async def execute_async( + self, + response: list[dict[str, Any]] | dict[str, Any] | str, + tools: Sequence[Tool] | None = None, + *, + context: ToolContext | None = None, + ) -> ToolExecution: + tool_calls = self._normalize_response(response) + tool_map = self._build_tool_map(tools) + if not tool_map: + if tool_calls: + raise BubError(ErrorKind.TOOL, "No runnable tools are available.") + return ToolExecution(tool_calls=[], tool_results=[]) + + results: list[Any] = [] + error: BubError | None = None + gathered = await asyncio.gather( + *(self._handle_tool_response_async(tool_response, tool_map, context) for tool_response in tool_calls), + return_exceptions=True, + ) + for result in gathered: + if isinstance(result, BubError): + error = result + results.append(result.as_dict()) + elif isinstance(result, BaseException): + raise result + else: + results.append(result) + + return ToolExecution(tool_calls=tool_calls, tool_results=results, error=error) + + def _resolve_tool_call( + self, + tool_response: Any, + tool_map: dict[str, Tool], + ) -> tuple[str, Tool, dict[str, Any]]: + if not isinstance(tool_response, dict): + raise BubError(ErrorKind.INVALID_INPUT, "Each tool call must be an object.") + tool_name = tool_response.get("function", {}).get("name") + if not tool_name: + raise BubError(ErrorKind.INVALID_INPUT, "Tool call is missing name.") + tool_obj = tool_map.get(tool_name) + if tool_obj is None: + raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.") + tool_args = tool_response.get("function", {}).get("arguments", {}) + tool_args = self._normalize_tool_args(str(tool_name), tool_args) + return str(tool_name), tool_obj, tool_args + + 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_response: Any, + tool_map: dict[str, Tool], + context: ToolContext | None, + ) -> Any: + tool_name, tool_obj, tool_args = self._resolve_tool_call(tool_response, tool_map) + 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 + + def _normalize_response( + self, + response: list[dict[str, Any]] | dict[str, Any] | str, + ) -> list[dict[str, Any]]: + if isinstance(response, str): + try: + response = json.loads(response) + except json.JSONDecodeError as exc: + raise BubError( + ErrorKind.INVALID_INPUT, + "Tool response is not a valid JSON string.", + details={"error": str(exc)}, + ) from exc + if isinstance(response, dict): + response = [response] + if not isinstance(response, list): + raise BubError(ErrorKind.INVALID_INPUT, "Tool response must be a list of objects.") + return response + + def _build_tool_map(self, tools: Sequence[Tool] | None) -> dict[str, Tool]: + if tools is None: + raise BubError(ErrorKind.INVALID_INPUT, "No tools provided.") + return {tool_obj.name: tool_obj for tool_obj in tools} + + def _normalize_tool_args(self, tool_name: str, tool_args: Any) -> dict[str, Any]: + if isinstance(tool_args, str): + try: + tool_args = json.loads(tool_args) + except json.JSONDecodeError as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"Tool '{tool_name}' arguments are not valid JSON.") from exc + if isinstance(tool_args, dict): + return dict(tool_args) + raise BubError(ErrorKind.INVALID_INPUT, f"Tool '{tool_name}' arguments must be an object.") + # Central registry for tools. Tools defined with the @tool decorator are automatically added here. REGISTRY: dict[str, Tool] = {} def _add_logging(tool: Tool) -> Tool: - if tool.handler is None: - return tool + handler = tool.handler async def wrapped(*args, **kwargs): call_kwargs = kwargs.copy() @@ -26,7 +254,7 @@ async def wrapped(*args, **kwargs): start = time.monotonic() try: - result = tool.handler(*args, **kwargs) + result = handler(*args, **kwargs) if inspect.isawaitable(result): result = await result except Exception: @@ -112,99 +340,31 @@ def tool( ) -> Tool | Callable[[Callable], Tool]: """Decorator to convert a function into a Tool instance.""" - result = republic_tool( - func=func, - name=name, - model=model, - description=description, - context=context, - ) - if isinstance(result, Tool): - tool_instance = _add_logging(result) - REGISTRY[tool_instance.name] = tool_instance - return tool_instance - def decorator(func: Callable) -> Tool: - tool_instance = _add_logging(result(func)) + 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 (model.__doc__ or ""), + parameters=model.model_json_schema(), + handler=handler, + context=context, + ) + else: + result = Tool.from_callable(func, name=name, description=description, context=context) + tool_instance = _add_logging(result) REGISTRY[tool_instance.name] = tool_instance return tool_instance - return decorator - - -def _to_model_name(name: str) -> str: - return name.replace(".", "_") - - -def _tool_name_index() -> dict[str, str]: - real_names = {tool_name.casefold(): tool_name for tool_name in REGISTRY} - alias_names = {_to_model_name(tool_name).casefold(): tool_name for tool_name in REGISTRY} - return {**alias_names, **real_names} - - -def resolve_tool_name(name: str) -> str | None: - """Resolve a user/model-provided tool name to the runtime registry name.""" - key = name.strip().casefold() - if not key: - return None - return _tool_name_index().get(key) - - -def _resolve_explicit_tool_names(names: Iterable[str]) -> tuple[set[str], set[str]]: - resolved: set[str] = set() - unknown: set[str] = set() - for name in names: - normalized_name = name.strip() - if resolved_name := resolve_tool_name(normalized_name): - resolved.add(resolved_name) - else: - unknown.add(normalized_name) - return resolved, unknown - - -def _raise_unknown_tool_names(names: set[str]) -> None: - formatted = ", ".join(sorted(repr(name) for name in names)) - raise ValueError(f"unknown tool name(s): {formatted}") - - -def resolve_tool_names(names: Iterable[str] | None = None, *, exclude: Iterable[str] = ()) -> set[str]: - """Resolve tool names from either runtime names or model-facing aliases.""" - excluded, unknown_excluded = _resolve_explicit_tool_names(exclude) - if unknown_excluded: - _raise_unknown_tool_names(unknown_excluded) - if names is None: - return set(REGISTRY) - excluded - - resolved, unknown = _resolve_explicit_tool_names(names) - if unknown: - _raise_unknown_tool_names(unknown) - return resolved - excluded - - -def model_tools(tools: Iterable[Tool]) -> list[Tool]: - """Helper to convert a list of Tool instances into a format accepted by LLMs.""" - return [replace(tool, name=_to_model_name(tool.name)) for tool in tools] - - -def _tool_signature(tool: Tool) -> str: - properties = tool.parameters.get("properties", {}) - if not isinstance(properties, dict) or not properties: - return f"{_to_model_name(tool.name)}()" - - required = tool.parameters.get("required", []) - required_names = set(required) if isinstance(required, list) else set() - params = [name if name in required_names else f"{name}?" for name in properties] - return f"{_to_model_name(tool.name)}({', '.join(params)})" - - -def render_tools_prompt(tools: Iterable[Tool]) -> str: - """Render a human-readable description of tools for model prompts.""" - if not tools: - return "" - lines = [] - for tool in tools: - line = f"- {_tool_signature(tool)}" - if tool.description: - line += f": {tool.description}" - lines.append(line) - return f"\n{'\n'.join(lines)}\n" + if func is None: + return decorator + return decorator(func) diff --git a/src/bub/types.py b/src/bub/types.py index b85bf2c8..65ad88af 100644 --- a/src/bub/types.py +++ b/src/bub/types.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from typing import Any, Protocol -from republic import StreamEvent +from bub.runtime import StreamEvent type Envelope = Any type State = dict[str, Any] diff --git a/src/bub/utils.py b/src/bub/utils.py index db97a0c3..39488a05 100644 --- a/src/bub/utils.py +++ b/src/bub/utils.py @@ -3,8 +3,7 @@ from pathlib import Path from typing import Any -from republic import TapeEntry - +from bub.tape import TapeEntry from bub.types import State diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 2ad86f76..ba470306 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -1,53 +1,19 @@ from __future__ import annotations import contextlib -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, AsyncIterator from typing import Any from unittest.mock import MagicMock, patch import pytest -import republic.auth.openai_codex as openai_codex -from republic import AsyncStreamEvents, StreamEvent, TapeContext +from any_llm.types.completion import ChatCompletionChunk -import bub.builtin.agent as agent_module from bub.builtin.agent import Agent from bub.builtin.settings import AgentSettings +from bub.runtime import BubError +from bub.tape import Tape, TapeContext from bub.tools import REGISTRY, tool - -def test_build_llm_passes_codex_resolver_to_republic(monkeypatch) -> None: - captured: dict[str, Any] = {} - resolver = object() - - class FakeLLM: - def __init__(self, *args: object, **kwargs: object) -> None: - captured["args"] = args - captured["kwargs"] = kwargs - - monkeypatch.setattr(agent_module, "LLM", FakeLLM) - monkeypatch.setattr(openai_codex, "openai_codex_oauth_resolver", lambda: resolver) - - settings = AgentSettings.model_construct( - model="openai:gpt-5-codex", - api_key=None, - api_base=None, - client_args={"extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"}}, - ) - tape_store = object() - - agent_module._build_llm(settings, tape_store, "ctx") - - assert captured["args"] == ("openai:gpt-5-codex",) - assert captured["kwargs"]["api_key"] is None - assert captured["kwargs"]["api_base"] is None - assert captured["kwargs"]["client_args"] == { - "extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"}, - } - assert captured["kwargs"]["api_key_resolver"] is resolver - assert captured["kwargs"]["tape_store"] is tape_store - assert captured["kwargs"]["context"] == "ctx" - - # --------------------------------------------------------------------------- # Agent.run() tests: merge_back logic and model passthrough # --------------------------------------------------------------------------- @@ -62,21 +28,52 @@ def _make_agent() -> Agent: 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") + agent.settings = AgentSettings.model_construct(model="test:model", api_key="k", api_base="b", client_args={}) agent.framework = framework + + async def fake_completion_response(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: + agent.completion_kwargs = kwargs + return _chat_stream("done") + + agent.completion_kwargs = None + agent._completion_response = fake_completion_response # type: ignore[method-assign] return agent +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 the merge_back kwarg passed to fork_tape.""" + """Captures fork_tape enter and exit behavior.""" def __init__(self) -> None: self.merge_back_values: list[bool] = [] + self.exit_count = 0 @contextlib.asynccontextmanager async def fork_tape(self, tape_name: str, merge_back: bool = True) -> AsyncGenerator[None, None]: self.merge_back_values.append(merge_back) - yield + try: + yield + finally: + self.exit_count += 1 class _FakeTapeService: @@ -84,37 +81,58 @@ class _FakeTapeService: def __init__(self, fork_capture: _ForkCapture) -> None: self._fork = fork_capture - self.run_tools_model: str | None = None - self.stream_kwargs: dict[str, Any] | None = None - - def session_tape(self, session_id: str, workspace: Any) -> MagicMock: - tape = MagicMock() - tape.name = "test-tape" - tape.context = TapeContext(state={}) - - async def fake_stream_events_async(**kwargs: Any) -> AsyncStreamEvents: - self.run_tools_model = kwargs.get("model") - self.stream_kwargs = kwargs - - async def iterator(): - yield StreamEvent("final", {"text": "done"}) + self.messages: list[dict[str, Any]] = [] + self.events: list[tuple[str, str, dict[str, Any]]] = [] - return AsyncStreamEvents(iterator()) - - tape.stream_events_async = fake_stream_events_async - return tape + def session_tape(self, session_id: str, workspace: Any) -> Tape: + return Tape(name="test-tape", context=TapeContext(state={})) async def ensure_bootstrap_anchor(self, tape_name: str) -> None: pass - async def append_event(self, tape_name: str, name: str, payload: dict) -> None: - pass - @contextlib.asynccontextmanager async def fork_tape(self, tape_name: str, merge_back: bool = True) -> AsyncGenerator[None, None]: async with self._fork.fork_tape(tape_name, merge_back=merge_back): yield + async def read_messages(self, tape: Tape) -> list[dict[str, Any]]: + return list(self.messages) + + async def append_event(self, tape_name: str, name: str, payload: dict[str, Any], **meta: Any) -> None: + self.events.append((tape_name, name, payload)) + + async def record_chat( + self, + *, + tape: str, + 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((tape, "system", {"content": system_prompt})) + if context_error is not None: + self.events.append((tape, "error", context_error.as_dict())) + self.messages.extend(new_messages) + if tool_calls: + self.events.append((tape, "tool_call", {"calls": tool_calls})) + if tool_results is not None: + self.events.append((tape, "tool_result", {"results": tool_results})) + if error is not None and error is not context_error: + self.events.append((tape, "error", error.as_dict())) + if response_text is not None: + self.messages.append({"role": "assistant", "content": response_text}) + self.events.append((tape, "run", {"run_id": run_id, "model": model, "error": error is not None})) + @pytest.mark.asyncio async def test_agent_run_regular_session_merges_back() -> None: @@ -124,9 +142,14 @@ async def test_agent_run_regular_session_merges_back() -> None: agent.tapes = _FakeTapeService(fork_capture) # type: ignore[assignment] result = await agent.run_stream(session_id="user/session1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 + + assert fork_capture.merge_back_values == [True] + assert fork_capture.exit_count == 0 + [event async for event in result] assert fork_capture.merge_back_values == [True] + assert fork_capture.exit_count == 1 @pytest.mark.asyncio @@ -137,14 +160,19 @@ async def test_agent_run_temp_session_does_not_merge_back() -> None: agent.tapes = _FakeTapeService(fork_capture) # type: ignore[assignment] result = await agent.run_stream(session_id="temp/abc123", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 + + assert fork_capture.merge_back_values == [False] + assert fork_capture.exit_count == 0 + [event async for event in result] assert fork_capture.merge_back_values == [False] + assert fork_capture.exit_count == 1 @pytest.mark.asyncio async def test_agent_run_passes_model_to_llm() -> None: - """The model parameter should be forwarded to stream_events_async.""" + """The model parameter should be forwarded to any-llm.""" agent = _make_agent() fork_capture = _ForkCapture() fake_tapes = _FakeTapeService(fork_capture) @@ -158,7 +186,7 @@ async def test_agent_run_passes_model_to_llm() -> None: ) [event async for event in result] - assert fake_tapes.run_tools_model == "openai:gpt-4o" + assert agent.completion_kwargs["model"] == "openai:gpt-4o" @pytest.mark.asyncio @@ -177,7 +205,7 @@ 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, None should be passed to run_tools_async.""" + """When model is not specified, settings.model is used for any-llm.""" agent = _make_agent() fork_capture = _ForkCapture() fake_tapes = _FakeTapeService(fork_capture) @@ -186,7 +214,7 @@ 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] - assert fake_tapes.run_tools_model is None + assert agent.completion_kwargs["model"] == "test:model" @pytest.mark.asyncio @@ -217,9 +245,9 @@ def denied_agent_tool() -> str: ) [event async for event in result] - assert fake_tapes.stream_kwargs is not None - assert [tool.name for tool in fake_tapes.stream_kwargs["tools"]] == ["tests_allowed_agent_tool"] - system_prompt = fake_tapes.stream_kwargs["system_prompt"] + assert agent.completion_kwargs is not None + assert [tool.name for tool in agent.completion_kwargs["tools"]] == ["tests_allowed_agent_tool"] + system_prompt = agent.completion_kwargs["messages"][0]["content"] assert "- tests_allowed_agent_tool(): Allowed tool" in system_prompt assert "tests_denied_agent_tool" not in system_prompt diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index f345c2b4..344589ce 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -11,7 +11,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.configure as configure import bub.inquirer as bub_inquirer @@ -53,7 +52,6 @@ class OnboardPlugin: @hookimpl def onboard_config(self, current_config): assert isinstance(current_config.get("model"), str) - assert current_config["api_format"] == "completion" assert current_config["enabled_channels"] == "telegram" assert current_config["stream_output"] is False assert "telegram" not in current_config @@ -111,7 +109,6 @@ def onboard_config(self, current_config): assert f"Saved config to {config_file.resolve()}" in result.stdout assert loaded == { "model": "openai:gpt-5", - "api_format": "completion", "enabled_channels": "telegram", "stream_output": False, "telegram": {"token": "123:abc"}, @@ -140,11 +137,6 @@ def test_onboard_collects_builtin_runtime_config(tmp_path: Path, monkeypatch) -> "ask_fuzzy", lambda message, choices, default=None: "openrouter", ) - monkeypatch.setattr( - bub_inquirer, - "ask_select", - lambda message, choices, default="": "responses", - ) monkeypatch.setattr( bub_inquirer, "ask_checkbox", @@ -168,7 +160,6 @@ def test_onboard_collects_builtin_runtime_config(tmp_path: Path, monkeypatch) -> assert result.exit_code == 0 assert loaded == { "model": "openrouter:openrouter/free", - "api_format": "responses", "enabled_channels": "telegram,cli", "stream_output": True, "api_key": "sk-test", @@ -316,11 +307,6 @@ def test_onboard_collects_builtin_runtime_config_with_custom_provider(tmp_path: "ask_fuzzy", lambda message, choices, default=None: "custom", ) - monkeypatch.setattr( - bub_inquirer, - "ask_select", - lambda message, choices, default="": "messages", - ) monkeypatch.setattr( bub_inquirer, "ask_checkbox", @@ -353,67 +339,16 @@ def test_onboard_collects_builtin_runtime_config_with_custom_provider(tmp_path: assert _rendered_onboard_banner() in result.stdout assert loaded == { "model": "acme:ultra-1", - "api_format": "messages", "enabled_channels": "telegram", "stream_output": False, } -def test_login_openai_runs_oauth_flow_and_prints_usage_hint( - tmp_path: Path, - monkeypatch, -) -> None: - captured: dict[str, object] = {} - - def fake_login_openai_codex_oauth(**kwargs: object) -> auth.OpenAICodexOAuthTokens: - captured.update(kwargs) - prompt_for_redirect = kwargs["prompt_for_redirect"] - assert callable(prompt_for_redirect) - callback = prompt_for_redirect("https://auth.openai.com/authorize") - assert callback == "http://localhost:1455/auth/callback?code=test" - return auth.OpenAICodexOAuthTokens( - access_token="access", # noqa: S106 - refresh_token="refresh", # noqa: S106 - expires_at=123, - account_id="acct_123", - ) - - monkeypatch.setattr(auth, "login_openai_codex_oauth", fake_login_openai_codex_oauth) - monkeypatch.setattr(auth.typer, "prompt", lambda message: "http://localhost:1455/auth/callback?code=test") - - result = CliRunner().invoke( - _create_app(), - ["login", "openai", "--manual", "--no-browser", "--codex-home", str(tmp_path)], - ) - - assert result.exit_code == 0 - assert captured["codex_home"] == tmp_path - assert captured["open_browser"] is False - assert captured["redirect_uri"] == auth.DEFAULT_CODEX_REDIRECT_URI - assert captured["timeout_seconds"] == 300.0 - 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 - assert "BUB_MODEL=openai:gpt-5-codex" in result.stdout - - -def test_login_openai_surfaces_oauth_errors(monkeypatch) -> None: - def fake_login_openai_codex_oauth(**kwargs: object) -> auth.OpenAICodexOAuthTokens: - raise auth.CodexOAuthLoginError("bad redirect") - - monkeypatch.setattr(auth, "login_openai_codex_oauth", fake_login_openai_codex_oauth) - - result = CliRunner().invoke(_create_app(), ["login", "openai", "--manual"]) - - assert result.exit_code == 1 - assert "Codex login failed: bad redirect" in result.stderr - - -def test_login_rejects_unsupported_provider() -> None: - result = CliRunner().invoke(_create_app(), ["login", "anthropic"]) +def test_login_command_is_not_registered() -> None: + result = CliRunner().invoke(_create_app(), ["login"]) assert result.exit_code == 2 - assert "No such command 'anthropic'" in result.stderr + assert "No such command 'login'" in result.stderr def test_build_bub_requirement_uses_direct_url_json(monkeypatch) -> None: diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index ca10d3a6..8c8310d1 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -6,12 +6,12 @@ from types import SimpleNamespace import pytest -from republic import AsyncStreamEvents, StreamEvent from bub.builtin.hook_impl import AGENTS_FILE_NAME, DEFAULT_SYSTEM_PROMPT, BuiltinImpl from bub.builtin.store import FileTapeStore from bub.channels.message import ChannelMessage from bub.framework import BubFramework +from bub.runtime import AsyncStreamEvents, StreamEvent class RecordingLifespan: diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 44897f21..6455b21e 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -7,13 +7,21 @@ from types import SimpleNamespace import pytest -from republic import ToolContext -from republic.core.errors import ErrorKind -from republic.tools.executor import ToolExecutor import bub.builtin.tools as builtin_tools from bub.builtin.shell_manager import ShellManager -from bub.builtin.tools import bash, bash_output, kill_bash, quit_tool +from bub.builtin.tools import ( + bash, + bash_output, + completion_tools, + kill_bash, + model_tools, + quit_tool, + render_tools_prompt, + resolve_tool_names, +) +from bub.runtime import ErrorKind +from bub.tools import REGISTRY, Tool, ToolContext, ToolExecutor, tool def _tool_context(tmp_path, **state) -> ToolContext: @@ -24,6 +32,122 @@ def _python_shell(code: str) -> str: return f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}" +def test_completion_tools_builds_any_llm_payload() -> None: + parameters = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + sample_tool = Tool( + name="tests_sample_tool", + description="Sample tool", + parameters=parameters, + handler=lambda value: value, + ) + + assert completion_tools([sample_tool]) == [ + { + "type": "function", + "function": { + "name": "tests_sample_tool", + "description": "Sample tool", + "parameters": parameters, + }, + } + ] + + +def test_model_tools_rewrites_dotted_names_without_mutating_original() -> None: + tool_name = "tests.rename_me" + REGISTRY.pop(tool_name, None) + + @tool(name=tool_name, description="rename") + def rename_me(value: str) -> str: + return "ok" + + rewritten = model_tools([rename_me]) + + assert [item.name for item in rewritten] == ["tests_rename_me"] + assert rewritten[0].parameters == rename_me.parameters + assert rename_me.name == tool_name + assert "additionalProperties" not in rename_me.parameters + + +def test_render_tools_prompt_renders_available_tools_block() -> None: + first_name = "tests.prompt_one" + second_name = "tests.prompt_two" + REGISTRY.pop(first_name, None) + REGISTRY.pop(second_name, None) + + @tool(name=first_name, description="First tool") + def prompt_one() -> str: + return "one" + + @tool(name=second_name) + def prompt_two() -> str: + return "two" + + rendered = render_tools_prompt([prompt_one, prompt_two]) + + assert rendered == "\n- tests_prompt_one(): First tool\n- tests_prompt_two()\n" + + +def test_render_tools_prompt_includes_model_name_and_parameter_signature() -> None: + tool_name = "tests.prompt_signature" + REGISTRY.pop(tool_name, None) + + @tool(name=tool_name, description="Read a file") + def prompt_signature(path: str, offset: int = 0) -> str: + return f"{path}:{offset}" + + rendered = render_tools_prompt([prompt_signature]) + + assert rendered == "\n- tests_prompt_signature(path, offset?): Read a file\n" + + +def test_render_tools_prompt_returns_empty_string_for_empty_input() -> None: + assert render_tools_prompt([]) == "" + + +def test_resolve_tool_names_accepts_runtime_names_and_model_aliases() -> None: + dotted_name = "tests.resolve_alias" + underscored_name = "tests_with_underscore" + excluded_name = "tests.excluded_tool" + REGISTRY.pop(dotted_name, None) + REGISTRY.pop(underscored_name, None) + REGISTRY.pop(excluded_name, None) + + @tool(name=dotted_name) + def resolve_alias() -> str: + return "alias" + + @tool(name=underscored_name) + def resolve_runtime_name() -> str: + return "runtime" + + @tool(name=excluded_name) + def excluded_tool() -> str: + return "excluded" + + assert resolve_tool_names( + [" tests_resolve_alias ", " tests_with_underscore "], exclude={" tests_excluded_tool "} + ) == { + dotted_name, + underscored_name, + } + assert dotted_name not in resolve_tool_names(None, exclude={" tests_resolve_alias "}) + assert excluded_name not in resolve_tool_names(None, exclude={" tests_excluded_tool "}) + assert resolve_tool_names(None, exclude={" tests_resolve_alias "}) >= {underscored_name} + + +def test_resolve_tool_names_rejects_unknown_names() -> None: + with pytest.raises(ValueError, match="tests_missing_tool"): + resolve_tool_names([" tests_missing_tool "]) + + with pytest.raises(ValueError, match="tests_missing_tool"): + resolve_tool_names(None, exclude={" tests_missing_tool "}) + + @pytest.mark.asyncio async def test_bash_returns_stdout_for_foreground_command(tmp_path) -> None: result = await bash.run(cmd=_python_shell("print('hello')"), context=_tool_context(tmp_path)) diff --git a/tests/test_channels.py b/tests/test_channels.py index 3235f967..39cbff9f 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -7,7 +7,6 @@ from types import SimpleNamespace import pytest -from republic import StreamEvent from bub.channels.base import Channel, Interface, Lifecycle from bub.channels.cli import CliChannel @@ -16,6 +15,7 @@ from bub.channels.manager import ChannelManager from bub.channels.message import ChannelMessage from bub.channels.telegram import BubMessageFilter, TelegramChannel, TelegramMessageParser +from bub.runtime import StreamEvent from bub.turn_admission import AdmitDecision, SessionTurnController, SteeringBuffer diff --git a/tests/test_file_tape_store_entry_ids.py b/tests/test_file_tape_store_entry_ids.py index 15d50baf..da8ec666 100644 --- a/tests/test_file_tape_store_entry_ids.py +++ b/tests/test_file_tape_store_entry_ids.py @@ -1,9 +1,9 @@ from __future__ import annotations import pytest -from republic import TapeEntry from bub.builtin.store import FileTapeStore, ForkTapeStore +from bub.tape import TapeEntry @pytest.mark.asyncio diff --git a/tests/test_fork_store_merge_back.py b/tests/test_fork_store_merge_back.py index 418c83f1..4652ca53 100644 --- a/tests/test_fork_store_merge_back.py +++ b/tests/test_fork_store_merge_back.py @@ -1,10 +1,9 @@ from __future__ import annotations import pytest -from republic import TapeEntry, TapeQuery -from republic.tape import InMemoryTapeStore from bub.builtin.store import ForkTapeStore +from bub.tape import InMemoryTapeStore, TapeEntry, TapeQuery @pytest.mark.asyncio diff --git a/tests/test_framework.py b/tests/test_framework.py index ce9df20c..f39b8b03 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -9,7 +9,6 @@ import pytest import typer -from republic import AsyncStreamEvents, StreamEvent, StreamState from typer.testing import CliRunner from bub import configure @@ -20,6 +19,7 @@ from bub.configure import ensure_config from bub.framework import BubFramework from bub.hookspecs import hookimpl +from bub.runtime import AsyncStreamEvents, StreamEvent, StreamState from bub.turn_admission import AdmitDecision, SteeringBuffer, TurnSnapshot @@ -149,7 +149,7 @@ def provide_tape_store(self): assert tape_store.exit_count == 1 -def test_builtin_cli_exposes_login_and_gateway_command(write_config) -> None: +def test_builtin_cli_exposes_gateway_command(write_config) -> None: with patch.dict(os.environ, {}, clear=True): framework = BubFramework(config_file=write_config()) framework.load_hooks() @@ -160,7 +160,7 @@ def test_builtin_cli_exposes_login_and_gateway_command(write_config) -> None: gateway_result = runner.invoke(app, ["gateway", "--help"]) assert help_result.exit_code == 0 - assert "login" in help_result.stdout + assert "login" not in help_result.stdout assert "gateway" in help_result.stdout assert "onboard" in help_result.stdout assert "│ message" not in help_result.stdout diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py index d2afadc9..78088858 100644 --- a/tests/test_hook_runtime.py +++ b/tests/test_hook_runtime.py @@ -1,9 +1,9 @@ import pluggy import pytest -from republic import AsyncStreamEvents, StreamEvent from bub.hook_runtime import HookRuntime from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl +from bub.runtime import AsyncStreamEvents, StreamEvent def _runtime_with_plugins(*plugins: tuple[str, object]) -> HookRuntime: diff --git a/tests/test_settings.py b/tests/test_settings.py index 81399d89..6d3c6f63 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -37,7 +37,7 @@ def test_settings_no_keys_return_none() -> None: assert settings.api_key is None assert settings.api_base is None - assert settings.client_args is None + assert settings.client_args == {} def test_settings_provider_names_are_lowercased() -> None: @@ -124,7 +124,7 @@ def test_env_settings_override_yaml(load_config) -> None: def test_settings_client_args_can_be_disabled() -> None: settings = _settings_with_env({"BUB_CLIENT_ARGS": "null"}) - assert settings.client_args is None + assert settings.client_args == {} def test_load_settings_returns_defaults_without_loaded_config() -> None: @@ -140,11 +140,9 @@ def test_load_settings_returns_loaded_config(load_config) -> None: load_config( """ model: openrouter:openrouter/free -api_format: responses """.strip(), ) settings = load_settings() assert settings.model == "openrouter:openrouter/free" - assert settings.api_format == "responses" diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py index 14ec3472..6cf3853b 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -4,9 +4,9 @@ from unittest.mock import AsyncMock import pytest -from republic import AsyncStreamEvents, StreamEvent from bub.builtin.tools import run_subagent +from bub.runtime import AsyncStreamEvents, StreamEvent from bub.tools import REGISTRY, tool diff --git a/tests/test_tape_search_output.py b/tests/test_tape_search_output.py index 1cfa03ae..5159234a 100644 --- a/tests/test_tape_search_output.py +++ b/tests/test_tape_search_output.py @@ -3,10 +3,10 @@ from dataclasses import dataclass import pytest -from republic import ToolContext import bub.builtin.tools as builtin_tools from bub.builtin.tools import tape_search +from bub.tools import ToolContext @dataclass(frozen=True) @@ -18,12 +18,28 @@ class _FakeEntry: class _FakeTapes: def __init__(self, entries: list[_FakeEntry]) -> None: self._entries = entries - self._store = object() + + def query(self, _tape: str) -> _FakeQuery: + return _FakeQuery() async def search(self, _query: object) -> list[_FakeEntry]: return list(self._entries) +class _FakeQuery: + def query(self, _value: str) -> _FakeQuery: + return self + + def kinds(self, *_kinds: str) -> _FakeQuery: + return self + + def limit(self, _value: int) -> _FakeQuery: + return self + + def between_dates(self, _start: str, _end: str) -> _FakeQuery: + return self + + class _FakeAgent: def __init__(self, entries: list[_FakeEntry]) -> None: self.tapes = _FakeTapes(entries) diff --git a/tests/test_tools.py b/tests/test_tools.py index 927de9c3..6f770ffd 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -6,7 +6,7 @@ from loguru import logger from pydantic import BaseModel -from bub.tools import REGISTRY, model_tools, render_tools_prompt, resolve_tool_names, tool +from bub.tools import REGISTRY, tool class EchoInput(BaseModel): @@ -86,94 +86,3 @@ def direct_call(value: str) -> str: assert REGISTRY[tool_name] is direct_tool assert await REGISTRY[tool_name].run("hello") == "HELLO" - - -def test_model_tools_rewrites_dotted_names_without_mutating_original() -> None: - tool_name = "tests.rename_me" - REGISTRY.pop(tool_name, None) - - @tool(name=tool_name, description="rename") - def rename_me(value: str) -> str: - return "ok" - - rewritten = model_tools([rename_me]) - - assert [item.name for item in rewritten] == ["tests_rename_me"] - assert rewritten[0].parameters == rename_me.parameters - assert rename_me.name == tool_name - assert "additionalProperties" not in rename_me.parameters - - -def test_render_tools_prompt_renders_available_tools_block() -> None: - first_name = "tests.prompt_one" - second_name = "tests.prompt_two" - REGISTRY.pop(first_name, None) - REGISTRY.pop(second_name, None) - - @tool(name=first_name, description="First tool") - def prompt_one() -> str: - return "one" - - @tool(name=second_name) - def prompt_two() -> str: - return "two" - - rendered = render_tools_prompt([prompt_one, prompt_two]) - - assert rendered == "\n- tests_prompt_one(): First tool\n- tests_prompt_two()\n" - - -def test_render_tools_prompt_includes_model_name_and_parameter_signature() -> None: - tool_name = "tests.prompt_signature" - REGISTRY.pop(tool_name, None) - - @tool(name=tool_name, description="Read a file") - def prompt_signature(path: str, offset: int = 0) -> str: - return f"{path}:{offset}" - - rendered = render_tools_prompt([prompt_signature]) - - assert rendered == "\n- tests_prompt_signature(path, offset?): Read a file\n" - - -def test_render_tools_prompt_returns_empty_string_for_empty_input() -> None: - assert render_tools_prompt([]) == "" - - -def test_resolve_tool_names_accepts_runtime_names_and_model_aliases() -> None: - dotted_name = "tests.resolve_alias" - underscored_name = "tests_with_underscore" - excluded_name = "tests.excluded_tool" - REGISTRY.pop(dotted_name, None) - REGISTRY.pop(underscored_name, None) - REGISTRY.pop(excluded_name, None) - - @tool(name=dotted_name) - def resolve_alias() -> str: - return "alias" - - @tool(name=underscored_name) - def resolve_runtime_name() -> str: - return "runtime" - - @tool(name=excluded_name) - def excluded_tool() -> str: - return "excluded" - - assert resolve_tool_names( - [" tests_resolve_alias ", " tests_with_underscore "], exclude={" tests_excluded_tool "} - ) == { - dotted_name, - underscored_name, - } - assert dotted_name not in resolve_tool_names(None, exclude={" tests_resolve_alias "}) - assert excluded_name not in resolve_tool_names(None, exclude={" tests_excluded_tool "}) - assert resolve_tool_names(None, exclude={" tests_resolve_alias "}) >= {underscored_name} - - -def test_resolve_tool_names_rejects_unknown_names() -> None: - with pytest.raises(ValueError, match="tests_missing_tool"): - resolve_tool_names([" tests_missing_tool "]) - - with pytest.raises(ValueError, match="tests_missing_tool"): - resolve_tool_names(None, exclude={" tests_missing_tool "}) diff --git a/uv.lock b/uv.lock index 7701533f..1fccadcf 100644 --- a/uv.lock +++ b/uv.lock @@ -152,7 +152,7 @@ wheels = [ [[package]] name = "any-llm-sdk" -version = "1.13.0" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, @@ -163,9 +163,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/4b/dea4eeeb5e4e3e55fa6b37a7ec033a18a2527aabf565bd598071d6308fbd/any_llm_sdk-1.13.0.tar.gz", hash = "sha256:967c5f4dd099f5f6cc9673f2888d5550f6e821d25341d31133a05741c1ce903e", size = 153753, upload-time = "2026-03-23T10:27:29.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/19/1fd535059dbb55b68251586be8d8aa7253f3abb3b4d4aa4a9fa9784334da/any_llm_sdk-1.17.0.tar.gz", hash = "sha256:429d59eac71e2dcdeff3848cf55a5ae71dfde012496f0c932d6c302b6ea11ca1", size = 149396, upload-time = "2026-06-05T09:34:00.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/a1/0106667efdf870595981a0fc3e807675d1f8ded8dc8c20e936e6c42bcd95/any_llm_sdk-1.13.0-py3-none-any.whl", hash = "sha256:7e456a843cec249ae1cb3a498aa89df5baff8aa62d76a24718babb3b8a51e495", size = 216557, upload-time = "2026-03-23T10:27:28.306Z" }, + { url = "https://files.pythonhosted.org/packages/42/ed/e2891f42247892e3358db9327bf2bb3fb7a0f046c4fb891c4d22dbdc26a1/any_llm_sdk-1.17.0-py3-none-any.whl", hash = "sha256:634d1e1413bdd2c593aa8eec0d32c4cad868c681b31da4f78e36e239833e9c76", size = 198742, upload-time = "2026-06-05T09:33:59.052Z" }, ] [[package]] @@ -190,18 +190,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "authlib" -version = "1.6.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" }, -] - [[package]] name = "bub" source = { editable = "." } @@ -218,9 +206,9 @@ dependencies = [ { name = "python-telegram-bot" }, { name = "pyyaml" }, { name = "rapidfuzz" }, - { name = "republic" }, { name = "rich" }, { name = "typer" }, + { name = "typing-extensions" }, ] [package.optional-dependencies] @@ -254,9 +242,9 @@ requires-dist = [ { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "rapidfuzz", specifier = ">=3.14.3" }, - { name = "republic", specifier = ">=0.5.4" }, { name = "rich", specifier = ">=13.0.0" }, { name = "typer", specifier = ">=0.9.0" }, + { name = "typing-extensions", specifier = ">=4.13.0" }, ] provides-extras = ["logfire"] @@ -289,63 +277,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.7" @@ -440,59 +371,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "cryptography" -version = "46.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -1386,15 +1264,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.12.5" @@ -1689,21 +1558,6 @@ 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.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "any-llm-sdk" }, - { name = "authlib" }, - { name = "httpx" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/71/4766e21bd9aff9edc806556c92f208bc14aa7d708f8f4d3941bc5ebe6115/republic-0.5.8.tar.gz", hash = "sha256:4b8a3cad45080557c7d34b49cfd2d5d24d01aaba35d27ac19a3bf510674b77e5", size = 157246, upload-time = "2026-05-10T14:54:40.281Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/9c/c453a54b8678e5cd6d6db3d8730a4fad86e7cc558d0d9cd6b046ee61013b/republic-0.5.8-py3-none-any.whl", hash = "sha256:e323881df8239fb23b4cce3f7323aee88dae257fa4a47e625090d711545919a0", size = 62805, upload-time = "2026-05-10T14:54:38.895Z" }, -] - [[package]] name = "requests" version = "2.33.1"