跳转到内容

Python SDK

bub.core 是 Bub 的 agent 内核。它的 Agent 运行 agent loop(调用模型、执行模型请求的工具、重复直到模型给出回答), 所用的组件都由你提供:工具、system prompt section 和 tape store,以及传给 agent 或每次运行的 Republic 模型。 它不读取 Bub 的 settings,也不加载插件,因此可以嵌入任何应用。

Bub 自身的运行时也构建在这个内核之上:bub.builtin.BubAgent 在其上加入 settings、插件钩子、skills、命令和 steering。

在 Python 3.12+ 项目中安装 Bub:

uv add bub

下文描述的是当前源码中的 API;请使用包含这些接口的发布版本,或安装当前源码。

将以下内容保存为 sdk_example.py:

import asyncio
from pathlib import Path

import republic

from bub.core import Agent, Tool
from bub.core.store import FileTapeStore


async def lookup_order(order_id: str) -> dict[str, str]:
    """Look up the status of an order."""
    # 将这个演示返回值替换为你应用中的订单服务。
    return {"order_id": order_id, "status": "shipped"}


def create_agent() -> Agent:
    root = Path(__file__).resolve().parent
    return Agent(
        store=FileTapeStore(root / "sessions"),
        tools=[Tool.from_callable(lookup_order)],
        system_prompt=[
            "You are an order assistant. Reply directly to the user.",
            "Use lookup_order to check order status before answering.",
        ],
    )


async def main() -> None:
    agent = create_agent()
    # 任意 Republic "provider:model"。API key 从 REPUBLIC_OPENAI_API_KEY 读取。
    async with republic.get_model("openai:gpt-5-mini") as model:
        async with agent.run(
            "Where is order A123?", model=model, options={"max_tokens": 4096}, tape="customer-42"
        ) as stream:
            async for event in stream:
                if event.kind == "text":
                    print(event.data.get("delta", ""), end="", flush=True)
                elif event.kind == "error":
                    raise RuntimeError(str(event.data.get("message", "Agent failed")))
        print()


if __name__ == "__main__":
    asyncio.run(main())

用 REPUBLIC_OPENAI_API_KEY=... uv run python sdk_example.py 运行。使用同一个 tape 再次运行,会继续保存在 sessions/ 下的对话。

Agent(...) 参数 行为
model 未传入模型的运行所用的默认模型;见 模型。默认 None,即每次运行都需传入。
store TapeStore 或 AsyncTapeStore。默认 InMemoryTapeStore()。
tools 模型可调用的 Tool 对象,按名字保存在 agent.tools 中;也可传入由 provider 自行执行的 Republic 内置工具(如 republic.tools.WebSearch()),保存在 agent.builtin_tools 中,随每次模型调用发送。
system_prompt 一个或一组 system prompt section;见 System prompt。
context 决定哪些 tape 条目进入模型上下文的 TapeContext。默认 default_tape_context()。
sidecars 挂载到每条 tape 上的 TapeSidecar 能力。
max_steps 一次运行中最多的模型调用次数。
name 在 invoke_agent trace 中上报的 agent 名称。默认 "agent"。
command_prefix 标记命令 prompt 的前缀,例如 ",";见 命令。默认 None,即不启用命令。

每次运行调用传入的模型,未传入时调用 agent 的 model,因此同一个 agent 可以用不同模型服务不同的运行;两者都没有时, 需要调用模型的运行会抛出 ValueError。传入 republic.get_model() 得到的 Republic chat model 即可;它的生命周期归你管理,请在使用它的运行外层用 async with 打开(或自行关闭)。凭据和端点属于 Republic 的客户端参数:

model = republic.get_model("openai:gpt-5-mini", api_key="...", api_base="https://example.test/v1")
async with agent.run(prompt, model=model, options={"max_tokens": 4096, "temperature": 0.2}, tape="t") as stream:
    ...

agent = Agent(model=model)  # 未传入模型的运行调用它
async with agent.run(prompt, tape="t") as stream:
    ...

options 是随本次运行每次模型调用发送的 republic.ChatOptions;循环会加入本步的工具。BeforeLlmCall 处理器可以把一次调用重定向到其他 provider:model:同一 provider 的模型复用运行模型的客户端,其他 provider 使用从 Republic 环境变量配置的客户端。

如果需要自己的策略,例如 fallback、按 provider 的凭据或整次调用的超时,可以改为传入实现 bub.core.ModelClient 的对象:提供 spec(默认的 provider:model)、默认 max_tokens(或 None),以及 open_stream(messages, spec=..., options=...)——一个产出 provider 名称和 republic.Stream 的异步上下文管理器。 Bub 自己的 bub.builtin.model.BubModel 就是这样的客户端。

Tool.from_callable() 从类型注解生成参数 schema,从 docstring 生成描述,不会注册到全局。bub.core 中的 @tool 装饰器生成同样的 Tool,同时把它加入 Bub 的全局注册表,Bub 自己的 agent 会取其快照。

以 context=True 创建的工具会收到 ToolContext,其中包含本次运行的 tape、turn state、run_id 和事件总线:

from bub.core import ToolContext


def remember(note: str, *, context: ToolContext) -> str:
    """Remember a note for this conversation."""
    context.state.setdefault("notes", []).append(note)
    return "noted"


agent.tools["remember"] = Tool.from_callable(remember, context=True)

工具出错时,错误会作为工具结果返回给模型,而不会结束运行。名字中带 . 的工具会以 _ 替换后提供给模型 (例如 fs.read 变为 fs_read)。

system prompt 是有序的 section 列表,每个模型步骤渲染一次,以空行连接。section 可以是文本,也可以是接收 PromptContext 并返回文本的同步或异步函数;返回空串或 None 时跳过该 section。agent.system_prompt.add(section) 追加一个 section 并返回它,因此也可以用作装饰器;agent.system_prompt.remove(section) 将其移除。

from bub.core import PromptContext


@agent.system_prompt.add
def tool_list(context: PromptContext) -> str:
    names = ", ".join(tool.name for tool in context.tools)
    return f"Tools available in this step: {names}."


@agent.system_prompt.add
async def customer(context: PromptContext) -> str | None:
    if (user := context.state.get("user")) is None:
        return None
    return f"You are talking to {user}."

context 包含本步的 prompt(工具调用后继续时为空)、本步模型可见的 tools、本步 model 的 provider:model、 本次运行的 tape 与 state,以及 agent(并提供 available_tools 快捷属性)。

每次运行使用 agent store 中以 tape= 命名的 tape。复用同一个名字即可继续对话;新对话使用新名字。运行写入的是该 tape 的 fork,运行的流关闭时合并回去;传入 merge_back=False 则丢弃。

state= 是本次运行可修改的 turn state:一个由 system prompt section、工具和事件处理器共享的字典。agent 不会持久化它; 需要保留的内容请放在你的应用中或写到 tape 上。

async with agent.run("Any news on A123?", model=model, tape="customer-42", state={"user": "Ada"}) as stream:
    ...

tape = agent.tape("customer-42")
messages = await tape.read_messages()  # 以 Republic 消息表示的对话。
await tape.handoff(name="summary", state={"summary": "Ada asked about order A123."})

agent.tape(name) 返回某个对话的 tape,供运行之外使用;它直接写入 store。默认情况下模型上下文是该 tape 最近一个 anchor 之后记录的对话,因此 handoff() 会开始一个更短的上下文。如果某次调用因上下文过长而失败,运行会自动 handoff 一次并重试该 prompt。

设置了 command_prefix 后,以该前缀开头的文本 prompt 会作为命令执行,而不走 agent loop,也不会调用模型。name args... 调用名为 name 的工具,先传位置参数(按顺序对应工具的参数),再传 key=value 参数;其他命令在 agent 有 bash 工具时交给它执行。输出作为 本次运行的 text 和 final 事件返回;同时发出 CommandFinished 事件报告这次命令,tape 会将其记录为 command 事件。

agent = Agent(tools=[Tool.from_callable(lookup_order)], command_prefix=",")

async with agent.run(",lookup_order order_id=A123", model=model, tape="customer-42") as stream:
    async for event in stream:
        print(event.kind, event.data)  # 依次是 text 和 final,内容为工具渲染后的结果。

首尾空白会被忽略,且只去掉一个前缀;只有前缀时会抛出 ValueError。前缀必须非空且不含空白。带媒体的 prompt 不会作为命令。 命令调用工具时和模型调用工具一样发出 BeforeToolCall 和 AfterToolCall,因此工具处理器同样作用于它们;其 ToolContext 的 command 为真。 agent.run_command(tape, line) 可以在已打开的 tape 上直接执行命令。

循环通过 bub.core.events 中的事件报告进度;注册处理器即可观察或干预它。

from bub.core import AfterToolCall, BeforeToolCall, StepFinished, ToolCallDecision


@agent.events.on(BeforeToolCall)
def guard(event: BeforeToolCall) -> None:
    if event.call.tool == "lookup_order" and not event.call.arguments.get("order_id"):
        event.decision = ToolCallDecision.deny("order_id is required")


@agent.events.on(AfterToolCall)
async def audit(event: AfterToolCall) -> None:
    print("tool", event.call.tool, event.result.duration_ms, "ms")


agent.events.on(StepFinished, lambda event: print("step", event.step, event.status))
事件 时机 处理器可以
RunStarted 一次运行开始 观察模型 spec、prompt 和运行 metadata
StepStarted / StepFinished 一个模型步骤开始 / 以某个状态结束 观察
BeforeLlmCall 每次模型调用之前 替换 event.request(模型、消息、max_tokens),或设置 event.decision = LlmCallDecision.finish(text) 跳过调用
AfterLlmCall 每次模型调用完成或失败之后 观察文本、工具调用、用量和错误
BeforeToolCall 每次工具调用之前 替换 event.call 以修改参数,或设置 ToolCallDecision.replace(result) / deny(message)
AfterToolCall 每次工具调用之后,包括被拒绝和被替换的调用 赋值 event.result.result 以改变模型看到的结果
ChatCompleted 一个步骤的对话已可记录 观察
CommandFinished 一个命令 prompt 执行完毕(见 命令) 观察命令、状态和输出

