From c69cc647cc80f224fdb39f8d70a758cc77dc79ee Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 21 Mar 2026 12:48:59 +0800 Subject: [PATCH 1/3] feat(hooks): new hook: build_tape_context Signed-off-by: Frost Ming --- src/bub/builtin/agent.py | 9 ++++----- src/bub/builtin/context.py | 4 ++-- src/bub/builtin/hook_impl.py | 6 ++++++ src/bub/framework.py | 5 ++++- src/bub/hookspecs.py | 7 ++++++- tests/test_builtin_agent.py | 3 +-- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 964513b2..a6aa7d7f 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -15,10 +15,9 @@ from typing import Any from loguru import logger -from republic import LLM, AsyncTapeStore, ToolAutoResult, ToolContext +from republic import LLM, AsyncTapeStore, TapeContext, ToolAutoResult, ToolContext from republic.tape import InMemoryTapeStore, Tape -from bub.builtin.context import default_tape_context from bub.builtin.settings import AgentSettings from bub.builtin.store import ForkTapeStore from bub.builtin.tape import TapeService @@ -46,7 +45,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) + llm = _build_llm(self.settings, tape_store, self.framework.build_tape_context()) return TapeService(llm, self.settings.home / "tapes", tape_store) async def run( @@ -265,7 +264,7 @@ def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome: return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}") -def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore) -> LLM: +def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore, tape_context: TapeContext) -> LLM: from republic.auth.openai_codex import openai_codex_oauth_resolver return LLM( @@ -276,7 +275,7 @@ def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore) -> LLM: api_key_resolver=openai_codex_oauth_resolver(), tape_store=tape_store, api_format=settings.api_format, - context=default_tape_context(), + context=tape_context, verbose=settings.verbose, ) diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py index 15d065d9..bbe23521 100644 --- a/src/bub/builtin/context.py +++ b/src/bub/builtin/context.py @@ -9,10 +9,10 @@ from republic import TapeContext, TapeEntry -def default_tape_context(state: dict[str, Any] | None = None) -> TapeContext: +def default_tape_context() -> TapeContext: """Return the default context selection for Bub.""" - return TapeContext(select=_select_messages, state=state or {}) + return TapeContext(select=_select_messages, state={}) def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[dict[str, Any]]: diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 6f563044..91c52914 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -6,9 +6,11 @@ import typer from loguru import logger +from republic import TapeContext from republic.tape import TapeStore from bub.builtin.agent import Agent +from bub.builtin.context import default_tape_context from bub.channels.base import Channel from bub.channels.message import ChannelMessage, MediaItem from bub.envelope import content_of, field_of @@ -187,3 +189,7 @@ def provide_tape_store(self) -> TapeStore: from bub.builtin.store import FileTapeStore return FileTapeStore(directory=self.agent.settings.home / "tapes") + + @hookimpl + def build_tape_context(self) -> TapeContext: + return default_tape_context() diff --git a/src/bub/framework.py b/src/bub/framework.py index a00c029d..268d97ba 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -9,7 +9,7 @@ import pluggy import typer from loguru import logger -from republic import AsyncTapeStore +from republic import AsyncTapeStore, TapeContext from republic.tape import TapeStore from bub.envelope import content_of, field_of, unpack_batch @@ -209,3 +209,6 @@ def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) -> for result in reversed(self._hook_runtime.call_many_sync("system_prompt", prompt=prompt, state=state)) if result ) + + def build_tape_context(self) -> TapeContext: + return self._hook_runtime.call_first_sync("build_tape_context") diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py index d22c50ca..237a0c16 100644 --- a/src/bub/hookspecs.py +++ b/src/bub/hookspecs.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any import pluggy -from republic import AsyncTapeStore +from republic import AsyncTapeStore, TapeContext from republic.tape import TapeStore from bub.types import Envelope, MessageHandler, State @@ -93,3 +93,8 @@ def provide_tape_store(self) -> TapeStore | AsyncTapeStore: def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: """Provide a list of channels for receiving messages.""" raise NotImplementedError + + @hookspec(firstresult=True) + def build_tape_context(self) -> TapeContext: + """Build a tape context for the current session, to be used to build context messages.""" + raise NotImplementedError diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 49ee8f7d..df72c188 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -25,12 +25,11 @@ def __init__(self, *args: object, **kwargs: object) -> None: monkeypatch.setattr(agent_module, "LLM", FakeLLM) monkeypatch.setattr(openai_codex, "openai_codex_oauth_resolver", lambda: resolver) - monkeypatch.setattr(agent_module, "default_tape_context", lambda: "ctx") settings = AgentSettings(model="openai:gpt-5-codex", api_key=None, api_base=None) tape_store = object() - agent_module._build_llm(settings, tape_store) + agent_module._build_llm(settings, tape_store, "ctx") assert captured["args"] == ("openai:gpt-5-codex",) assert captured["kwargs"]["api_key"] is None From 74d767deebcbc56003feff00becaa478fb14594c Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 21 Mar 2026 13:19:41 +0800 Subject: [PATCH 2/3] fix(agent, context): update state handling in TapeContext and improve default context initialization Signed-off-by: Frost Ming --- src/bub/builtin/agent.py | 4 ++-- src/bub/builtin/context.py | 2 +- tests/test_builtin_agent.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index a6aa7d7f..8b9c1d36 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -8,7 +8,7 @@ import shlex import time from collections.abc import Collection -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime from functools import cached_property from pathlib import Path @@ -61,7 +61,7 @@ async def run( if not prompt: return "error: empty prompt" tape = self.tapes.session_tape(session_id, workspace_from_state(state)) - tape.context.state.update(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) diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py index bbe23521..a58248b8 100644 --- a/src/bub/builtin/context.py +++ b/src/bub/builtin/context.py @@ -12,7 +12,7 @@ def default_tape_context() -> TapeContext: """Return the default context selection for Bub.""" - return TapeContext(select=_select_messages, state={}) + return TapeContext(select=_select_messages) def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[dict[str, Any]]: diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index df72c188..407c0ddd 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -7,7 +7,7 @@ import pytest import republic.auth.openai_codex as openai_codex -from republic import ToolAutoResult +from republic import TapeContext, ToolAutoResult import bub.builtin.agent as agent_module from bub.builtin.agent import Agent @@ -80,7 +80,7 @@ def __init__(self, fork_capture: _ForkCapture) -> None: def session_tape(self, session_id: str, workspace: Any) -> MagicMock: tape = MagicMock() tape.name = "test-tape" - tape.context.state = {} + tape.context = TapeContext(state={}) async def fake_run_tools_async(**kwargs: Any) -> ToolAutoResult: self.run_tools_model = kwargs.get("model") From b7412bf149975de2d9cbbbef193d7e6bc0c38295 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 21 Mar 2026 13:23:56 +0800 Subject: [PATCH 3/3] feat(agent): log tape events at the start of the run loop Signed-off-by: Frost Ming --- src/bub/builtin/agent.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 8b9c1d36..69863560 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -122,6 +122,16 @@ async def _agent_loop( ) -> str: next_prompt: str | list[dict] = prompt display_model = model or self.settings.model + await self.tapes.append_event( + tape.name, + "loop.start", + { + "model": display_model, + "prompt": prompt, + "allowed_skills": list(allowed_skills) if allowed_skills else None, + "allowed_tools": list(allowed_tools) if allowed_tools else None, + }, + ) for step in range(1, self.settings.max_steps + 1): start = time.monotonic() logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model)