Hook:在 agent 执行过程中介入

agent 并不是收到问题后只调用一次模型。一次运行可能要反复请求模型、执行工具、等待审批、压缩上下文,最后才生成答案。如果应用只能看到最终结果,就很难完成下面这些工作:

  • 在模型请求前补充上下文,或者调整温度、请求头等参数;
  • 在工具执行前检查参数,必要时改写参数或拒绝调用;
  • 在工具执行后记录结果,或者清理返回给模型的内容;
  • 把执行过程发送给界面、日志系统和审计系统;
  • 遇到敏感操作时暂停执行,等待用户决定是否继续。

这些介入点常被统称为 Hook,但 Hook 并不是一种固定接口。判断一个扩展点能做什么,不能只看它是否叫 Hook,而要看它位于哪条调用链,以及它是否拥有三种能力:能不能修改数据,能不能阻止后续执行,调用方会不会等待它完成。

MAF、Codex 和 OpenCode 恰好代表了三种不同设计。MAF 使用带 next 的中间件包裹执行过程;Codex 把面向用户的 Hook 做成可配置的外部命令;OpenCode 则把同步修改交给插件 Hook,把过程通知交给事件系统。三者都允许应用介入 agent,却不能用同一种心智模型理解。

先分清通知、拦截和审批

最容易混淆的是事件与 Hook。事件通常表示“某件事已经发生”,订阅者可以记录它,却不能改变已经发生的事实。拦截器位于操作前后,能够修改输入或输出,有些拦截器还能让操作不再继续。审批则会主动暂停调用链,直到用户或策略给出答复。

正在渲染 Mermaid 图表...

这几种能力有明确边界。工具执行后的 Hook 即使拒绝返回结果,也无法撤销工具已经写入的文件;异步事件订阅者即使抛出异常,也未必能让发布事件的一方失败;能够修改共享对象,也不代表能够以成功状态跳过后续插件。阅读源码时,应沿着真实调用点追踪数据和控制流,而不是只阅读接口声明。

MAF:用中间件包裹调用链

Microsoft Agent Framework 没有统一命名为 Hook 的公共抽象。它主要沿用 .NET 中常见的 Builder 与 Decorator 模式,把中间件放在 agent、函数调用和 IChatClient 三个层次。三层看到的对象不同,触发频率也不同。

最外层是 AIAgentBuilder.Use()。它包裹一次完整的 RunAsync()RunStreamingAsync(),适合做输入过滤、整次运行的日志和响应清理。普通运行中间件的形状如下:

async Task<AgentResponse> Middleware(
    IEnumerable<ChatMessage> messages,
    AgentSession? session,
    AgentRunOptions? options,
    AIAgent innerAgent,
    CancellationToken cancellationToken)
{
    var filtered = FilterMessages(messages);
    var response = await innerAgent.RunAsync(
        filtered, session, options, cancellationToken);

    response.Messages = FilterMessages(response.Messages);
    return response;
}

innerAgent 就是下一层。中间件可以先修改消息,再调用它,也可以直接返回一个 AgentResponse,让内层完全不执行。调用完成后,中间件还可以替换响应或处理异常。

AIAgentBuilder.Build() 会按注册顺序的反方向构造装饰器,所以先注册的中间件位于最外层。例如先注册 first,再注册 second,执行顺序是 first before -> second before -> agent -> second after -> first after。实现和顺序测试分别位于:

  • src/Microsoft.Agents.AI/AIAgentBuilder.cs
  • tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs

流式调用还多一个容易忽略的边界。如果只提供非流式委托,框架会先获得完整 AgentResponse,再把它转换成若干 AgentResponseUpdate,这不是真正逐段处理的流式中间件。即使提供了流式委托,写在枚举结束后的代码也只有在调用方自然消费完整个流时才会执行。资源释放应放进 finally,不能假设普通的 after 代码一定运行。

第二层是函数调用中间件,它拦截每一个 AIFunction。入口仍然是 Builder 的 Use(),不过回调中拿到的是 FunctionInvocationContext 和一个 next 委托:

async ValueTask<object?> FunctionMiddleware(
    AIAgent agent,
    FunctionInvocationContext context,
    Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next,
    CancellationToken cancellationToken)
{
    if (context.Function.Name == "delete_file")
    {
        return "The operation was blocked.";
    }

    var result = await next(context, cancellationToken);
    return Sanitize(result);
}

调用 next 才会执行原函数。因此,这一层既能查看或替换 context.Arguments,也能短路函数,还能替换返回值。它不是简单的前后通知,而是完整的工具执行管道。

这套机制依赖 FunctionInvokingChatClientFunctionInvocationDelegatingAgent 会在运行时把函数包装成 MiddlewareEnabledFunction,函数执行时再从 FunctionInvokingChatClient.CurrentContext 取得当前调用上下文。若内层 agent 没有暴露 FunctionInvokingChatClient,注册函数中间件时会抛出 InvalidOperationException。相关实现位于:

  • src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs
  • src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgentBuilderExtensions.cs
  • tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs

第三层是 IChatClient 中间件。它包裹的是一次模型服务调用,而不是一次完整的 agent 运行。模型返回工具调用后,agent 可能执行工具并再次请求模型,所以一个 RunAsync() 中可能触发多次 IChatClient.GetResponseAsync()。要调整发给模型的消息、ChatOptions 或模型原始响应,应放在这一层;要处理完整的 AgentResponse,则应放在 agent 中间件。

ChatClientAgent 默认建立的管道中包含 FunctionInvokingChatClient、消息注入、历史持久化和 OpenTelemetry 等装饰器。配置 UseProvidedChatClientAsIs 后,框架不会自动补齐这些组件,函数调用中间件也可能因此失去前置条件。默认管道的组装逻辑位于 src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs

MAF 还有 AIContextProvider,但它的用途比通用中间件更窄。InvokingAsync() 在调用前提供 instructions、messages 或 tools,InvokedAsync() 在调用后接收结果,适合检索增强和记忆存储。内层 agent 失败时,provider 可以从 InvokedContext.InvokeException 看到异常,不过默认 InvokedCoreAsync() 会在失败时跳过 StoreAIContextAsync()。因此,“失败时会通知 provider”和“失败结果会被默认存储”是两件不同的事。

审批也不是一个通用 Hook。MAF 用 ApprovalRequiredAIFunctionToolApprovalRequestContentToolApprovalResponseContent 表达暂停与回复,应用负责展示审批界面并把答复送回 agent。框架示例确实可以用 agent 中间件组织这段循环,但底层协议不是“调用一个 approval hook”。

Codex:配置驱动的生命周期 Hook

Codex 是三个项目中唯一把 Hook 做成独立 crate 的实现,源码位于 codex-rs/hooks。当前 HOOK_EVENT_NAMES 定义了 11 个事件:

pub const HOOK_EVENT_NAMES: [&str; 11] = [
    "PreToolUse",
    "PermissionRequest",
    "PostToolUse",
    "PreCompact",
    "PostCompact",
    "SessionStart",
    "SessionEnd",
    "UserPromptSubmit",
    "SubagentStart",
    "SubagentStop",
    "Stop",
];

这些 Hook 面向 Codex 用户,而不只是 Rust 开发者。用户可以在 hooks.jsonconfig.toml[hooks] 中注册外部命令,并用 matcher 筛选工具名称等目标。命令从标准输入接收 JSON,执行结果再从标准输出解析。下面的配置只匹配 Bash 工具:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^Bash$",
        "hooks": [
          {
            "type": "command",
            "command": "python3 check-command.py",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

现代命令 Hook 的调度器使用 FuturesUnordered 并发执行匹配的 handler,完成后再按配置顺序整理报告。因此不能说它们按配置顺序串行运行。对于多个 PreToolUse Hook 同时改写输入的情况,冲突取决于实际完成顺序,而不是报告中的排列顺序。

工具调用的控制点可以从 codex-rs/core/src/tools/registry.rs 看清。ToolRegistry 在实际派发前调用 run_pre_tool_use_hooks()

match run_pre_tool_use_hooks(...).await {
    PreToolUseHookResult::Blocked(message) => {
        return Err(FunctionCallError::RespondToModel(message));
    }
    PreToolUseHookResult::Continue {
        updated_input: Some(updated_input),
    } => {
        invocation = tool.with_updated_hook_input(invocation, updated_input)?;
    }
    PreToolUseHookResult::Continue {
        updated_input: None,
    } => {}
}

PreToolUse 可以阻止调用,也可以通过 updatedInput 改写参数。不过改写只有和 permissionDecision: "allow" 一起返回时才会采用。PostToolUse 则发生在工具完成之后,它可以阻止工具结果继续返回给模型,或者补充模型可见的上下文,但不能撤销已经发生的文件修改和进程副作用。

旧版进程内 Hook 仍然存在于 codex-rs/hooks/src/types.rs。它的 HookFn 返回 HookResult::SuccessFailedContinueFailedAbort,当前主要服务于 legacy AfterAgent 通知。现代外部命令 Hook 不经过这套 HookFn,也不使用 FailedAbort;它们通过各事件自己的 should_blockcontinuedecision 等字段表达控制结果。把两套 API 混在一起,会误以为所有 Hook 都共享同一种失败语义。

PermissionRequest 位于普通审批之前。工具需要授权时,Codex 先执行 permission Hook:任一 Hook 返回 deny,就拒绝操作;没有 deny 时可以采用 Hook 给出的 allow;所有 Hook 都不作决定,才进入 Guardian 或用户审批。当前实现不允许这个 Hook 修改工具输入、权限集合或中断对象,这些字段会被 fail closed。审批回复仍通过独立的请求与响应协议完成,而不是通过 HookCompleted 事件完成。

Hook 自身的执行过程也会产生 EventMsg::HookStartedEventMsg::HookCompleted。这两个事件供 TUI、app-server 和日志观察 Hook 状态,不负责做审批决定。也就是说,Codex 同时存在两条链:Hook 命令是主流程等待的扩展点,EventMsg 是客户端观察核心状态的事件流。

外部命令可能在 sandbox 之外执行,所以 Codex 还为配置 Hook 增加了信任状态:ManagedTrustedUntrustedModified。未信任或配置发生变化的 Hook 会被发现,但不会进入实际 handler 列表;TUI 启动时可以让用户审核并保存配置哈希。相关实现集中在:

  • codex-rs/hooks/src/engine/discovery.rs
  • codex-rs/tui/src/startup_hooks_review.rs
  • codex-rs/tui/src/hooks_rpc.rs

这层信任不是工具审批。工具审批回答的是“本次命令能否执行”,Hook 信任回答的是“这段会被 Codex 自动启动的外部程序是否可信”。

OpenCode:插件修改数据,事件负责通知

OpenCode 当前同时保留 V1 插件接口和正在演进的 V2 注册式 API。工具、聊天和事件 Hook 的成熟调用链仍主要位于 V1,因此这里以 packages/plugin/src/index.ts 中的 Hooks 接口为准,不把 V2 的计划文件当作已经接入的功能。

V1 Hook 是一个由字符串键组成的对象。常用入口包括:

Hook可以修改的内容
chat.message用户消息及其 parts
chat.params温度、top-p、最大输出等模型参数
chat.headersprovider 请求头
tool.definition工具描述和参数 schema
tool.execute.before工具参数 args
tool.execute.after工具的标题、文本结果和 metadata
shell.envshell 环境变量
event只接收事件通知,不修改主流程

同步 Hook 由 packages/opencode/src/plugin/index.ts 中的 Plugin.trigger() 调度。它的核心不是 next,而是共享对象:

for (const hook of state.hooks) {
  const fn = hook[name]
  if (!fn) continue
  yield* Effect.promise(async () => fn(input, output))
}

return output

插件按加载顺序串行执行,后一个插件会看到前一个插件对 output 的修改。回调返回值不会替换 output,插件必须直接修改宿主传入的对象。如果某个回调抛出异常,Plugin.trigger() 会失败,后面的插件不再执行;但接口没有“不执行 next 并返回一个正常替代结果”的专门语义。

普通工具在 packages/opencode/src/session/tools.ts 中按照下面的顺序运行:

yield* plugin.trigger(
  "tool.execute.before",
  { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
  { args },
)

const result = yield* item.execute(args, ctx)
const output = { ...result, attachments }

yield* plugin.trigger(
  "tool.execute.after",
  { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
  output,
)

before 修改后的参数会交给真实工具,after 修改后的结果会交给模型。不过 after 不是 finally:如果 before、权限检查或工具执行失败,普通工具不会触发 after。Shell 也不经过这组工具 Hook,它使用独立的 shell.env。因此不能依靠 tool.execute.after 统一收集所有失败,也不能用它审计每一条 shell 命令。

插件接口中仍声明了 permission.ask,但当前权限主链没有触发这个插件入口。实际权限服务先计算 allow、deny 和 ask 规则;需要询问时发布 Permission.Event.Asked,创建 Deferred 并等待 oncealwaysreject 回复。教程若根据接口声明断言插件能修改 permission.askstatus 来批准操作,就会与当前调用链不符。

OpenCode 的 event Hook 和上述同步 Hook 也不是同一种调度。插件宿主通过 EventV2Bridge 监听当前目录的事件,再以 void hook.event(...) 的方式通知插件。发布方不会等待这个异步回调,它也不能修改正在执行的工具。客户端通过 /event SSE 接收事件则是另一条网络链路,两者都属于观察机制,不是工具拦截器。

底层 EventV2 位于 packages/core/src/event.ts。普通事件只进入内存 PubSub;声明为 durable 的事件会按 aggregate 写入 SQLite,并获得连续的 sequence。durable() 可以先读取历史事件,再继续追踪实时事件;replay()replayAll() 可以重放 durable 事件;projector 与事件写入处于同一个事务。只有 durable 事件拥有这些能力,不能把它概括为“OpenCode 的所有事件都能持久化和重放”。

OpenCode 因而把职责拆得很清楚:需要同步修改聊天或工具数据时使用 Plugin.trigger();需要展示运行状态时订阅 EventV2 或 SSE;需要暂停敏感操作时进入 Permission 服务。三条链会在同一次工具调用中相遇,但它们没有合并成一个万能 Hook。

三种设计如何选择

把三个项目放到同一张表里,更容易看出它们的边界:

能力MAFCodexOpenCode
主要扩展方式C# 中间件与 provider配置驱动的外部命令 HookTypeScript 插件与 EventV2
工具执行前改参数函数中间件修改 ArgumentsPreToolUse.updatedInputtool.execute.before 修改 args
工具执行后改结果可以替换函数返回值通常补充上下文或拒绝结果,不撤销副作用tool.execute.after 修改共享 output
正常短路工具不调用 next 并返回替代值PreToolUse 返回阻断决定没有专门的正常短路协议,异常会终止 trigger
审批approval content 协议,应用组织交互permission Hook 优先,随后进入审批协议Permission 规则、事件、Deferred 与用户回复
过程通知Logging、OpenTelemetry 等装饰器EventMsgEventV2 与 SSE
外部配置 Hook不支持支持 JSON 与 TOMLV1 插件需要代码
Hook 信任审核没有对应机制对外部命令保存配置哈希并审核插件加载与权限规则分别处理

如果是在 .NET 应用中组合强类型能力,MAF 的 next 管道最直接。它把控制权交给宿主代码,输入、输出和异常都能在编译期约束,但配置面向开发者,不适合让终端用户随手增加脚本。

如果产品需要让用户在不修改 Codex 源码的情况下接入审计、策略检查或自动化命令,Codex 的配置 Hook 更合适。它为生命周期、matcher、超时和信任审核给出了完整协议,但 Hook、观测事件和审批是三套相邻机制,使用时必须分清。

如果应用本身已经采用 OpenCode 的插件和事件架构,应根据目的选择入口:改工具参数和结果使用同步插件 Hook,记录与展示过程使用 EventV2,权限确认交给 Permission 服务。尤其不要根据 Hooks 接口中存在某个字段,就推断核心流程一定触发了它;真正决定能力的是 Plugin.trigger()events.publish()permission.ask() 的调用位置。

设计自己的 agent 扩展系统时,也可以沿用同一个判断顺序:先确定扩展点位于操作前还是操作后,再决定允许观察、改写还是阻断,最后明确异常是否传播、多个处理器是串行还是并发、失败后的副作用能否恢复。接口叫什么并不重要,控制流契约才是 Hook 的实际能力。