Skip to content

feat(hooks): HookCenter typed-event hook system with plugin support#3564

Open
aiguozhi123456 wants to merge 15 commits intoHKUDS:nightlyfrom
aiguozhi123456:feat/hookcenter-typed-events
Open

feat(hooks): HookCenter typed-event hook system with plugin support#3564
aiguozhi123456 wants to merge 15 commits intoHKUDS:nightlyfrom
aiguozhi123456:feat/hookcenter-typed-events

Conversation

@aiguozhi123456
Copy link
Copy Markdown
Contributor

@aiguozhi123456 aiguozhi123456 commented Apr 30, 2026

Summary

构建 HookCenter 基于类型化事件的钩子系统,替换 AgentHook 的方法重写模式。外部开发者现在可以通过 entry_points(group="nanobot.hooks") 分发 hook plugin,handler 支持 observe/transform/guard 三种模式。旧 AgentHook 通过兼容适配器完全向后兼容。

Context

nanobot 当前 AgentHook 系统存在三个瓶颈:添加 hook 位点需修改基类和 CompositeHook(O(n)),外部开发者无法自主分发 hook plugin(仅构造函数注入),错误处理不一致(async 方法有隔离但 finalize_content 没有)。Channel plugin 已有成熟的 entry_points 模式,但 hooks 完全没有。nanobot/hooks/ 目录已预分配但空置。

Why

这让 nanobot 从一个"只有内部开发者能扩展 hook"的框架变成一个"任何人都能写和分发 hook plugin"的生态。同时让内部开发者能零摩擦地添加新 hook 位点(定义事件类 + emit 一行),不再受 6 方法耦合的限制。

Solve

引入 nanobot/hooks/ 模块,核心设计:

  • 类型化事件 dataclass 替代字符串作为 hook 点标识(IDE 可补全、编译期检查)
  • HookCenter 注册表 + emit() 分派引擎,Guard→Transform→Observe 顺序执行,内部 handler 先于外部
  • entry_points 发现nanobot.hooks 组),带 enabled_plugins allowlist 安全控制
  • AgentHook 兼容适配器 将旧子类自动包装为事件 handler,_LoopHook/_SubagentHook 保持内联
  • Deny(reason, abort=?) 返回结构化拒绝,abort=False(默认)注入 tool result 继续循环,abort=True 终止 agent loop
  • HooksConfig 添加到 schema.pyAgentLoop.__init__ 创建持久化 HookCenter 并在启动时调用 discover(config),SubagentManager 复用父 center

Changes

核心修改 +1,338 / -117,测试 +1,832 / -64,文档 +384 / -29

文件 说明
nanobot/hooks/__init__.py +41 公开 API re-export(事件类型、Deny、Modified、HookCenter)
nanobot/hooks/event_types.py +62 6 个事件 dataclass(BeforeIteration, OnStream, OnStreamEnd, BeforeExecuteTools, AfterIteration, FinalizeContent)
nanobot/hooks/protocols.py +52 -1 HookHandler Protocol, Modified(data), Deny(reason, abort) 返回类型
nanobot/hooks/center.py +227 HookCenter 注册表 + emit() + HookSession + wants_streaming/finalize_content
nanobot/hooks/discovery.py +86 entry_points 发现 + enabled_plugins allowlist + ThreadPoolExecutor 超时保护
nanobot/hooks/adapters.py +191 -8 AgentHook→事件适配器 + CompositeHook 展平
nanobot/agent/runner.py +243 -55 AgentRunSpec.hook→center+session, emit() 调用, Deny 软拒绝/硬中断双模式
nanobot/agent/loop.py +359 -51 持久化 HookCenter(per-AgentLoop 单例),启动时 discover(config),传给 SubagentManager
nanobot/agent/subagent.py +45 -9 子代理接收父 HookCenter 引用,复用而非新建
nanobot/config/schema.py +7 HooksConfig(enabled_plugins) 接入 Config,支持 camelCase/snake_case 双别名
nanobot/nanobot.py +1 SDK facade 传入 hooks_config
nanobot/cli/commands.py +3 3 处 AgentLoop 构造传入 hooks_config
nanobot/agent/hook.py +1 slots 兼容微调
tests/hooks/test_center.py +547 32 个测试:分派、排序、去重、错误隔离、Deny.abort
tests/hooks/test_discovery.py +362 19 个测试:发现、allowlist、错误路径
tests/hooks/test_adapters.py +591 24 个测试:所有方法的适配器、reraise、展平
tests/agent/test_runner.py +332 -64 76 个测试:全部通过(含 BeforeIteration 软拒绝 + Deny abort 双模式集成测试)
docs/hook-plugin-guide.md +239 Hook plugin 开发完整指南(含 BlocklistHandler abort 示例)
docs/configuration.md +145 -29 hooks.enabled_plugins 配置文档

Code review 修复(04cb55e9):

  • BeforeIteration 软拒绝(abort=False)注入 Deny 原因到 conversation 并继续循环(与 BeforeExecuteTools 一致)
  • _call_finalize_handler 检测 async handler 返回 coroutine,防止输出 corruption
  • _call_finalize_handler handler 返回 None 保留原 content("未修改"语义)
  • _apply_modified 丢弃非 dict Modified.data 而非替换 event 对象
  • ep.load() 包装进 ThreadPoolExecutor + 10s 超时,防止阻塞启动
  • hook_streaming plugin 属性完整 wiring
  • 移除死代码(_METHOD_EVENT_MAP@runtime_checkable

Test

  • tests/hooks/test_center.py — 32 tests: HookCenter dispatch, Guard/Transform/Observe ordering, dedup, error isolation, wants_streaming, finalize_content, Deny.abort
  • tests/hooks/test_discovery.py — 19 tests: entry_points discovery, allowlist filtering, error paths
  • tests/hooks/test_adapters.py — 24 tests: AgentHook adapter, method mapping, reraise propagation, CompositeHook flattening
  • tests/agent/test_runner.py — 76 tests: 全部通过(含 BeforeIteration 软拒绝 + Deny abort 硬中断双模式集成测试)
  • tests/agent/test_hook_composite.py — 18 tests: 全部通过
  • HooksConfig 序列化验证:默认 None、列表值、camelCase 别名均正常
  • 268 个相关测试全部通过(hooks + runner + composite + loop + config + channel plugins)

Enhancement Direction

方向 说明 优先级
新增 hook 位点 tools, session, LLM, memory, cron 等子系统暴露 hook 点
自定义分派顺序 当前 emit() 固定 Guard→Transform→Observe 顺序执行。某些场景需要打破此约束:例如 observe handler 需在 guard 之前执行(日志审计先于拒绝判定),或 transform handler 需要绕过 guard 直接修改事件。可在 register_point() 时声明该事件类型的分派策略(如 "free" 模式按注册顺序执行而非按 mode 分组),或在 emit() 时接受 order 参数覆盖默认行为
插件处理优先级 当前所有 plugin 按发现顺序执行,缺少优先级控制。需从两层引入:(1) 开发者层 — handler 声明 priority: int(数值越小越先执行),同类事件多插件时确定性排序;(2) 用户层config.hooks.plugin_priority 映射表覆盖默认值,运维可调整生产环境插件顺序无需改代码
HooksConfig 完善 per-plugin 配置(mode 覆盖等)
runner 回调统一 合并 progress_callback 等 4 个并行回调
nanobot hooks inspect CLI 命令查询活跃 hook 列表
Skill-to-Hook 桥接 SKILL.md frontmatter 声明 hooks

Built with OpenCode
Built with Compound Engineering
HARNESS

- 6 event dataclasses: BeforeIteration, OnStream, OnStreamEnd,
  BeforeExecuteTools, AfterIteration, FinalizeContent
- HookHandler Protocol (async callable, runtime_checkable)
- Return types: Modified(data), Deny(reason), HookResult union
- Slotted dataclasses for memory efficiency
- HookCenter: typed-event registry with register_point/register/register_internal
- HookSession: per-call container for internal handlers
- emit() dispatch: Guard → Transform → Observe, internal before external
- Guard Deny short-circuit, Transform chain pipeline, Observe sequential
- Error isolation with per-handler reraise flag
- wants_streaming() and finalize_content() dedicated methods
- 29 tests covering all modes, ordering, errors, and edge cases
- discover_hook_plugins() scans entry_points(group='nanobot.hooks')
- register_discovered() filters by enabled_plugins allowlist
- Plugin contract: hook_events attribute, hook_streaming flag
- Error isolation: individual plugin failures logged, others proceed
- entry_points() full failure degrades gracefully
- 19 tests covering discovery, allowlist, and error paths
- adapt_agent_hook() wraps legacy AgentHook methods as event handlers
- Method→event mapping: 6 lifecycle events
- AgentHookContext reconstruction from event dataclasses
- _reraise flag propagation from AgentHook to handler registration
- adapt_agent_hook_list() with CompositeHook recursive flattening
- 24 tests covering all methods, reraise, and adapter patterns
…+U6)

- AgentRunSpec: remove hook field, add center + session fields
- AgentRunner.run(): emit typed events through HookCenter
- All 27 hook call sites replaced with emit() + event dataclasses
- _LoopHook and _SubagentHook adapted through HookCenter
- Adapter shares mutable AgentHookContext via session.context
- 162 tests pass — full backward compatibility
- Guard Deny check in runner BeforeExecuteTools (P0 HKUDS#1)
- AfterIteration emit on max_iterations path (P0 HKUDS#3)
- Allowlist check before ep.load() in discovery (P0 HKUDS#2)
- Remove FinalizeContent dead payload fields (HKUDS#4)
- Remove register_point dead code (HKUDS#5)
- Docstring session.context coupling contract (HKUDS#6)
- Extract _make_after_iteration helper (HKUDS#7)
- Warning on non-dict Modified.data (HKUDS#8)
- Dedup in register_internal (HKUDS#9)
- New docs/hook-plugin-guide.md: build, package, install hook plugins
- Update docs/README.md: add hook plugin guide to Advanced Docs
- Update docs/configuration.md: document hooks.enabled_plugins
- Update docs/python-sdk.md: add event-based hook API documentation
@JackLuguibin
Copy link
Copy Markdown
Contributor

很有意思的功能,我觉得你可以想想能不能做成热加载

@aiguozhi123456
Copy link
Copy Markdown
Contributor Author

aiguozhi123456 commented May 1, 2026

很有意思的功能,我觉得你可以想想能不能做成热加载

不太安全吧,现在nanobot的/restart其实挺方便的。

Guards can now return Deny(reason, abort=True) to immediately terminate
the agent loop instead of just injecting a soft-denial tool result.
BeforeIteration also gains guard support for early iteration blocking.
@aiguozhi123456
Copy link
Copy Markdown
Contributor Author

@Re-bin @chengyongru 个人大致初步想法是这样的,有一些细节代码还可以优化。没新增hook位点是希望社区按需贡献。想问问你们的看法?

…ksConfig

HookCenter.discover() was never called in production paths — external
hook plugins were silently ignored.  Add HooksConfig to the config schema
(enabled_plugins allowlist) and create a persistent HookCenter singleton
in AgentLoop.__init__ that calls discover() once.  SubagentManager
shares the parent center instead of creating throwaway instances.
…d error path tests

- Change plugin discovery to default-deny: enabled_plugins=null loads
  no plugins instead of all (P0 security fix)
- Remove 'context' in dir() debug code in runner.py, replace with
  proper None-guarded _make_after_iteration call (P0)
- Add 5 error-path tests: guard exceptions, transform pipeline
  resilience, finalize_content Deny, independent sessions (P2)
- Update docs and config schema to reflect default-deny policy
…plugin load safety

- BeforeIteration soft-deny (abort=False) now injects guard reason
  as a synthetic user message and continues the loop, matching
  BeforeExecuteTools soft-deny behavior
- _call_finalize_handler detects coroutine returns from async
  handlers and logs a warning instead of corrupting agent output
- _call_finalize_handler preserves original content when handler
  returns None (no-change signal)
- _apply_modified discards non-dict Modified.data instead of
  replacing the live event object (prevents downstream crash)
- Removed dead _METHOD_EVENT_MAP in adapters and unused
  @runtime_checkable on HookHandler protocol
- Promoted misspelled Modified.data key log from debug to warning
- Wrapped ep.load() in ThreadPoolExecutor with 10s timeout to
  prevent plugin load from blocking agent startup
- Implemented hook_streaming plugin attribute wiring
- Added test_runner_before_iteration_soft_deny_continues_loop
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants