Skip to content

feat(hooks): event-driven hook system with skill disable support (#1932)#1934

Open
lailoo wants to merge 57 commits intoHKUDS:nightlyfrom
lailoo:feat/skill-disable-clean-1932
Open

feat(hooks): event-driven hook system with skill disable support (#1932)#1934
lailoo wants to merge 57 commits intoHKUDS:nightlyfrom
lailoo:feat/skill-disable-clean-1932

Conversation

@lailoo
Copy link
Copy Markdown
Contributor

@lailoo lailoo commented Mar 12, 2026

Summary

  • Feature: Event-driven hook system + skill disable/enable support
  • Architecture: hooks/ package with HookStorage (JSON state), skills.py unaware of hooks
  • User Extensibility: JSON-based user hooks (no code changes required)

Fixes #1932

Problem

Users reported that skills cannot be disabled, only deleted. The initial implementation modified SKILL.md frontmatter directly, which was invasive and required complex file manipulation (especially for builtin skills).

Solution: Event-Driven Hook System

Instead of modifying skill files, we introduced a hook system that filters skills at context-build time via a separate JSON state file.

Architecture

nanobot/agent/hooks/
├── __init__.py          # Package exports
├── base.py              # HookEvent enum, HookResult dataclass, Hook ABC
├── registry.py          # HookRegistry with emit() event dispatch
├── storage.py           # HookStorage (JSON persistence)
├── filters.py           # SkillsEnabledFilter (built-in hook)
└── json_loader.py       # User-defined hooks via JSON config

Core Concepts (aligned with Claude Code)

Concept Implementation
Lifecycle events HookEvent enum: SessionStart, PreToolUse, PostToolUse, PreBuildContext, Stop
Matcher filtering Hook.matcher regex — only run hook when tool name matches
Block/pass semantics HookResult.proceed — False short-circuits remaining hooks
Priority execution Hook.priority — lower value runs first
Data modification HookResult.modified_data — chained between hooks

Key Design Decisions

  • JSON state vs frontmatter: Skill files are never modified. State stored in hooks/state.json
  • User extensibility: Users can define hooks via .nanobot/hooks.json without code changes
  • Separation of concerns: skills.py unaware of hooks — all filtering happens in context.py
  • Lightweight subagent filtering: Subagents use HookStorage + SkillsLoader directly (no heavy ContextBuilder rebuild)
  • Exception safety: Hook exceptions are caught and logged, never break the chain

Changes

New: nanobot/agent/hooks/ package

  • base.pyHookEvent enum, HookResult dataclass, Hook ABC with on_event(), matcher, priority
  • registry.pyHookRegistry.emit() dispatches events by priority, matcher filtering for tool events, short-circuit on proceed=False
  • filters.pySkillsEnabledFilter listens to PRE_BUILD_CONTEXT to filter disabled skills from prompt
  • storage.pyHookStorage persists disabled skills in JSON
  • json_loader.pyJsonConfigHook loads user-defined hooks from .nanobot/hooks.json, executes shell commands with exit code semantics (0=pass, 2=block)

Modified files

  • nanobot/agent/context.py — Initialize HookRegistry with built-in + JSON hooks; _build_filtered_skills_summary() and _get_filtered_always_skills() emit PRE_BUILD_CONTEXT; _apply_skills_hooks() as unified entry point
  • nanobot/agent/skills.py — Added build_skills_summary_from() to accept pre-filtered skills list; no hook dependency
  • nanobot/agent/loop.py — Inject hook events: SessionStart, PreToolUse (can block), PostToolUse, Stop
  • nanobot/agent/subagent.py — Lightweight filtering via HookStorage + SkillsLoader (respects disabled skills)
  • nanobot/cli/commands.pyskills list/enable/disable commands using HookStorage directly

New documentation

  • docs/USER_HOOKS.md — Comprehensive guide for user-defined hooks with examples

Tests

  • tests/test_hook_system.py — 12 tests: emit, priority, blocking, matcher, data chaining, exception safety
  • tests/test_skill_disable.py — 11 tests: storage, filter, context builder, persistence
  • tests/test_json_hooks.py — 5 tests: JSON loading, blocking, passing, env vars

User Workflow

Built-in: Skill Disable/Enable

# List all skills with status
nanobot skills list

# Disable a skill (state stored in hooks/state.json, skill files untouched)
nanobot skills disable weather

# Re-enable
nanobot skills enable weather

User-Defined Hooks

Create .nanobot/hooks.json:

{
  "hooks": [
    {
      "name": "block-dangerous-commands",
      "event": "PreToolUse",
      "matcher": "^exec$",
      "command": "~/.nanobot/hooks/security-check.sh",
      "priority": 10
    }
  ]
}

See docs/USER_HOOKS.md for more examples.

Test Plan

  • 28 unit tests pass (12 hook system + 11 skill disable + 5 JSON hooks)
  • 12 E2E tests pass (CLI commands + prompt filtering + state persistence)
  • Integration verification: disable → prompt excludes skill → enable → prompt includes skill
  • Event emission verified: SessionStart, PreToolUse, PostToolUse, PreBuildContext, Stop
  • Blocking semantics verified: PreToolUse hook can prevent tool execution
  • Matcher filtering verified: regex-based tool name matching
  • Exception safety verified: broken hooks don't break the chain
  • Subagent filtering verified: subagents respect disabled skills
  • JSON user hooks verified: shell command execution with exit code semantics
  • Backward compatible: no hook = no filtering, zero impact on existing behavior

Future Extensibility

The hook system enables:

  • Security hooks: Block dangerous tool calls via PreToolUse
  • Audit logging: Record tool usage via PostToolUse
  • Custom filters: Register additional hooks without modifying existing code
  • User-defined workflows: JSON config allows users to customize behavior without code changes

@chengyongru
Copy link
Copy Markdown
Collaborator

Hi @lailoo ,

In my perspective , supporting multiple disabled formats is unnecessary. And thanks for your work!

@Re-bin
Copy link
Copy Markdown
Collaborator

Re-bin commented Mar 13, 2026

Hi, very nice feature! Is it ready for reviewing?

@lailoo
Copy link
Copy Markdown
Contributor Author

lailoo commented Mar 13, 2026

Hi @lailoo ,

In my perspective , supporting multiple disabled formats is unnecessary. And thanks for your work!

Hi @chengyongru,thanks for the valuable feedback! I'll optimize it to simplify the disabled format support.

@lailoo
Copy link
Copy Markdown
Contributor Author

lailoo commented Mar 13, 2026

Hi, very nice feature! Is it ready for reviewing?

Hi @Re-bin thanks for your interest! It will be ready very soon - just need to run a full round of testing and verification first.

@lailoo
Copy link
Copy Markdown
Contributor Author

lailoo commented Mar 13, 2026

@Re-bin @chengyongru I've made some updates — would appreciate another look.

I created a test-verify skill to verify the enable/disable functionality across different scenarios.

Test Scenarios

  • Enable → Disable → Enable workflow
  • Verify skill visibility in new sessions after each state change
  • Verify CLI list shows correct status (✓/✗)

Test Steps

  1. Create test skill and verify it's visible in new session
  2. Disable the skill and verify it disappears from new session
  3. Enable the skill and verify it reappears in new session

Test Results

image image

@lailoo
Copy link
Copy Markdown
Contributor Author

lailoo commented Mar 13, 2026

However during testing, I discovered that after disabling a skill, the agent may still "see" the disabled skill within the same conversation session.

Root Causes

  1. Conversation History Interference: If the agent mentioned the skill in previous responses, LLMs tend to maintain consistency with conversation history,
    even after the system prompt has been updated.

  2. File System Visibility: Disabling a skill only sets enabled: false in the frontmatter—the file still exists in the skills/ directory. The agent can
    use list_dir to inspect the filesystem and may conclude the skill is still available upon finding the file. By contrast, uninstalling directly deletes the
    file, so the agent can confirm its absence after checking the directory.

Current Solution

  • Disabled skills are completely removed from the system prompt (same effect as uninstalling)
  • Works correctly in new sessions
  • CLI displays a hint: "Start a new session or use a different session ID (-s flag) to see the change"

Complete Solution (Future Consideration)

Move disabled skills to a hidden directory (e.g., skills/.disabled/) so they physically disappear from the skills/ directory. This would prevent the agent
from discovering them through either the system prompt or filesystem inspection. Skills would be moved back when re-enabled.

This approach has higher implementation complexity and is more invasive, so it's not adopted at this time.


I'd love to hear your thoughts on this approach. If you have any concerns or suggestions for improvement, please let me know!

@lailoo lailoo marked this pull request as ready for review March 13, 2026 13:26
@lailoo lailoo changed the title feat(skills): add support for disabling skills with 'enabled: false' (#1932) feat(hooks): event-driven hook system with skill disable support (#1932) Mar 15, 2026
@lailoo lailoo force-pushed the feat/skill-disable-clean-1932 branch 7 times, most recently from c5e8d78 to d9db03e Compare March 15, 2026 05:58
@lailoo lailoo force-pushed the feat/skill-disable-clean-1932 branch from d9db03e to 87b33be Compare March 15, 2026 06:30
…DS#1932)

Introduce event-driven hook system to replace frontmatter-based skill disable:

Core Architecture:
- hooks/ package: HookEvent enum, HookResult dataclass, Hook ABC
- HookRegistry.emit() dispatches lifecycle events by priority
- SkillsEnabledFilter uses HookStorage (JSON) instead of modifying SKILL.md
- Matcher regex filtering for tool-specific hooks
- Short-circuit on proceed=False for blocking semantics

Lifecycle Events:
- SessionStart: Emitted at session initialization
- PreToolUse: Can block tool execution (proceed=False)
- PostToolUse: Audit/logging after tool execution
- PreBuildContext: Filter skills/tools/memory before context build
- Stop: Cleanup on agent shutdown

Skill Disable Implementation:
- CLI: nanobot skills list/enable/disable
- State stored in hooks/state.json (never modifies skill files)
- Disabled skills filtered from system prompt via SkillsEnabledFilter
- get_always_skills() respects enabled state

Tests:
- 12 hook system tests: emit, priority, blocking, matcher, data chaining
- 11 skill disable tests: storage, filter, context builder, persistence

Fixes HKUDS#1932
@lailoo lailoo force-pushed the feat/skill-disable-clean-1932 branch from 87b33be to 1bc047c Compare March 15, 2026 07:03
@lailoo lailoo changed the base branch from main to nightly March 15, 2026 07:34
@chengyongru chengyongru self-requested a review March 15, 2026 09:32
@Re-bin
Copy link
Copy Markdown
Collaborator

Re-bin commented Mar 15, 2026

However during testing, I discovered that after disabling a skill, the agent may still "see" the disabled skill within the same conversation session.

Root Causes

  1. Conversation History Interference: If the agent mentioned the skill in previous responses, LLMs tend to maintain consistency with conversation history,
    even after the system prompt has been updated.
  2. File System Visibility: Disabling a skill only sets enabled: false in the frontmatter—the file still exists in the skills/ directory. The agent can
    use list_dir to inspect the filesystem and may conclude the skill is still available upon finding the file. By contrast, uninstalling directly deletes the
    file, so the agent can confirm its absence after checking the directory.

Current Solution

  • Disabled skills are completely removed from the system prompt (same effect as uninstalling)
  • Works correctly in new sessions
  • CLI displays a hint: "Start a new session or use a different session ID (-s flag) to see the change"

Complete Solution (Future Consideration)

Move disabled skills to a hidden directory (e.g., skills/.disabled/) so they physically disappear from the skills/ directory. This would prevent the agent from discovering them through either the system prompt or filesystem inspection. Skills would be moved back when re-enabled.

This approach has higher implementation complexity and is more invasive, so it's not adopted at this time.

I'd love to hear your thoughts on this approach. If you have any concerns or suggestions for improvement, please let me know!

Nice idea. Please give me some time to think about this issue carefully :)

@chengyongru
Copy link
Copy Markdown
Collaborator

#1721

Suddenly remembered I had a similar PR before.

@chengyongru chengyongru force-pushed the nightly branch 2 times, most recently from 2e4d681 to 3f9938e Compare March 16, 2026 08:55
xzq-xu and others added 9 commits March 22, 2026 20:45
Without this flag, a BaseException (e.g. CancelledError from /stop)
in one tool would propagate immediately and discard results from the
other concurrent tools, corrupting the OpenAI message format.

With return_exceptions=True, all tool results are collected; any
exception is converted to an error string for the LLM.

Made-with: Cursor
The tz parameter was previously only allowed with cron_expr. When users
specified tz with at for one-time tasks, it returned an error. Now tz
works with both cron_expr and at — naive ISO datetimes are interpreted
in the given timezone via ZoneInfo.

- Relax validation: allow tz with cron_expr or at
- Apply ZoneInfo to naive datetimes in the at branch
- Update SKILL.md with at+tz examples
- Add automated tests for tz+at combinations

Co-authored-by: weitongtong <tongtong.wei@nodeskai.com>
Made-with: Cursor
- Restore enable attribute to ExecToolConfig
- Remove deprecated memory_window field (was removed in f44c4f9 but brought back by cherry-pick)
- Restore exclude=True on openai_codex and github_copilot oauth providers
…conflict resolution

The build_messages() method was missing the current_role parameter that
loop.py calls with, causing a TypeError at runtime. This restores the
parameter with its default value of "user" to match the original PR HKUDS#2104.
ZhangYuanhan-AI and others added 11 commits March 23, 2026 13:58
Add a new WeChat (微信) channel that connects to personal WeChat using
the ilinkai.weixin.qq.com HTTP long-poll API. Protocol reverse-engineered
from @tencent-weixin/openclaw-weixin v1.0.2.

Features:
- QR code login flow (nanobot weixin login)
- HTTP long-poll message receiving (getupdates)
- Text message sending with proper WeixinMessage format
- Media download with AES-128-ECB decryption (image/voice/file/video)
- Voice-to-text from WeChat + Groq Whisper fallback
- Quoted message (ref_msg) support
- Session expiry detection and auto-pause
- Server-suggested poll timeout adaptation
- Context token caching for replies
- Auto-discovery via channel registry

No WebSocket, no Node.js bridge, no local WeChat client needed — pure
HTTP with a bot token obtained via QR code scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cherry-picked from PR HKUDS#2355 (ad128a7) — only agent/context.py and agent/tools/message.py.

Co-Authored-By: qulllee <qullkui@tencent.com>
During testing, we discovered that when a user requests the agent to
send a file (e.g., "send me IMG_1115.png"), the agent would call
read_file to view the content and then reply with text claiming
"file sent" — but never actually deliver the file to the user.

Root cause: The system prompt stated "Reply directly with text for
conversations. Only use the 'message' tool to send to a specific
chat channel", which led the LLM to believe text replies were
sufficient for all responses, including file delivery.

Fix: Add an explicit IMPORTANT instruction in the system prompt
telling the LLM it MUST use the 'message' tool with the 'media'
parameter to send files, and that read_file only reads content
for its own analysis.

Co-Authored-By: qulllee <qullkui@tencent.com>
Previously the WeChat channel's send() method only handled text messages,
completely ignoring msg.media. When the agent called message(media=[...]),
the file was never delivered to the user.

Implement the full WeChat CDN upload protocol following the reference
@tencent-weixin/openclaw-weixin v1.0.2:
  1. Generate a client-side AES-128 key (16 random bytes)
  2. Call getuploadurl with file metadata + hex-encoded AES key
  3. AES-128-ECB encrypt the file and POST to CDN with filekey param
  4. Read x-encrypted-param from CDN response header as download param
  5. Send message with the media item (image/video/file) referencing
     the CDN upload

Also adds:
- _encrypt_aes_ecb() for AES-128-ECB encryption (reverse of existing
  _decrypt_aes_ecb)
- Media type detection from file extension (image/video/file)
- Graceful error handling: failed media sends notify the user via text
  without blocking subsequent text delivery

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ands

Move channel-specific login logic from CLI into each channel class via a
new `login(force=False)` method on BaseChannel. The `channels login <name>`
command now dynamically loads the channel and calls its login() method.

- WeixinChannel.login(): calls existing _qr_login(), with force to clear saved token
- WhatsAppChannel.login(): sets up bridge and spawns npm process for QR login
- CLI no longer contains duplicate login logic per channel
- Update CHANNEL_PLUGIN_GUIDE to document the login() hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolve telegram.py conflict by keeping main's streaming implementation
(_StreamBuf + send_delta with edit_message_text approach) over nightly's
_send_with_streaming (draft-based approach).
urlparse on Windows puts the path in netloc, not path. Use
(parsed.path or parsed.netloc) to get the correct raw path.
* feat: add contextBudgetTokens config field for tool-loop trimming

* feat: implement _trim_history_for_budget for tool-loop cost reduction

* feat: thread contextBudgetTokens into AgentLoop constructor

* feat: wire context budget trimming into agent loop

* refactor: move trim_history_for_budget to helpers and add docs

- Extract trim_history_for_budget() as a pure function in helpers.py
- AgentLoop._trim_history_for_budget becomes a thin wrapper
- Add docs/CONTEXT_BUDGET.md with usage guide and trade-off notes
- Replace wrapper tests with direct helper unit tests

---------

Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
Allow hooks to inject content into the system prompt at build time.
Hooks receive CONTEXT_TYPE, CHANNEL, and CHAT_ID as env vars, and
their stdout is captured as injected content when exit 0. Multiple
hooks accumulate independently, wrapped in <dynamic_context> tags
with a 4000-char safety cap.
…mpt-injection

# Conflicts:
#	nanobot/agent/context.py
#	nanobot/agent/loop.py
#	nanobot/channels/whatsapp.py
#	nanobot/cli/commands.py
#	nanobot/cli/model_info.py
#	nanobot/cli/onboard_wizard.py
#	nanobot/config/loader.py
#	tests/test_commands.py
#	tests/test_config_migration.py
#	tests/test_onboard_logic.py
lailoo and others added 6 commits March 24, 2026 09:07
The --non-interactive flag was renamed to --wizard (inverted logic)
in upstream/nightly. Default mode is now non-interactive.
* feat(feishu): add streaming support via CardKit PATCH API

Implement send_delta() for Feishu channel using interactive card
progressive editing:
- First delta creates a card with markdown content and typing cursor
- Subsequent deltas throttled at 0.5s to respect 5 QPS PATCH limit
- stream_end finalizes with full formatted card (tables, rich markdown)

Also refactors _send_message_sync to return message_id (str | None)
and adds _patch_card_sync for card updates.

Includes 17 new unit tests covering streaming lifecycle, config,
card building, and edge cases.

Made-with: Cursor

* feat(feishu): close CardKit streaming_mode on stream end

Call cardkit card.settings after final content update so chat preview
leaves default [生成中...] summary (Feishu streaming docs).

Made-with: Cursor

* style: polish Feishu streaming (PEP8 spacing, drop unused test imports)

Made-with: Cursor

* docs(feishu): document cardkit:card:write for streaming

- README: permissions, upgrade note for existing apps, streaming toggle
- CHANNEL_PLUGIN_GUIDE: Feishu CardKit scope and when to disable streaming

Made-with: Cursor

* docs: address PR 2382 review (test path, plugin guide, README, English docstrings)

- Move Feishu streaming tests to tests/channels/
- Remove Feishu CardKit scope from CHANNEL_PLUGIN_GUIDE (plugin-dev doc only)
- README Feishu permissions: consistent English
- feishu.py: replace Chinese in streaming docstrings/comments

Made-with: Cursor
…mpt-injection

# Conflicts:
#	nanobot/agent/loop.py
Simulate flobo3's use case end-to-end: a real shell script reads
per-chat_id .md files and injects content through the full pipeline
(shell → JsonConfigHook → HookRegistry → ContextBuilder → system prompt).

Covers: matching file injection, missing file no-op, build_messages
passthrough, large file truncation, and coexistence with SkillsEnabledFilter.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request to-nightly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

技能不支持禁用,只能删除,这个不方便灵活配置