agent.events.on(EventType, handler) 注册同步或异步处理器并返回它;@agent.events.on(EventType) 以装饰器形式完成同样的注册, agent.events.off(EventType, handler) 将其移除。为 AgentEvent 注册的处理器接收所有事件。处理器按注册顺序执行; 在 Before* 事件上设置 decision 会停止向后续处理器分发。处理器抛出的异常会传给本次运行。

每个事件都带有本次运行的 state;循环事件还带有它的 tape。记录本身也是一个处理器:最先注册的 TapeRecorder 把对话和 loop.* 事件写入本次运行的 tape。

可以定义自己的事件,让工具、处理器和应用通过同一条事件总线协作。事件是继承 AgentEvent 的 keyword-only dataclass (继承 TapeEvent 则还会携带本次运行的 tape)。用 await events.emit(event) 发出事件:它会把事件分发给为该类型或其 父类型注册的处理器,并返回该事件。在工具中,本次运行的事件总线是 context.events:

from dataclasses import dataclass

from bub.core import AgentEvent, ToolContext


@dataclass(kw_only=True)
class OrderLookedUp(AgentEvent):
    order_id: str
    status: str


@dataclass(kw_only=True)
class RefundRequested(AgentEvent):
    order_id: str
    approved: bool | None = None

    @property
    def stopped(self) -> bool:
        # 一旦有处理器做出决定,后续处理器就不再执行。
        return self.approved is not None


async def lookup_order(order_id: str, *, context: ToolContext) -> dict[str, str]:
    """Look up the status of an order and whether it can be refunded."""
    result = {"order_id": order_id, "status": "shipped"}
    if context.events is not None:  # 工具在 agent 之外运行时为 None。
        await context.events.emit(OrderLookedUp(state=context.state, **result))
        request = await context.events.emit(RefundRequested(state=context.state, order_id=order_id))
        result["refundable"] = str(bool(request.approved))
    return result


agent.tools["lookup_order"] = Tool.from_callable(lookup_order, context=True)


@agent.events.on(OrderLookedUp)
async def notify(event: OrderLookedUp) -> None:
    print("order", event.order_id, "is", event.status, "for", event.state.get("user"))


@agent.events.on(RefundRequested)
def approve(event: RefundRequested) -> None:
    event.approved = event.state.get("user") == "Ada"

每个事件都带有发出它的那次运行的 state,因此请传入 context.state(在其他处理器中则传 event.state)。处理器可以修改 可变事件,发出方从返回的事件中读取结果;覆盖 stopped 可以在某个处理器做出决定后停止分发。应用也可以直接用 await agent.events.emit(...) 发出事件。自定义事件不会写入 tape;需要记录时,请在处理器中自行追加,例如在工具里调用 await context.tape.append_event("order.looked_up", {...}),或使用 TapeEvent 并在处理器中调用 event.tape.append_event(...)。

agent.run() 是一个异步上下文管理器:进入时开始运行,产出的流在迭代时驱动循环。事件包括 text、reasoning、tool_call、 tool_result、usage、error 和 final。final 事件结束的是一个模型步骤;流被消费完时整个运行结束。请检查 error 事件并处理迭代中抛出的异常。流对象还提供 error 和 usage。

离开 async with 块时会关闭流,提前 break 或抛出异常时也一样:模型流停止、tape fork 合并回去、trace span 结束。

async with agent.run("Check order A123", model=model, tape="customer-42") as stream:
    async for event in stream:
        if event.kind == "tool_call":
            break  # 运行在这里停止,已记录的内容会保留。

如果要把流交给其他代码(例如稍后才消费它的框架),用相同参数调用 await agent.start(...)。它返回流但不负责关闭; 消费方必须把它消费完或调用 aclose()。

空 prompt 会直接得到 error: empty prompt,不会打开 tape。配置了 tracing 时,运行会记录为 invoke_agent {name} span; 见 Observability。

如果要在自己管理的 tape 上驱动循环,用 agent.session(name, state=...) 打开它,再调用 agent.run_on(tape, prompt, model=model);只跑一个模型步骤时用 agent.run_once(prompt, model=model, tape=tape)。 两者都接受 options。

继承 Agent 可以改变每一步的准备方式。每个方法都会收到本次运行的 tape,其 context.state 就是 turn state:

方法 默认行为 用途
prepare_tools(tape, prompt) 全部工具 选择下一步模型可见的工具。
pending_messages(tape) 无 返回运行期间到达的用户消息。
has_pending_messages(tape) False 有消息等待时让运行再继续一步。
continue_prompt(tape, prompt, state) None 在工具调用后返回下一步的用户消息。
from bub.core import Tape


class TriageAgent(Agent):
    async def prepare_tools(self, tape: Tape, prompt):
        tools = await super().prepare_tools(tape, prompt)
        if tape.context.state.get("read_only"):
            tools = [tool for tool in tools if tool.name == "lookup_order"]
        return tools

要通过 HTTP 提供 agent 服务,请参阅 用 FastAPI 提供 Bub agent 服务。