Skip to content

feat: add AgentHooks support for dogfooding#1131

Open
IndenScale wants to merge 33 commits intoMoonshotAI:mainfrom
IndenScale:feat/agenthooks
Open

feat: add AgentHooks support for dogfooding#1131
IndenScale wants to merge 33 commits intoMoonshotAI:mainfrom
IndenScale:feat/agenthooks

Conversation

@IndenScale
Copy link

@IndenScale IndenScale commented Feb 13, 2026

This PR adds AgentHooks support to kimi-cli for dogfooding purposes.

Changes

  • Add hooks discovery, parser, executor, and manager modules
  • Add built-in hooks for:
    • block-dangerous-commands: Security hook blocking dangerous shell commands
    • enforce-tests: Quality gate (checks test existence)
    • auto-format-python: Auto-format Python files after write
    • session-logger / session-logger-end: Session auditing hooks
  • Hook events: pre-session, post-session, pre-agent-turn, post-agent-turn, pre-agent-turn-stop, pre-tool-call, post-tool-call, etc.
  • New: Add /hooks slash command to display loaded hooks and their statistics
  • Refactor: Remove legacy trigger name aliases (backward compatibility code)
    • Remove TRIGGER_ALIASES mapping
    • Remove normalize_trigger() function
    • Use canonical trigger names only

Notes

  • enforce-tests hook checks for test directory existence (doesn't run tests)
  • All hooks follow the AgentHooks protocol with exit codes: 0=allow, 2=block
  • Hooks can be configured at user level (~/.config/kimi/hooks/) or project level (.agents/hooks/)

Open with Devin

devin-ai-integration[bot]

This comment was marked as resolved.

@IndenScale
Copy link
Author

IndenScale commented Feb 13, 2026

🔧 Devin Review 修复已完成

已根据 Devin Review 的反馈修复了 4 个问题:

修复摘要

级别 文件 问题 修复方式
🔴 对非 JSON 序列化输入崩溃 添加 参数
🔴 缓存的 ToolResult 包含过时的 只缓存 ,返回时构造新的 ToolResult
🟡 在 时错误匹配 当设置了 pattern 但值为 None 时返回 False
🔴 hook 可能导致无限 LLM 调用 添加限制(最多 3 次阻止)

详细说明

  1. 非 JSON 序列化输入处理 - 与 executor 第 91 行的做法保持一致
  2. tool_call_id 修复 - Devin 建议的方法:缓存决策理由而非完整 ToolResult
  3. Matcher 逻辑修正 - 确保配置了 tool pattern 但没有提供 tool_name 时正确返回不匹配
  4. before_stop 限制 - 防止 misconfigured hook 浪费过多 API 调用

所有修复均已验证通过。


🔧 Devin Review Fix completed

4 issues have been fixed based on feedback from Devin Review:

Fix summary

Level File Problem Fix
🔴 Crash on non-JSON serialized input Add parameters
🔴 Cached ToolResult containing stale Cache only, construct new ToolResult on return
🟡 False match when Returns False when pattern is set but the value is None
🔴 Hook may cause unlimited LLM calls Add limit (max 3 blocks)

Detailed description

  1. Non-JSON serialized input handling - consistent with executor line 91
  2. tool_call_id fix - Devin's suggested approach: cache decision reasons instead of full ToolResult
  3. Matcher logic correction - Ensure that a mismatch is correctly returned when tool pattern is configured but tool_name is not provided.
  4. before_stop limit - prevent misconfigured hooks from wasting too many API calls

All fixes have been verified.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@IndenScale
Copy link
Author

🔧 Devin Review 第二轮修复已完成

已根据第二轮反馈修复了 3 个新问题:

修复摘要

级别 文件 问题 修复方式
🔴 executor.py 路径空格导致 hook 静默失效 使用 create_subprocess_exec 替代 create_subprocess_shell
🟡 kimisoul.py _total_steps 从未递增 在 _step() 成功后调用 increment_step_count()
🟡 toolset.py ToolHookCache 永久缓存 完全移除缓存机制(非 AgentHooks 规范要求)

详细说明

  1. create_subprocess_exec - 避免路径包含空格时 shell 错误解析导致安全 hook 被绕过
  2. increment_step_count() - 确保 session_end hooks 收到准确的 total_steps 数据
  3. 移除 ToolHookCache - 缓存不是 AgentHooks 规范的一部分,且可能导致误报无法恢复

请重新扫描确认所有问题已修复。


🔧 Devin Review Round 2 Fix Completed

Fixed 3 new issues from the second round of review:

Level File Issue Fix
🔴 executor.py Paths with spaces cause silent hook failure Use create_subprocess_exec instead of create_subprocess_shell
🟡 kimisoul.py _total_steps never incremented Call increment_step_count() after _step() succeeds
🟡 toolset.py ToolHookCache permanent caching Remove caching entirely (not in AgentHooks spec)

Please rescan to verify all issues are resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@IndenScale
Copy link
Author

🔄 更新:钩子触发器命名标准化

本次推送对钩子事件命名进行了系统性重构,采用统一的 pre/post-event 模式:

命名变更对照表

旧命名 (Legacy) 新命名 (Canonical) 说明
session_start pre-session 会话开始前
session_end post-session 会话结束后
before_agent pre-agent-turn Agent 轮次开始前
after_agent post-agent-turn Agent 轮次结束后
before_tool pre-tool-call 工具调用前
after_tool post-tool-call 工具调用后
after_tool_failure post-tool-call-failure 工具调用失败后
subagent_start pre-subagent 子 Agent 启动前
subagent_stop post-subagent 子 Agent 结束后
pre_compact pre-context-compact 上下文压缩前
before_stop pre-agent-turn-stop Agent 停止前(Quality Gate)
- post-agent-turn-stop Agent 停止后(新增)
- post-context-compact 上下文压缩后(新增)

主要改动

  1. 标准化命名:所有事件采用 {timing}-{entity}[-qualifier] 统一格式
  2. 向后兼容:旧命名作为别名保留,现有钩子无需修改
  3. 新增事件post-agent-turn-stop, post-context-compact
  4. 异步执行优化:新增 fire_and_forget() 用于后台执行异步钩子
  5. 代码简化:重构 HookManager,移除 asyncio task 跟踪

影响范围

  • agenthooks/hooks-ref/src/agenthooks_ref/models.py - 更新 HookEventType 枚举
  • src/kimi_cli/hooks/ - 核心钩子模块更新
  • src/kimi_cli/soul/ - KimiSoul 钩子调用点更新
  • 所有内置钩子定义和文档

所有旧命名仍然可用,无需修改现有钩子配置。

devin-ai-integration[bot]

This comment was marked as resolved.

@IndenScale
Copy link
Author

📦 agenthooks 参考实现已同步更新

独立仓库(https://github.com/IndenScale/agenthooks)也已推送同步更新:

  • 更新 HookEventType 枚举,添加新的 canonical 事件名和 legacy 别名
  • 更新 SPECIFICATION.md 文档,统一使用新的命名规范
  • 更新所有示例钩子(auto-format-hook, notify-hook, security-hook)
  • 保持向后兼容:旧命名作为别名保留

提交:81902cb - refactor: standardize hook triggers with pre/post-event pattern

devin-ai-integration[bot]

This comment was marked as resolved.

@IndenScale
Copy link
Author

IndenScale commented Feb 15, 2026

🔧 Devin Review 第三轮修复已完成

已根据最新反馈修复了 4 个问题:

修复摘要

级别 文件 问题 修复方式
🟡 Message.content (Pydantic 模型) 非 JSON 可序列化 使用 显式转换 ContentPart
🔴 hooks 不触发 根据 error 动态选择事件类型
🔴 hooks 覆盖用户工具拒绝 仅对 执行 hooks
🟡 超时后产生僵尸进程 后添加

详细说明

  1. Message.content 序列化 - Pydantic ContentPart 模型现在使用 正确序列化为 JSON,hook 脚本可正常解析
  2. post-tool-call-failure 事件 - 工具失败时现在正确触发 钩子,符合 AgentHooks 规范
  3. 用户拒绝保护 - 停止原因不再被 hooks 覆盖,尊重用户明确决策
  4. 僵尸进程修复 - 遵循 Python asyncio 最佳实践,确保子进程资源完全回收

Commit:

所有相关测试通过 (71 passed)。


🔧 Devin Review The third round of repairs has been completed

4 issues have been fixed based on latest feedback:

Fix summary

Level File Problem Fix
🟡 Message.content (Pydantic model) non-JSON serializable Use explicit conversion ContentPart
🔴 Hooks are not triggered Dynamically select event type based on error
🔴 hooks override user tool denial Execute hooks only for
🟡 Spawn zombie process after timeout Add later

Detailed description

  1. Message.content serialization - Pydantic ContentPart model is now correctly serialized to JSON, and the hook script can be parsed normally
  2. post-tool-call-failure event - the hook is now correctly triggered when the tool fails, compliant with the AgentHooks specification
  3. User Rejection Protection - The reason for stopping is no longer covered by hooks, respecting the user’s clear decision-making
  4. Zombie process fix - Follow Python asyncio best practices to ensure that child process resources are fully recycled

Commit:

All relevant tests passed (71 passed).

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

…-0001)

Add hooks system core architecture:
- HooksConfig: Pydantic models for configuration
- HookManager: registration, matching, and execution
- Command hook execution engine with stdin/stdout protocol
- Config integration: hooks field in main Config
- Unit tests for config, models, and manager
- Example configs: security.toml and productivity.toml

Features:
- 9 event types: session_start/end, before/after_agent,
  before/after_tool, after_tool_failure, subagent_start/stop, pre_compact
- Matcher system with regex support for tool names and argument patterns
- Exit code semantics: 0=success, 2=block, other=warning
- Async execution with timeout control and fail-open policy
- Environment variables: KIMI_SESSION_ID, KIMI_WORK_DIR, KIMI_ENV_FILE
Fix YAML frontmatter for EPIC-0001 and FEAT-0001~0004:
- Add proper frontmatter with uid, created_at, updated_at
- Move parent and tags into frontmatter
- Fix dependency references
- Add files field for tracking

All issues now pass 'monoco issue lint' checks.
- Add before_tool hook execution in KimiToolset.handle()
- Add after_tool hook execution for post-processing
- Implement hook result caching for performance optimization
- Add hook execution statistics tracking
- Support blocking tool execution with deny decision
- Support fail-open behavior on hook errors
- Add comprehensive unit tests
- 添加 Prompt 类型钩子,支持 LLM 智能决策
- 添加 Agent 类型钩子,支持子 Agent 复杂验证
- 实现 --debug-hooks 参数和调试日志
- 编写中英文文档和 5+ 示例 hooks
- 所有现有测试通过
Remove Prompt and Agent hook types to follow Gemini CLI's design philosophy:
- Remove PromptHookConfig and AgentHookConfig classes
- Remove PromptHookExecutor and AgentHookExecutor
- Simplify HookConfig to single Command type
- Update all tests to use unified HookConfig
- Update documentation and examples
- Fix code style issues (line length, unused imports)

Hooks now only support Command type for transparency and simplicity.
Users can implement LLM-based logic in external scripts if needed.
- 简化 hooks 系统为纯命令类型
- 重构 hooks 模块结构(discovery, executor, parser)
- 更新配置和集成点
- 更新文档和示例
- 移除 Issues/ 和 .monoco/ 目录(保留本地文件)
- 添加忽略规则防止意外提交
- 从 make test 改为 pytest tests/(跳过 e2e 和包测试)
- timeout 从 120s 改为 30s
- 避免每次 stop 都运行全量测试套件
- 添加 hook_manager 到 Runtime fixture
- enforce-tests 只运行 core 和 utils 单元测试,排除 e2e 和工具测试
- 缩短 hook 超时时间为 30s
…locking

The enforce-tests hook was running pytest on every session stop, causing
significant delays and blocking the agent from completing. This change:

- Removes actual test execution from enforce-tests hook
- Hook now only checks for test directory existence
- Always returns exit code 0 (allow) to avoid blocking
- Updates timeout from 30s to 15s
- Updates documentation to reflect new behavior

Tests should be run in CI rather than blocking the agent's pre-stop hook.
Fix 4 issues reported by Devin Review:

1. toolset.py: Add default=str to json.dumps for non-serializable inputs
   - Prevents TypeError when tool_input contains bytes or custom objects

2. toolset.py: Fix stale tool_call_id in cached ToolResult
   - Cache only block_reason instead of full ToolResult
   - Construct new ToolResult with correct tool_call_id on cache hit

3. parser.py: Fix Matcher incorrectly matching None tool_name
   - Return False when tool_pattern is set but tool_name is None
   - Same fix for arg_pattern and arguments

4. kimisoul.py: Add limit to before_stop hook blocks
   - Prevent unbounded LLM calls when hook always blocks
   - Limit set to 3 consecutive blocks with warning log
- executor.py: use create_subprocess_exec instead of create_subprocess_shell
  to prevent hook scripts from silently failing on paths with spaces
  (security hooks could be bypassed due to fail-open policy)

- kimisoul.py: add increment_step_count() call in _step() to ensure
  session_end hooks receive accurate total_steps data

- toolset.py: remove ToolHookCache entirely as it was not part of
  AgentHooks specification and could cause false positives to be
  permanently cached without recovery mechanism
Replace the old config.toml-based hooks documentation with the new
modular Agent Hooks standard:

- Modular directory structure with HOOK.md + scripts/
- User-level (~/.config/agents/hooks/) and project-level (.agents/hooks/)
  layered configuration
- Open standard compatible across agent platforms
- Updated examples for HOOK.md frontmatter and executable scripts

Both zh and en documentation are updated.
…attern

Systematically rename all hook event types to follow the consistent
pre/post-event naming convention:

- session_start → pre-session
- session_end → post-session
- before_agent → pre-agent-turn
- after_agent → post-agent-turn
- before_tool → pre-tool-call
- after_tool → post-tool-call
- after_tool_failure → post-tool-call-failure
- subagent_start → pre-subagent
- subagent_stop → post-subagent
- pre_compact → pre-context-compact
- before_stop → pre-agent-turn-stop (NEW)
- after_stop → post-agent-turn-stop (NEW)

Changes:
- Update HookEventType enum with canonical names and legacy aliases
- Add normalize_trigger() for backward compatibility
- Update all hook definitions and documentation
- Add fire_and_forget() for async hook execution
- Refactor hook manager to remove asyncio task tracking
- Update test suite with new event names
- Add tts-notification hook example

All legacy names remain as aliases for backward compatibility.
- Remove TRIGGER_ALIASES mapping (legacy names -> canonical names)
- Remove normalize_trigger() function
- Remove redundant backwards compatibility alias in manager.py
- Update VALID_TRIGGERS to only include canonical names
- Update discovery.py to use direct trigger comparison
Add /hooks command to list and show information about loaded AgentHooks:
- Show hook directories (user-level and project-level)
- List all discovered hooks grouped by source
- Display hook metadata (trigger, mode, priority, matcher)
- Show execution statistics for current session
- Provide instructions for managing hooks

Extract display logic into new display.py module to keep slash.py clean.
The global-agents-md hook now properly passes content via stdout
instead of writing to the project's AGENTS.md file.
- Fix Message.content JSON serialization for Pydantic models
- Fix post-tool-call-failure hooks never firing (was hardcoded to post-tool-call)
- Fix before_stop hooks overriding explicit user tool rejection
- Fix zombie processes by awaiting proc.wait() after kill on timeout
- Remove .agents/hooks/.logs/session.log from git tracking
- Add ignore rules for agent hooks runtime directories (.logs/, state/, cache/, tmp/)
- Fix hook context accumulation on continued sessions (app.py)
- Fix subprocess resource leak on non-timeout exceptions (executor.py)
- Add exception handling for _execute_pre_session_hooks (agent.py)
- Add exception handling for execute_post_session_hooks (agent.py)

All 67 hook tests pass.
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 32 additional findings in Devin Review.

Open in Devin Review

Comment on lines +198 to +200
if self.arg_pattern is not None:
if arguments is None or not self.arg_pattern.search(str(arguments)):
return False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Matcher pattern matching on str(dict) breaks anchored regex patterns like \.py$

The Matcher.matches() method converts the entire arguments dict to a string via str() before applying re.search(). This means str({"file_path": "/tmp/test.py", "content": "..."}) produces "{'file_path': '/tmp/test.py', 'content': '...'}"—a string ending with }, not .py. Anchored patterns such as \.py$ will therefore never match.

Root Cause and Impact

The Matcher is used in src/kimi_cli/hooks/manager.py:176 to pre-filter which hooks fire for a given event. The auto-format-python hook at .agents/hooks/auto-format-python/HOOK.md:7 specifies pattern: "\\.py$", intending to match only Python files. But because Matcher.matches() at src/kimi_cli/hooks/parser.py:199 does:

if arguments is None or not self.arg_pattern.search(str(arguments)):
    return False

the $ anchor requires the entire dict string to end with .py, which it never will (it ends with }). This silently disables any hook whose matcher uses anchored patterns ($, ^). Specifically, auto-format-python will never fire for Python files. Non-anchored patterns (e.g., rm -rf /|mkfs) are unaffected because re.search finds them anywhere in the string.

Impact: Hooks relying on anchored regex patterns in matcher.pattern are silently broken and will never be executed.

Prompt for agents
Fix the Matcher.matches() method in src/kimi_cli/hooks/parser.py lines 192-202 so that the arg_pattern is tested against individual string values of the arguments dict (not the str() of the entire dict). For example, when arguments is a dict, iterate over the values and apply re.search to each string value. If any value matches, return True for the pattern check. This ensures anchored patterns like '\.py$' work correctly when matching against individual values like file paths. If arguments is not a dict, fall back to str(arguments) as currently done.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant