Skip to content
Xiaoyu's Blog
Go back

as-java-review: 声明式 Subagent 继承父 Agent Hooks

本文记录的是 2026-09-08 审查时的代码与结论。PR 状态和实现可能继续变化,请以 GitHub 上的最新内容为准。
PR:agentscope-ai/agentscope-java#2996
Issue:#1578 声明的子 Agent 不会继承父 Agent hooks
审查提交:af344c15babef34159ca1d8398c5bf09dcdfec23
Review:Approve + LGTM

问题:父 Agent 的治理能力没有进入声明式 Subagent

Harness 允许在父 Agent 上注册 Hook:

HarnessAgent.builder()
    .hook(new ToolGovernanceHook())
    .toolkit(toolkit)
    .workspace(workspace)
    .build();

Hook 可以监听 Agent 和工具事件,也可以在工具调用前修改参数。实际项目会用它实现:

父 Agent 如果把任务委派给 Subagent,治理规则通常也应该继续生效。否则父 Agent 本身受控,声明式 Subagent 却能直接执行工具,委派路径就形成了治理缺口。

Issue #1578 给出的例子是:父 Agent 配置了工具确认 Hook,声明式 Subagent 只允许调用 query_logs。预期 query_logs 仍会触发父 Hook,修改前却不会。

先分清四种 Subagent 创建方式

这次问题很容易被概括成“Markdown Subagent 不继承 Hook”,但范围比 Markdown 更大。

创建方式修改前PR #2996
内置 general-purpose已继承父 Hook保持不变
Java SubagentDeclaration未继承修复
静态或动态 Markdown 声明未继承修复
自定义 SubagentFactory由 factory 自己决定保持不变

Java 声明和 Markdown 声明最终都进入 buildDeclaredFactory(...)。因此真正的边界是“框架自动构建的 declared subagent”,不是“代码创建”与“Markdown 创建”。

远程 Subagent 只创建 RemoteSubagentStub,不走本地子 Agent 的 Hook 安装;自定义 factory 仍由使用者决定如何构建,也不在这次修改范围内。

根因:两条工厂路径少传了一份配置

内置 general-purpose 工厂原来已经做了两件事:

final List<Hook> capturedHooks = List.copyOf(b.hooks);

// 构建子 Agent 时
sub.hooks(capturedHooks);
return sub.build();

其中:

声明式 Subagent 的工厂复制了父 Agent 的 model、toolkit、memory、middleware、执行超时等配置,却漏掉了 Hook。结果是父子使用不同的 Hook 集合,子 Agent 的 PreCall、PreActing 和 PostActing 等事件不会进入父 Hook。

这不是 Hook 调度器失效,也不是 RuntimeContext 没传。DefaultAgentManager 在调用子 Agent 时已经基于父 Context 构造子 Context,并替换子任务的 sessionId 和 userId。缺失发生在更早的构建阶段:子 Agent 根本没有安装父 Hook。

第一处核心修改:把父 Hook 装到 declared subagent

修复直接补齐了声明式工厂:

final List<Hook> capturedHooks = List.copyOf(b.hooks);

// ...构建 declared subagent

sub.middlewares(capturedMiddlewares);
sub.hooks(capturedHooks);
return sub.build(decl.getTools());

List.copyOf 复制的是列表结构,不会复制 Hook 实例。这样做有两个效果:

  1. 后续修改父 Builder 的列表,不会改变已捕获的列表内容。
  2. 父子仍使用相同的 Hook 实例,沿用原有优先级和去重语义。

sub.hooks(capturedHooks) 才是 Issue #1578 的直接修复。之后子 Agent 调用 query_logs 时,父级治理 Hook 也能收到子 Agent 的事件。

为什么不能只加 sub.hooks(...)

Hook 不只是事件监听器,它还可以通过 Hook.tools() 提供工具:

interface Hook {
    default List<Object> tools() {
        return List.of();
    }
}

如果只是把 Hook 传给子 Agent,Hook 提供的全部工具也会被注册。假设声明式 Subagent 只允许:

tools:
  - query_logs

而父 Hook 同时贡献了 query_logs 和 delete_logs,直接继承会把 delete_logs 重新带入子 Agent,绕过声明的工具白名单。

因此这个 PR 除了修复 Hook 继承,还必须解决一个配套问题:继承 Hook 的行为,但不能绕过子 Agent 的工具边界。

第二处核心修改:先过滤 Hook 工具,再安装

PR 在 ReActAgent.Builder 新增了构建入口:

public ReActAgent build(Consumer<Toolkit> hookToolFilter) {
    Toolkit agentToolkit = this.toolkit.copy();

    if (hookToolFilter == null) {
        registerToolsFromHooks(agentToolkit);
    } else {
        Toolkit hookTools = new Toolkit();
        registerToolsFromHooks(hookTools);
        hookToolFilter.accept(hookTools);

        for (String toolName : hookTools.getToolNames()) {
            agentToolkit.registerAgentTool(hookTools.getTool(toolName));
        }
    }

    // ...继续构建 ReActAgent
}

流程变成:

读取 Hook.tools()
  → 注册到临时 hookTools
  → 应用 declared tools 白名单
  → 应用子 workspace 的 tools.json
  → 把剩余工具安装到正式 agentToolkit

临时 Toolkit 还有一个实际作用:正式 Toolkit 可能配置为禁止运行时删除工具。构建阶段先在临时对象中完成过滤,可以避免“删除被禁止,受限工具又被保留下来”。

HarnessAgent 侧调用 inner.build(...) 时,同时应用两层规则:

声明中的工具列表为空时,仍然会执行 workspace 工具策略;子 Agent 自己的 Harness 平台工具也按现有规则保留。

是否应该默认继承所有父 Hook

这次 PR 选择了自动继承父 Builder 上显式配置的全部 Hook,而且没有新增开关。

这个默认值对治理类 Hook 是合理的。权限、HITL、审计和预算如果允许子 Agent自行跳过,就很容易被任务委派绕开。

但并非所有 Hook 都天然适合父子共享。例如只负责顶层 UI 展示或父流程统计的 Hook,处理子事件后可能重复展示或重复计数。当前可以在 Hook 中根据事件携带的 Agent 身份决定是否跳过:

public <T extends HookEvent> Mono<T> onEvent(T event) {
    if (!"parent".equals(event.getAgent().getName())) {
        return Mono.just(event);
    }

    // 仅处理父 Agent 事件
    return handleParentEvent(event);
}

Review 中对此留下了一条非阻塞建议:文档应明确 declared subagent 会继承全部显式父 Hook,并说明 parent-only Hook 应如何跳过子事件。这个契约比再增加一个继承开关更值得先写清楚。

Consumer<Toolkit> 的 API 边界

Consumer<Toolkit> 在功能上可以完成需求,但它比文档描述的“过滤器”能力更宽。调用方拿到可变 Toolkit 后,不仅能删除工具,还能增加或替换工具:

hookToolFilter.accept(hookTools);

当前规则只需要回答“这个工具名保留还是丢弃”,更窄的标准库接口会更直接:

public ReActAgent build(Predicate<String> hookToolFilter)

对应的构建逻辑只允许做 keep/drop 决策:

for (String toolName : hookTools.getToolNames()) {
    if (hookToolFilter.test(toolName)) {
        agentToolkit.registerAgentTool(hookTools.getTool(toolName));
    }
}

这不是本 PR 的功能错误,也没有作为合并条件。Review 将它记录为非阻塞 suggestion:既然新增的是 public API,最好让类型本身表达最小权限,而不是只靠 Javadoc 约束调用者。

测试覆盖了什么

PR 共修改 5 个文件,新增 680 行、删除 3 行,其中 616 行是测试。覆盖的主要场景包括:

审查时 GitHub 上的 Linux、Windows、Codecov、License 和 CLA 检查均通过。PR 作者还报告了多组定向测试;其中 core 全量测试的两个错误来自 Windows 符号链接权限,并在相同 main 提交上复现。本文没有把作者的本地结果表述为我的本机复测。

Review 结论

这次修改解决了一个真实的治理缺口:父 Agent 明确安装了 Hook,框架自动创建的 declared subagent 却没有继承。修复位置与 Issue 对得上,三种声明来源共用同一个工厂,工具白名单也没有因为 Hook 继承而失效。

最终提交了 Approve,整体意见为 LGTM,并留下两条非阻塞建议:

  1. 文档明确全部显式父 Hook 的继承语义,以及 parent-only Hook 的过滤方式。
  2. 考虑用 Predicate<String> 收窄新增的 public 过滤接口。

这次 Review 最值得复用的判断顺序是:看到“把父配置复制给子 Agent”时,先确认哪些创建路径共用这段工厂;看到“继承 Hook”时,再确认 Hook 是否还会带入工具、状态或其他能力。只检查 sub.hooks(capturedHooks) 这一行,会漏掉工具权限这半个问题。


Share this post:

Previous Post
1.2 基于 one-logger 的业务服务韧性支持方案
Next Post
从对象头、栈帧到锁与 JIT:我的 JVM 学习脉络