feat(hooks): event-driven hook system with skill disable support (#1932)#1934
feat(hooks): event-driven hook system with skill disable support (#1932)#1934lailoo wants to merge 57 commits intoHKUDS:nightlyfrom
Conversation
|
Hi @lailoo , In my perspective , supporting multiple disabled formats is unnecessary. And thanks for your work! |
|
Hi, very nice feature! Is it ready for reviewing? |
Hi @chengyongru,thanks for the valuable feedback! I'll optimize it to simplify the disabled format support. |
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. |
|
@Re-bin @chengyongru I've made some updates — would appreciate another look. I created a Test Scenarios
Test Steps
Test Results
|
|
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
Current Solution
Complete Solution (Future Consideration)Move disabled skills to a hidden directory (e.g., 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! |
c5e8d78 to
d9db03e
Compare
d9db03e to
87b33be
Compare
…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
87b33be to
1bc047c
Compare
Nice idea. Please give me some time to think about this issue carefully :) |
|
Suddenly remembered I had a similar PR before. |
2e4d681 to
3f9938e
Compare
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.
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
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.


Summary
hooks/package with HookStorage (JSON state), skills.py unaware of hooksFixes #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
Core Concepts (aligned with Claude Code)
HookEventenum: SessionStart, PreToolUse, PostToolUse, PreBuildContext, StopHook.matcherregex — only run hook when tool name matchesHookResult.proceed— False short-circuits remaining hooksHook.priority— lower value runs firstHookResult.modified_data— chained between hooksKey Design Decisions
hooks/state.json.nanobot/hooks.jsonwithout code changesskills.pyunaware of hooks — all filtering happens incontext.pyHookStorage+SkillsLoaderdirectly (no heavy ContextBuilder rebuild)Changes
New:
nanobot/agent/hooks/packagebase.py—HookEventenum,HookResultdataclass,HookABC withon_event(),matcher,priorityregistry.py—HookRegistry.emit()dispatches events by priority, matcher filtering for tool events, short-circuit onproceed=Falsefilters.py—SkillsEnabledFilterlistens toPRE_BUILD_CONTEXTto filter disabled skills from promptstorage.py—HookStoragepersists disabled skills in JSONjson_loader.py—JsonConfigHookloads 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()emitPRE_BUILD_CONTEXT;_apply_skills_hooks()as unified entry pointnanobot/agent/skills.py— Addedbuild_skills_summary_from()to accept pre-filtered skills list; no hook dependencynanobot/agent/loop.py— Inject hook events: SessionStart, PreToolUse (can block), PostToolUse, Stopnanobot/agent/subagent.py— Lightweight filtering viaHookStorage+SkillsLoader(respects disabled skills)nanobot/cli/commands.py—skills list/enable/disablecommands usingHookStoragedirectlyNew documentation
docs/USER_HOOKS.md— Comprehensive guide for user-defined hooks with examplesTests
tests/test_hook_system.py— 12 tests: emit, priority, blocking, matcher, data chaining, exception safetytests/test_skill_disable.py— 11 tests: storage, filter, context builder, persistencetests/test_json_hooks.py— 5 tests: JSON loading, blocking, passing, env varsUser Workflow
Built-in: Skill Disable/Enable
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.mdfor more examples.Test Plan
Future Extensibility
The hook system enables: