前两篇分别梳理了 OpenHarness 的 Query Loop 和 Agent Loop 生命周期。这一篇继续跟着模型返回的 往下走:如何从零写一个工具、挂到注册表、让 LLM 调用它?工具执行时权限和 Hooks 又怎样介入?

一个 tool = 名字 + 描述 + 输入模型 + execute()

每个工具 = 名字 + 描述 + Pydantic 输入模型 + async execute()。

注册进 后,工具的 Schema 会发给 LLM;LLM 返回 时,引擎查表、校验、鉴权、执行,再把结果写回对话。

例Example text
LLM 看到工具列表(to_api_schema)
        │
        ▼
tool_use { name, input }
        │
        ▼
registry.get(name) → model_validate(input)
        │
        ▼
PRE_TOOL_USE → PermissionChecker → execute() → POST_TOOL_USE
        │
        ▼
ToolResult → 写回对话 → 下一轮 LLM

模型不会直接执行函数。它只会提出“我想调用这个工具,并传入这些参数”;是否找到工具、参数是否合法、是否要询问用户、是否允许产生副作用,都由 Runtime 决定。

图Fig. 一次工具调用要过几道关交互演示 · 本地模拟Interactive · simulated locally
工具调用场景Tool call scenario

共 9 步9 steps

参数有效、Hook 放行、权限允许,工具返回结果。

  1. ToolRegistry.to_api_schema()向 LLM 暴露工具

    name、description 与 input_model 的 JSON Schema 被组装成工具列表。

    tools: [{ name: "write_file", input_schema: { ... } }]
    放行Passed
  2. LLM提出 tool_use

    模型选择工具并生成输入;这仍然只是候选动作。

    tool_use: { name: "write_file", input: { path: "notes.md" } }
    放行Passed
  3. ToolRegistry.get()查找工具实例

    引擎根据 name 取回 BaseTool;未知名称会直接返回错误。

    tool: WriteFileTool
    放行Passed
  4. input_model.model_validate()校验输入

    Pydantic 将原始 input 解析为工具需要的参数模型。

    WriteFileInput(path="notes.md", content="...")
    放行Passed
  5. PRE_TOOL_USE运行前置 Hook

    Hook 可以记录、观察或阻断这次工具调用。

    pre_tool_use: pass
    放行Passed
  6. PermissionChecker.evaluate()判断权限

    结合 is_read_only、模式、路径与命令规则评估是否放行。

    permission: allow
    放行Passed
  7. BaseTool.execute()执行业务逻辑

    工具在单次 ToolExecutionContext 中完成真实动作,并返回 ToolResult。

    ToolResult(output="File written")
    放行Passed
  8. POST_TOOL_USE通知执行完成

    后置 Hook 用于记录和通知,不能再逆转已经发生的动作。

    post_tool_use: complete
    放行Passed
  9. ToolResultBlock写回对话

    执行结果成为下一轮 LLM 可以看到的新证据。

    role: "user" · tool_result appended
    放行Passed

