跳转到内容

工具

本指南展示如何定义一个 Bub 工具 —— 一个可在轮次内被模型调用的 Python 函数 —— 并确保你的插件确实注册了它。

  • 一个已经接入 bub 入口点组的插件包(见插件)。
  • 模块中可用的 from bub import tool 标记。

用 @tool 装饰任意函数。装饰器会构造一个 Tool 对象,套上计时日志,并写入中央 REGISTRY:

from bub import tool


@tool
def add(a: int, b: int) -> int:
    """Add two integers and return the sum."""
    return a + b

工具名默认为函数名。可用 @tool(name="math.add") 覆写,也可传 description= 控制它在模型提示中的呈现。带点的 registry 名会保留给运行时查找和逗号命令使用,但面向模型的工具名会把点替换为下划线(math.add 会变成 math_add)。支持异步函数 —— 包装器会 await 它们。

工具应返回结构化数据 —— dict、TypedDict、列表或 Pydantic 模型 —— 而不是预先排版好的文本。例外是标记为 preserve=True 的工具(见 Code mode)和只作为逗号命令的工具(agent_use=False):代码永远不会调用它们,因此返回纯文本。通过 renderer= 控制如何把结果转换成模型读取的纯文本:

from typing import TypedDict

from bub import tool


class Order(TypedDict):
    id: str
    status: str


@tool(name="orders.get", renderer=lambda order: f"order {order['id']}: {order['status']}")
def get_order(order_id: str) -> Order:
    """Look up an order by id."""
    return {"id": order_id, "status": "shipped"}

Tool.render(result) 会调用 renderer;未提供时,字符串原样返回,其他值序列化为 JSON。模型直接调用工具时,执行器会在 after_tool_call hook 和写入 tape 之前完成渲染,因此 hook 看到的就是模型将读到的文本。是否渲染由 ToolExecutor 决定:面向模型的执行器会渲染,而 run_code 使用的 ToolExecutor(render=False) 原样返回结构化结果。

Code mode 让模型在 Python 中调用工具,而不必一次只发起一个工具调用。它是按 session 生效的开关:发送逗号命令 ,code_mode enable=true(或 ,code_mode enable=false)来开启或关闭,从下一个 turn 起生效,并在重启后保留。SDK 调用方也可以直接设置 state["code_mode"] = True。在开启 code mode 的 turn 中,只要 run_code 在允许的工具之内:

  • 模型只直接看到标记为 preserve=True 的工具(bash、bash.output、bash.kill、fs.read、fs.write、fs.edit)以及 run_code(code: str) -> str。preserve 工具只能直接调用,不会出现在 tools.* 中。
  • 其他允许的工具在 run_code 中都是异步函数,名称使用面向模型的形式(tape.info 对应 await tools.tape_info())。支持顶层 await,可以用 asyncio.gather 并发调用。调用只接受关键字参数,照常经过 before_tool_call/after_tool_call hook,返回结构化结果,失败时抛出异常。
  • Bub 会在 ~/.bub/codemode/ 下为允许的非 preserve 工具生成一个 Python stub 文件,每个工具对应一个函数,包含参数类型、返回类型和 docstring,并把文件路径写入 system prompt。只要工具集合不变,同一个 session 中这个路径保持不变。
  • run_code(code, timeout_seconds=120) 返回代码写到 stdout 的内容。未捕获的异常会变成工具错误,错误详情中包含已打印的输出和 traceback。

如果 run_code 不在允许范围内(例如被 allowed_tools 限制的 subagent),该 turn 会退回直接调用工具的方式。给自己的工具声明 preserve=True 可以让它保持直接可调用。嵌套调用时 ToolContext.code_mode(hook 中通过 call.context.code_mode 读取)为 True,结果不会被渲染或 spill。

run_code 会把代码和一个 call_tool 回调一起交给当前 session 执行环境的 run_code 执行。工具调用始终经这个回调在宿主上执行,所以 hook 仍能看到每一次调用。超过 timeout_seconds 时会取消执行环境里的执行。builtin 的 LocalEnvironment 每次调用都会启动一个新的 Python 进程(使用 sys.executable,工作目录为 workspace),工具调用以 JSON 行的形式经进程的 stdin/stdout 转发,因此参数和结果都必须能 JSON 序列化。代码执行完、出错或超时后,它会杀掉该进程及其启动的所有子进程。这个进程直接跑在宿主上,权限与 bash 相同,因此只应在允许使用 bash 的场景下开启 code mode。stub 中的结果类型来自工具函数的返回注解(手动构造的工具则来自 Tool.output_schema)。

bash、bash.output、bash.kill 和 fs.* 不直接操作宿主机,而是在当前 session 的 Environment(见 bub.environment)里启动进程、读写文本文件,run_code 的代码也在这里执行。工具本身的行为(后台 shell、超时、渲染、hook)都留在宿主上,所以一个执行环境只需要实现几个操作:

  • spawn(command, *, cwd=None, env=None) 返回一个 Process:传字符串时经 shell 执行,传序列时作为参数列表直接执行。进程提供 stdout/stderr 流、write_stdin、wait 和 signal(kill=...),其中 signal 必须作用到整个进程树。
  • read_text(path) 和 write_text(path, content) 读写执行环境内的文件。
  • workspace 是执行环境内的工作目录,resolve_path 基于它解析相对路径。
  • run_code(code, *, tools, call_tool, write) 执行 Python 代码,代码里的 await tools.<name>(**kwargs) 会调用 call_tool(name, kwargs)。代码写到 stdout 的内容要实时交给 write;代码抛异常时抛出 CodeFailed;被取消时要停止代码。具体怎么执行由执行环境决定。有 Python 解释器的执行环境可以直接复用 bub.builtin.codemode.code_runner.run_code_in_subprocess,它会用 spawn 启动进程来执行代码。
  • close() 释放执行环境。

通过 provide_environment(session_id, workspace) hook 为每个 session 提供执行环境。Bub 在 session 的第一个 turn 调用它,把结果放进 state["_runtime_environment"],并在 framework.running() 退出时关闭。Bub 的 builtin hook 提供 LocalEnvironment(见 bub.builtin.environment),直接在宿主上运行;插件提供的实现会优先于它。自定义工具可以用 bub.builtin.environment.environment_from_state(context.state) 使用同一个环境。

REGISTRY 位于 bub.tools:

# 来自 src/bub/tools.py
REGISTRY: dict[str, Tool] = {}

每一次 @tool 调用都会在导入时修改这个字典。Bub 内置代理在为模型组装工具列表时从 REGISTRY 读取。没有独立的注册步骤。

由于注册是导入时副作用,定义 @tool 函数的模块必须在 Bub 询问工具之前真的被导入。在插件的入口模块里加入:

# bub_myplugin/plugin.py
from bub import hookimpl

from . import tools  # noqa: F401  —— 触发 @tool 注册

如果缺少这个导入,工具模块永远不会运行,REGISTRY 里也不会出现条目,模型自然看不到这个工具。

内置运行时也走相同模式 —— 见 BuiltinImpl.__init__,它出于同样原因导入 bub.builtin.tools。

二者都是 Bub 内的可调用单元,但操作者不同:

表面 调用者 触发方式
工具 模型 一次轮次中的工具调用消息
逗号命令 人类 以逗号开头的入站文本

像 ,skill name=hello 这样的一行就是逗号命令 —— 由操作者键入,Bub 内置的 build_prompt 会把消息标记为 kind="command",从而绕过模型。工具则相反,由模型在产出工具调用事件时自行触发。

操作者面与模型面的完整划分见表面。

工具不会出现在 bub hooks 中,但可以用一行 Python 验证注册:

uv run python -c "import bub_myplugin.plugin; from bub.tools import REGISTRY; print(sorted(REGISTRY))"

输出应包含你的工具名。然后运行一次让模型调用它的轮次。

  • 钩子 —— 把工具与钩子结合构造完整插件
  • 技能 —— 与工具一起打包面向模型的指令
  • 表面 —— 操作者面 vs 模型面