🛠️ 第四篇:工具与扩展

第8章 工具系统:ToolCallExecutor 与 Hook 链

Agent 的"手"——统一执行器如何让每次工具调用都可治理

8.1 本章导读

ReAct 循环(第4章)负责"决定调工具",本章讲"工具真正被执行"的这一段。核心是一个设计原则:所有工具调用,无论来自内置工具、Java 插件、Node 插件还是 MCP Server,都必须穿过同一个 ToolCallExecutor。正是这个"唯一入口"让 Hook、超时、审批、事件回填能统一实施。

8.2 工具的全景与命名规范

系统里有三类工具,命名格式即身份:

类型命名格式示例来源
内置工具直接名fs_read, fs_search, fs_write, str_replace_editor, shell_execute, web_search, web_fetch, ask_user_question随宿主发布
插件工具plugin__<pluginId>__<toolName>plugin__sample-tools__helloJava/Node 插件(第9章)
MCP 工具mcp__<serverName>__<toolName>mcp__filesystem__read_fileMCP Server(第10章)

统一注册进 ToolRegistry(📦 domain/tool/adapter/ToolRegistry.java),模型看到的工具 schema 列表就来自这里。命名前缀不只是约定——审批矩阵、Hook 匹配都依赖它做路由。

8.3 ToolCallExecutor:唯一入口的五步流水线

// ToolCallExecutor 关键结构(简化)
public final class ToolCallExecutor {
    private volatile IHookService hookService;
    private volatile RuntimeApprovalGate approvalGate;

    // PRE_HOOK:返回 true 表示被拦截
    private boolean blockedByPreHook(String toolName, ...) {
        return hookService.runHooks(HookDialect.CLAUDE_CODE, HookPoint.PRE_TOOL_USE, toolName, ...)
            .map(d -> d == HookDecision.BLOCK || d == HookDecision.DENY)  // 阻止类决策优先
            ...
    }

    public boolean execute(...) {
        // PRE_TOOL_USE → 执行 → POST_TOOL_USE → 事件回填 SessionLog
    }
}

8.3.1 超时:协作式 orTimeout

工具超时用 CompletableFuture.orTimeout——到点触发超时异常,但不强杀线程(与第4章 Agent 取消同理:协作式,让工具自己清理)。guard 域还提供重复调用提醒(同一工具同参数短时间反复调用时提示模型换思路)。

8.3.2 Hook:CLAUDE_CODE 方言

Hook 引擎支持 HookDialect.CLAUDE_CODE 方言,在 PRE_TOOL_USE / POST_TOOL_USE 等生命周期点触发。阻止类输出(BLOCK/DENY)优先——任何一个 Hook 返回阻止决策,工具就不执行。这给了企业在不修改核心代码的情况下注入自定义管控(如"工作时间禁止 shell_execute")的能力。

8.4 内置工具速览

工具能力默认需审批?
fs_read读文件内容
fs_searchglob 文件搜索(注意:pattern="*" 会递归所有文件)
fs_write写文件
str_replace_editor精确字符串替换编辑
shell_execute本地 Shell 执行(/bin/sh -c
web_search / web_fetch搜索 / 抓取网页web profile 整体需审批
ask_user_question向用户发起澄清问题
⚠️ shell_execute 是"零沙箱"工具

LocalShellExecutor 直接 /bin/sh -c,无命令白名单——安全性完全依赖提交期审批和沙箱模式(harness.sandbox.default-mode,本地默认 WORKSPACE_WRITE 限制 cwd 不越界)。生产部署务必开启审批并用容器隔离(第11、12章展开)。

8.5 事件回填:工具调用如何进事件流

执行器的最后一步把结果写回 SessionLog(呼应第5章):

callId 是配对的钥匙:ToolCall 先写入(UI 显示 running 卡片),ToolResult 后写入(同一 callId 更新为 success/error)。注意 callId 来自 MessageSource.ToolMessageSource.callId()不是 Message.id(那是 UUID)——这是第6章提到的"工具卡片不更新"Bug 修复时的关键细节。

8.6 课程补充:运行期审批 Gate——与提交期审批的分工

本章讲了 ToolCallExecutor 的"五步流水线"包含 PRE Hook 和审批。但审批其实是两个独立层次的协作,本节把这条"提交期 + 运行期"的分工讲透——这是第11章治理的全貌基础。

8.6.1 两层审批:提交期 vs 运行期

维度提交期审批运行期审批
触发时机任务提交(POST /api/harness/tasks),五个策略树节点中 PermissionCheckNode每次工具调用执行前,ToolCallExecutor.runGroup
决策依据profile + 启用工具列表 vs 权限矩阵具体工具名 + 当前参数 vs 权限矩阵
产出ApprovalDecisionVO(PENDING_APPROVAL/QUEUED)工具直接执行 / 合成 APPROVAL_REQUIRED 失败结果
用户介入列出整个任务待决,等人批/拒人工 broker 阻塞等待本次调用的裁决
领域服务PermissionPolicyService + ApprovalPolicyServiceRuntimeApprovalGate(接口)

简单说:提交期决定"这个任务能不能跑"运行期决定"工具这一刀能不能切下去"。提交期放过去后,工具的每一次调用还可以被运行期 gate 再次拦截——这给了"任务本身合规,但单步操作高危"场景更细粒度的控制。

8.6.2 IRuntimeApprovalBroker:运行期审批的端口

运行期审批是 Agent 在执行工具时阻塞等待人工裁决——必须有一个"挂起 + 通知 + 恢复"机制。本项目的设计:

// domain/agent/adapter/port/IRuntimeApprovalBroker.java
public interface IRuntimeApprovalBroker {
    ApprovalVerdict requestApproval(ApprovalRequest request);   // 阻塞
    boolean resolve(String approvalId, ApprovalVerdict verdict); // 提交裁决
    List listPending();                    // 前端轮询
    int pendingCount();
    record PendingApprovalView(String approvalId, String sessionId,
                               String toolName, String displayCommand,
                               Map arguments, String createdAt) {}
}

三层架构把这个接口分层放好:

  • domain/agent/adapter/port:仅接口 + 视图 record(不依赖 Spring)
  • infrastructureRuntimeApprovalBroker 内存实现,提供 ConcurrentHashMap> 挂起
  • triggerRuntimeApprovalController 暴露 /api/harness/runtime/approvals 给前端轮询,POST /:id/resolve 提交裁决

接口设计的关键是同步阻塞的 requestApproval——它假定人工响应会从另一个 HTTP 请求(resolve)进来唤醒。所以 RuntimeApprovalBroker 内部用 CompletableFuture

// 简化:runtime broker 内部
private final ConcurrentMap> waits = new ConcurrentHashMap<>();

public ApprovalVerdict requestApproval(ApprovalRequest req) {
    CompletableFuture future = new CompletableFuture<>();
    waits.put(req.approvalId(), future);
    try {
        return future.get();  // 阻塞,直到 resolve() 触发 complete()
    } finally {
        waits.remove(req.approvalId());
    }
}

public boolean resolve(String approvalId, ApprovalVerdict v) {
    var f = waits.remove(approvalId);
    if (f == null) return false;
    f.complete(v);   // 唤醒阻塞的请求
    return true;
}

8.6.3 MatrixRuntimeApprovalGate:默认实现

MatrixRuntimeApprovalGate 把权限矩阵 + broker 串成一个具体决策:

// domain/tool/service/MatrixRuntimeApprovalGate.java
public class MatrixRuntimeApprovalGate implements RuntimeApprovalGate {
    private final IPermissionMatrixPort matrix;
    private final String sessionId;
    private final BiFunction, ApprovalVerdict> askHandler;

    @Override
    public Decision check(String toolName, Object args) {
        PermissionMatrixVO m = matrix.load();
        if (!m.approvalRequiredTools().contains(toolName)) {
            return Decision.ALLOW;          // 不在审批清单 → 直接放行
        }
        ApprovalRequest req = new ApprovalRequest(UUID.randomUUID().toString(),
                                                  sessionId, toolName, args);
        ApprovalVerdict v = askHandler.apply(req, /* 临时引用 */);
        return v.approved() ? Decision.ALLOW : Decision.DENY;
    }
}

工作流程:

8.6.4 AgentFactory 怎么把 broker 接进 executor

最后一个细节:上面那些抽象怎么被真正接起来?答案是 AgentFactory.wireAgent()(infrastructure/adapter/agent/AgentFactory.java):

// AgentFactory.wireAgent()(简化)
ToolCallExecutor executor = new ToolCallExecutor(toolView, session, 10,
                                                new JacksonToolArgumentsParser(objectMapper));
if (hookService != null) executor.setHookService(hookService);

// 运行期审批:矩阵 + broker 都在才接 gate
if (permissionMatrixPort != null && approvalBroker != null) {
    MatrixRuntimeApprovalGate gate = new MatrixRuntimeApprovalGate(
            permissionMatrixPort,
            session.sessionId(),
            (req, g) -> approvalBroker.requestApproval(req)   // 注入 ask handler
    );
    executor.setApprovalGate(gate);
}

注意几个真实踩过的坑(已记 MEMORY.md):

⚠️ AgentFactory 的两个构造器

"完整版"(14 参数含 permissionMatrixPort + approvalBroker)和"兼容版"(12 参数,传 null)。当前 HarnessApplicationConfig 只注入兼容版,所以 approvalGate == null,默认走 RuntimeApprovalGate.allowAll()——提交期审批通过后,运行期不再二次校验。这就是为什么默认配置下 shell_execute 一旦通过提交期审批就能直接跑。要严格二次校验,需要在 HarnessApplicationConfig 把完整版构造器接起来。

⚠️ requestApproval 同步阻塞的代价

如果人工不响应(前端关掉、broker 没接 controller),Agent 会永远卡在这次工具调用。代码里没有超时回退——目前的兜底只有用户手动 cancel Agent(Phase.abort.set(true))。生产部署务必给 broker 加超时:要么前端轮询 UI 强提示,要么 broker 内部 future.get(timeout, ...) 超时返回 DENY。

8.7 课程补充:超时与守卫的真实挂载点——一次纠偏

8.3 节说"工具超时用 orTimeout"——这需要纠偏:domain 层的 ToolCallExecutor 本身没有任何超时机制。源码检索 orTimeout 只有两处,全部在 infrastructure 适配器里:

挂载点位置机制
Node 插件 sidecar 调用infrastructure/adapter/plugin/JsonRpcPluginToolBridge.java:159future.orTimeout(DEFAULT_TIMEOUT_MS, MILLISECONDS)
MCP 工具调用infrastructure/adapter/mcp/StdioMcpClient.java:178future.orTimeout(callTimeoutMs, MILLISECONDS)
内置工具(fs/shell/web)无超时,只受 abort 信号协作式取消

为什么这样分层是对的:domain 的 runGroup()并发调度器 + 有序提交器——它按 maxParallel 填充在途槽位(inFlight.size() < maxParallel)、用 commitReady() 按 index 顺序把已完成的结果写入事件流(保证 ToolCall/ToolResult 顺序与模型视角一致),取消只依赖 abortSignal 轮询。"单次调用最多等多久"是实现细节,属于传输层(进程/stdio/HTTP),所以下沉到各适配器:JsonRpcPluginToolBridge.java:159future.orTimeout(DEFAULT_TIMEOUT_MS, MILLISECONDS)StdioMcpClient.java:178future.orTimeout(callTimeoutMs, MILLISECONDS)。内置工具(fs/shell)是进程内直接调用,没有单次超时,只受 abort 信号协作式取消——这是"已知隐患"清单里 shell_execute 风险的一部分。

// infrastructure 侧的真实超时挂载点(简化)
// JsonRpcPluginToolBridge.java:159 — Node 插件 sidecar
return future.orTimeout(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS);

// StdioMcpClient.java:178 — MCP stdio JSON-RPC
return future.orTimeout(callTimeoutMs, TimeUnit.MILLISECONDS);

8.7.2 GuardService:执行后守卫的单一接缝

guard 域(📦 domain/guard/service/GuardService.java)是执行后守卫的组合接缝,只有两个动作:

// domain/guard/service/GuardService.java(全文截取)
public class GuardService implements IGuardService {
    private final RepeatToolReminderEngine reminderEngine;

    @Override
    public GuardDecision postExecute(Optional<String> agentId, String toolName,
                                     String arguments, String resultJson) {
        if (agentId.isEmpty()) return GuardDecision.allow();
        Optional<String> reminder = reminderEngine.observe(agentId.get(), toolName, arguments);
        return reminder.map(GuardDecision::allowWithReminder).orElse(GuardDecision.allow());
    }

    @Override
    public void preStep(String agentId, boolean hasUserMessage) {
        if (hasUserMessage) reminderEngine.reset(agentId);   // 新用户消息 → 重置重复计数
    }
}

行为读法:

  • postExecute 是"允许但提醒"而非拦截:同一 agent 同工具同参数反复出现时,RepeatToolReminderEngine.observe() 产出一句提醒,包成 GuardDecision.allowWithReminder() 附加到结果——工具照常执行,但提醒会随结果回给模型,推动它换思路。
  • preStep 在新用户消息到达时重置计数——重复判定是"同一 turn 内的死循环检测",不是跨会话全局限流。
  • 没有 agentId(非 Agent 场景调用)直接放行,守卫不挡基础设施路径。

8.8 小结与下一章预告

本章要点:三类工具(内置/plugin__/mcp__)统一注册、统一执行;ToolCallExecutor 五步流水线(解析→PRE Hook→执行→POST Hook→事件回填)是所有治理的挂载点;domain 层不做超时——等待超时挂在 infrastructure 桥接层(orTimeout 协作式),重复调用由 GuardService 守卫提醒;ToolCall/ToolResult 用 callId 配对进事件流。

下一章:内置工具不够用?第9章动手写自己的插件——Java Native(进程内类隔离)和 Node Bridge(sidecar 进程)两种模式,各写一个完整示例。