源码地图

  • tools/base.py:、、、。
  • tools/__init__.py:,注册全部内置工具。
  • tools/*_tool.py:约 40+ 个具体工具实现。
  • engine/query.py:,负责校验、权限与调用。
  • ui/runtime.py:启动时创建 Registry,并挂载插件工具。

base.py 里的四个核心类型

tools/base.py 里有四个最核心的类型:

  • :所有工具的抽象基类,name + description + + execute。
  • :单次工具调用的执行环境,也是 execute 的第二个参数。
  • :工具返回给引擎的标准化结果。
  • :name → 工具实例,并导出 API Schema 给 LLM。

本身很薄:

例Example python
class BaseTool(ABC):
    name: str
    description: str
    input_model: type[BaseModel]

    async def execute(self, arguments, context) -> ToolResult:
        ...

    def is_read_only(self, arguments) -> bool:
        return False

    def to_api_schema(self) -> dict:
        ...

其中,name 是 LLM 调用时填写的名称;description 用来告诉 LLM 何时应该使用这个工具; 则定义工具接受哪些参数,以及每个参数的类型和边界。

description 会和 name 一起发给 LLM,写得好不好直接影响模型是否会在正确时机调用工具。工具多、边界相近,或者模型能力一般时,描述就会变成工具设计的一部分。

一个不到 40 行的 tool

内置里最简单的工具之一,完整逻辑不到 40 行:

例Example python
class SleepToolInput(BaseModel):
    seconds: float = Field(default=1.0, ge=0.0, le=30.0)


class SleepTool(BaseTool):
    name = "sleep"
    description = "Sleep for a short duration."
    input_model = SleepToolInput

    def is_read_only(self, arguments: SleepToolInput) -> bool:
        return True

    async def execute(self, arguments, context) -> ToolResult:
        await asyncio.sleep(arguments.seconds)
        return ToolResult(output=f"Slept for {arguments.seconds} seconds")

模式很固定:

  1. 定义 XxxInput(BaseModel)。
  2. 定义 XxxTool(BaseTool),填入 name、description、。
  3. 实现异步 execute(),返回 。
  4. 可选地覆盖 。

Pydantic 在这里同时解决两件事:从 生成 Schema,告诉 LLM 参数结构;并在真正执行前用同一份模型校验模型返回的输入。

没注册的工具,模型永远看不见

提供的能力很直接:

例Example python
class ToolRegistry:
    def register(self, tool: BaseTool) -> None: ...
    def get(self, name: str) -> BaseTool | None: ...
    def list_tools(self) -> list[BaseTool]: ...
    def to_api_schema(self) -> list[dict]: ...

默认工具在 中显式 new + register,没有自动扫描目录:

例Example python
def create_default_tool_registry(mcp_manager=None):
    registry = ToolRegistry()
    for tool in (BashTool(), FileReadTool(), ...):
        registry.register(tool)
    if mcp_manager is not None:
        # MCP 动态工具也 register 进来
        ...
    return registry

新增一个内置工具通常只有两步:① 新建 tools/my_tool.py;② 在 tools/__init__.py 的 元组里加入 MyTool()。只写好工具类但没有注册,LLM 就永远不会看到它。

四十多个内置工具,分七类

  • Shell / 文件:bash、read_file、write_file、edit_file、glob、grep,日常编码主力。
  • 网络:web_fetch、web_search,用于外网信息。
  • 会话 / 计划:todo_write、enter_plan_mode、exit_plan_mode、brief、sleep,改变 Agent 行为状态。
  • 任务 / 多 agent:task_*、agent、team_*、send_message,用于协调子系统。
  • 定时 / 远程:cron_*、remote_trigger,用于后台调度。
  • MCP:list_mcp_resources、,用于外部协议工具。
  • 其它:skill、config、lsp、image_*、notebook_edit,按需加载。

引擎执行一次工具调用的八步

engine/query.py 中的 顺序固定:

  1. Hook:可以 blocked,直接错误返回。
  2. registry.get(name):未知工具返回错误。
  3. input_model.model_validate(tool_input):参数非法返回错误。
  4. 加上路径 / 命令抽取,进入 PermissionChecker.evaluate()。
  5. 需要确认则弹出 permission_prompt;用户拒绝则返回错误。
  6. await tool.execute(parsed, ToolExecutionContext(...))。
  7. 输出过长可 offload;记录 carryover;组装 ToolResultBlock。
  8. Hook:通知用,不阻断。
例Example text
PRE_TOOL_USE
  → registry.get(name)
  → model_validate(input)
  → PermissionChecker.evaluate()
  → execute()
  → POST_TOOL_USE

模型的输出永远只是候选动作。只有通过校验和授权,execute() 才会带来真实世界的副作用。

工具执行时手里有什么

例Example text
启动 Runtime
  tool_metadata = { mcp_manager, session_id, task_focus_state, ... }
  QueryEngine(tool_metadata=...)
        │
        ▼
  run_query(QueryContext(
      tool_registry=...,
      ask_user_prompt=...,
      tool_metadata=同一份 dict 引用,
  ))
        │
        ▼  LLM 要调工具
  _execute_tool_call(...)
        │
        ▼  现拼
  ToolExecutionContext(
      cwd=...,
      metadata={
        tool_registry,      ← 从 QueryContext 抄
        ask_user_prompt,    ← 从 QueryContext 抄
        **tool_metadata     ← 会话 dict 展开
      },
      hook_executor=...,
  )
        │
        ▼
  tool.execute(args, context)
        │
        ▼
  context.metadata.get("xxx")

是单次调用的最小环境:当前目录、Hook,以及 Runtime 明确交给工具使用的 metadata。需要 MCP manager、session id、任务焦点或用户提问回调时,工具从 context.metadata 取用,而不必依赖全局状态。

is_read_only 只是信号,不是授权

只是工具对权限系统提供的一条信号,并不是最终授权。

  • default:只读工具通常直接允许;写 / 执行类工具需用户确认。
  • full_auto:只读工具允许;写 / 执行类工具允许,但仍受敏感路径 / deny 列表约束。
  • plan:只读工具允许;写 / 执行类工具直接拦截,直到退出 Plan Mode。

另外还有显式 denied_tools / allowed_tools、路径规则、命令 deny 模式和内置敏感路径硬拦截。权限策略的主要入口在 permissions/checker.py。

内置、MCP、插件:三种工具怎么注册

  • 内置:来自 tools/*_tool.py,在 create_default_tool_registry 里写死 register。
  • MCP:来自外部 MCP Server,在同函数内由 再 register;名称是 mcp__server__tool,Schema 从远端生成。
  • 插件:来自插件 tools/*.py,Runtime 在默认表建好后二次 register。

相同点:最终都是 registry.register(BaseTool 实例),执行路径一致。不同点:发现和实例化时机与方式不同。

Skills / MCP Resources 不是 里的普通 tool:Skills 用 skill 工具加载;Resources 用专用的 list/read 工具。

插件的“二次注册”是怎么回事

会话启动 build_runtime(ui/runtime.py):

例Example text
1) load_plugins → 每个插件 load_plugin
      enabled 时:_load_plugin_tools
        扫 tools/*.py → 动态 import
        找 BaseTool 子类 → instance = 类()
        放进 LoadedPlugin.tools(此时还没进 Registry)

2) create_default_tool_registry(mcp)
      第一次:内置 + MCP

3) for plugin in plugins
      if enabled and tools:
          for tool in plugin.tools:
              tool_registry.register(tool)

“二次”= 同一张表、再跑一轮 register,不是第二种 API。同名后注册覆盖先注册;禁用插件时 tools=[],不会二次挂表。

精读清单

  • src/openharness/tools/base.py:全文很短。
  • src/openharness/tools/sleep_tool.py:最小模板。
  • src/openharness/tools/__init__.py 的 。
  • src/openharness/engine/query.py 约 887 行起的 。