第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__hello | Java/Node 插件(第9章) |
| MCP 工具 | mcp__<serverName>__<toolName> | mcp__filesystem__read_file | MCP 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_search | glob 文件搜索(注意:pattern="*" 会递归所有文件) | 否 |
fs_write | 写文件 | 是 |
str_replace_editor | 精确字符串替换编辑 | 否 |
shell_execute | 本地 Shell 执行(/bin/sh -c) | 是 |
web_search / web_fetch | 搜索 / 抓取网页 | web profile 整体需审批 |
ask_user_question | 向用户发起澄清问题 | 否 |
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 + ApprovalPolicyService | RuntimeApprovalGate(接口) |
简单说:提交期决定"这个任务能不能跑",运行期决定"工具这一刀能不能切下去"。提交期放过去后,工具的每一次调用还可以被运行期 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)
- infrastructure:
RuntimeApprovalBroker内存实现,提供ConcurrentHashMap挂起> - trigger:
RuntimeApprovalController暴露/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):
"完整版"(14 参数含 permissionMatrixPort + approvalBroker)和"兼容版"(12 参数,传 null)。当前 HarnessApplicationConfig 只注入兼容版,所以 approvalGate == null,默认走 RuntimeApprovalGate.allowAll()——提交期审批通过后,运行期不再二次校验。这就是为什么默认配置下 shell_execute 一旦通过提交期审批就能直接跑。要严格二次校验,需要在 HarnessApplicationConfig 把完整版构造器接起来。
如果人工不响应(前端关掉、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:159 | future.orTimeout(DEFAULT_TIMEOUT_MS, MILLISECONDS) |
| MCP 工具调用 | infrastructure/adapter/mcp/StdioMcpClient.java:178 | future.orTimeout(callTimeoutMs, MILLISECONDS) |
| 内置工具(fs/shell/web) | — | 无超时,只受 abort 信号协作式取消 |
为什么这样分层是对的:domain 的 runGroup() 是并发调度器 + 有序提交器——它按 maxParallel 填充在途槽位(inFlight.size() < maxParallel)、用 commitReady() 按 index 顺序把已完成的结果写入事件流(保证 ToolCall/ToolResult 顺序与模型视角一致),取消只依赖 abortSignal 轮询。"单次调用最多等多久"是实现细节,属于传输层(进程/stdio/HTTP),所以下沉到各适配器:JsonRpcPluginToolBridge.java:159 用 future.orTimeout(DEFAULT_TIMEOUT_MS, MILLISECONDS),StdioMcpClient.java:178 用 future.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 进程)两种模式,各写一个完整示例。