Compare commits

..
142 Commits
Author SHA1 Message Date
Re-bin b14d4711c0 release: v0.1.4 2026-02-18 14:31:26 +00:00
Xubin RenandGitHub 92d279924f Merge pull request #802 to enable stream intermediate progress
feat: stream intermediate progress to user during tool execution
2026-02-18 22:28:37 +08:00
Re-bin 715b2db24b feat: stream intermediate progress to user during tool execution 2026-02-18 14:23:51 +00:00
Re-bin ce4f00529e Merge PR #713: scope sessions to workspace with migration and tool metadata 2026-02-18 05:16:00 +00:00
Re-bin 27a131830f refine: migrate legacy sessions on load and simplify get_history 2026-02-18 05:09:57 +00:00
Re-bin 5c61f30546 Merge branch 'main' into pr-713 2026-02-18 04:58:59 +00:00
Re-bin 4c577761e2 Merge PR #630: add SiliconFlow provider 2026-02-18 03:53:00 +00:00
Re-bin 80a5a8c983 feat: add siliconflow provider support 2026-02-18 03:52:53 +00:00
Re-bin df09ba1232 Merge branch 'main' into pr-630 2026-02-18 03:13:00 +00:00
Re-bin 7f8a3dfc0f Merge PR #312: add GitHub Copilot OAuth login and provider status display 2026-02-18 03:09:35 +00:00
Re-bin d54831a35f feat: add github copilot oauth login and improve provider status display 2026-02-18 03:09:09 +00:00
Re-bin 8f6dd8708f Merge branch 'main' into pr-312 2026-02-18 02:57:11 +00:00
Re-bin 74bec26698 Merge branch 'main' of https://github.com/HKUDS/nanobot 2026-02-18 02:51:16 +00:00
ras_botandCursor e5e5f02e73 merge: upstream/main into feat/add-siliconflow-provider, resolve schema conflict
- Keep siliconflow in ProvidersConfig
- Keep openai_codex and github_copilot from upstream/main

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 10:50:15 +08:00
Re-bin 43590145ee Merge PR #784: configurable Slack thread reply and reaction emoji 2026-02-18 02:48:28 +00:00
Xubin RenandGitHub 95fead24e0 Merge pull request #786 to add custom provider with direct openai-compatible support
feat: add custom provider with direct openai-compatible support
2026-02-18 10:40:26 +08:00
Re-bin e2a0d63909 feat: add custom provider with direct openai-compatible support 2026-02-18 02:39:15 +00:00
Jeroen Evens 16127d49f9 [github] Fix Oauth login 2026-02-17 23:07:04 +01:00
Jeroen Evens b161fa4f9a [github] Add Github Copilot 2026-02-17 23:07:04 +01:00
Hyudryu 72db01db63 slack: Added replyInThread logic and custom react emoji in config 2026-02-17 13:42:57 -08:00
Xubin RenandGitHub 831eb07945 docs: update security guideline 2026-02-18 02:00:30 +08:00
Re-bin 05d06b1eb8 docs: update line count 2026-02-17 17:58:36 +00:00
Re-bin ed2aa7fe67 Merge PR #765: add Docker Compose support 2026-02-17 17:56:04 +00:00
Re-bin aad1df5b9b Simplify Docker Compose docs and remove fixed CLI container name 2026-02-17 17:55:48 +00:00
Re-bin fae573573f Merge branch 'main' into pr-765 2026-02-17 17:50:56 +00:00
Re-bin 090b8fb768 Merge PR #746: enable cron tool in CLI agent mode 2026-02-17 17:49:22 +00:00
Re-bin 7d7d6bcadc Merge branch 'main' into pr-746 2026-02-17 17:46:46 +00:00
Re-bin 711d03e8ac Merge PR #766: use Pydantic alias_generator to fix MCP env key conversion 2026-02-17 17:34:31 +00:00
Re-bin 941c3d9826 style: restore single-line formatting for readability 2026-02-17 17:34:24 +00:00
Simon Guigui 4d4d629928 fix(config): mcpServers env variables should not be converted to snake case 2026-02-17 15:19:21 +01:00
Rajasimman S c03f2b670b 🐳 feat: add Docker Compose support for easy deployment
Add docker-compose.yml with gateway and CLI services, resource limits,
and comprehensive documentation for Docker Compose usage.
2026-02-17 18:50:03 +05:30
Re-bin 8053193a36 Merge PR #747: add media file sending support for Telegram 2026-02-17 10:38:05 +00:00
Re-bin 5ad9c837df refactor: clean up telegram media sending logic 2026-02-17 10:37:55 +00:00
Re-bin c81cc07032 Merge branch 'main' into pr-747 2026-02-17 10:24:26 +00:00
Re-bin 79d15e6023 Merge PR #748: avoid sending empty content entries in assistant messages 2026-02-17 08:59:49 +00:00
Re-bin 1db05c881d fix: omit empty content in assistant messages 2026-02-17 08:59:05 +00:00
Re-bin 80d1ff69ad Merge branch 'main' into pr-748 2026-02-17 08:57:27 +00:00
Re-bin d89736a484 Merge PR #720: add GitHub Copilot provider support 2026-02-17 08:41:16 +00:00
Re-bin f5c5b13ff0 refactor: use is_oauth flag instead of hardcoded provider name check 2026-02-17 08:41:09 +00:00
Re-bin 12e59ecaae Merge branch 'main' into pr-720 2026-02-17 08:33:34 +00:00
Re-bin d405dcb5a8 Merge PR #744: add timezone support for cron scheduling 2026-02-17 08:31:00 +00:00
Re-bin 6bae6a617f fix(cron): fix timezone display bug, add tz validation and skill docs 2026-02-17 08:30:52 +00:00
Re-bin 2c3a568e46 Merge branch 'main' into pr-744 2026-02-17 08:21:13 +00:00
Re-bin cf4dce5df0 docs: update clawhub news 2026-02-17 08:20:50 +00:00
Re-bin 8509a81120 docs: update 15/16 Feb news 2026-02-17 08:19:23 +00:00
Xubin RenandGitHub 23726cb802 Merge pull request #758 to add ClawHub skill
feat: add ClawHub skill
2026-02-17 16:15:47 +08:00
Re-bin 5735f9bdce feat: add ClawHub skill for searching and installing agent skills from the public registry 2026-02-17 08:14:16 +00:00
nano bot 56bc8b5677 fix: avoid sending empty content entries in assistant messages 2026-02-17 03:52:08 +00:00
Darye 778a93370a Enable Cron management on CLI Agent. 2026-02-17 03:52:54 +01:00
jopo ae903e983c fix(cron): improve timezone scheduling and tz propagation 2026-02-16 17:49:19 -08:00
DaryeandGitHub 0a2a9a77b7 Merge branch 'HKUDS:main' into telegram-media 2026-02-16 21:08:41 +01:00
Darye 23b7e1ef5e Handle media files (voice messages, audio, images, documents) on Telegram Channel 2026-02-16 16:29:03 +01:00
DaryeandGitHub 96f63aee06 Merge branch 'HKUDS:main' into github_copilot 2026-02-16 15:03:01 +01:00
Darye 5033ac1759 Added Github Copilot Provider 2026-02-16 15:02:12 +01:00
Re-bin a219a91bc5 feat: support openclaw/clawhub skill metadata format 2026-02-16 13:42:33 +00:00
Xubin RenandGitHub 1207b89adb Merge pull request #717 from xek/slack-mrkdwn-formatting
slack: use slackify-markdown for proper mrkdwn formatting
2026-02-16 21:08:21 +08:00
Re-bin b0871497e0 Merge PR #717: use slackify-markdown for Slack formatting 2026-02-16 13:07:06 +00:00
Grzegorz Grasza c9926153b2 Add table-to-text conversion for Slack messages
Slack has no native table support, so Markdown tables are passed
through verbatim by slackify-markdown.  Pre-process tables into
readable key-value rows before converting to mrkdwn.

Assisted-by: Claude 4.6 Opus (Anthropic)
2026-02-16 14:03:33 +01:00
Grzegorz Grasza ed5593bbe0 slack: use slackify-markdown for proper mrkdwn formatting
Replace the regex-based Markdown-to-Slack converter with the
slackify-markdown library, which uses a proper Markdown parser
(markdown-it-py, already a dependency) to correctly handle headings,
bold/italic, code blocks, links, bullet lists, and strikethrough.

The regex approach didn't handle headings (###), bullet lists (* ),
or code block protection, causing raw Markdown to leak into Slack
messages.

Net -40 lines.

Assisted-by: Claude 4.6 Opus (Anthropic)
2026-02-16 13:56:30 +01:00
Re-bin c28e6771a9 Merge PR #694: fix Telegram message too long error 2026-02-16 12:39:45 +00:00
Re-bin db0e8aa61b fix: handle Telegram message length limit with smart splitting 2026-02-16 12:39:39 +00:00
Kiplangatkorir 8f49b52079 Scope sessions to workspace with legacy fallback 2026-02-16 15:22:15 +03:00
Re-bin 48a14edbda Merge branch 'main' into pr-694 2026-02-16 12:16:05 +00:00
Re-bin 3cdb8a0db2 Merge PR #701: fix Telegram command allowlist matching 2026-02-16 12:11:09 +00:00
Re-bin ffbb264a5d fix: consistent sender_id for Telegram command allowlist matching 2026-02-16 12:11:03 +00:00
Re-bin ba923c0205 Merge branch 'main' into pr-701 2026-02-16 12:07:58 +00:00
Re-bin e8e7215d3e refactor: simplify Slack markdown-to-mrkdwn conversion 2026-02-16 11:57:55 +00:00
Re-bin 3706903978 Merge branch 'main' into pr-704 2026-02-16 11:52:02 +00:00
Re-bin 1ce586e9f5 fix: resolve Codex provider bugs and simplify implementation 2026-02-16 11:43:36 +00:00
Re-bin 9e5f7348fe Merge branch 'main' into pr-151 2026-02-16 09:19:40 +00:00
Aleksander W. Oleszkiewicz (Alek)andGitHub fe0341da5b Fix regex for URL formatting in Slack channel 2026-02-16 09:58:38 +01:00
Aleksander W. Oleszkiewicz (Alek)andGitHub 5d683da38f Fix regex for URL and image URL formatting 2026-02-16 09:53:20 +01:00
Aleksander W. Oleszkiewicz (Alek)andGitHub 90be900448 Enhance Slack message formatting with new regex rules
Added regex substitutions for strikethrough, URL formatting, and image URLs in Slack message conversion.
2026-02-16 09:49:44 +01:00
Thomas Lisankie 51d22b7ef4 Fix: _forward_command now builds sender_id with username for allowlist matching 2026-02-16 00:14:34 -05:00
zhouzhuojie 9bfc86af41 refactor(telegram): extract message splitting into helper function
- Added _split_message() helper for cleaner separation of concerns
- Simplified send() method by using the helper
- Net -18 lines for the message splitting feature
2026-02-15 22:49:01 +00:00
zhouzhuojie 203aa154d4 fix(telegram): split long messages to avoid Message is too long error
Telegram has a 4096 character limit per message. This fix:
- Splits messages longer than 4000 chars into multiple chunks
- Prefers breaking at newline boundaries to preserve formatting
- Falls back to space boundaries if no newlines available
- Forces split at max length if no good boundaries exist
- Adds comprehensive tests for message splitting logic
2026-02-15 22:39:31 +00:00
Re-bin a5265c263d docs: update readme structure 2026-02-15 16:41:27 +00:00
Aleksander W. Oleszkiewicz (Alek)andGitHub 7e2d801ffc Implement markdown conversion for Slack messages
Add markdown conversion for Slack messages including italics, bold, and table formatting.
2026-02-15 15:51:19 +01:00
Re-bin 82074a7715 docs: update news section 2026-02-15 14:03:51 +00:00
Xubin RenandGitHub 69f80ec634 Merge pull request #664 to use json_repair for robust LLM response parsing
fix: use json_repair for robust LLM response parsing
2026-02-15 16:12:47 +08:00
Re-bin 49fec3684a fix: use json_repair for robust LLM response parsing 2026-02-15 08:11:33 +00:00
Re-bin 728874179c Merge PR #554: add MCP support 2026-02-15 07:03:08 +00:00
Re-bin 52cf1da30a fix: store original MCP tool name, make close_mcp public 2026-02-15 07:00:27 +00:00
Re-bin 54d5f637e7 merge main into pr-554 2026-02-15 06:12:15 +00:00
Re-bin e2ef1f9d48 docs: add custom provider guideline 2026-02-15 06:02:45 +00:00
Re-bin fd480bb6f5 Merge branch 'main' into pr-625 2026-02-15 05:27:16 +00:00
Xubin RenandGitHub 3411035447 Merge pull request #617 from themavik/fix/523-clamp-max-tokens
fix(providers): clamp max_tokens to >= 1 before calling LiteLLM
2026-02-14 18:02:20 +08:00
Xubin RenandGitHub 6e3f86714c Merge pull request #629 from C-Li/feishu_optmize
增加支持飞书富文本内容接收。Add support for receiving Feishu rich text content.
2026-02-14 17:51:10 +08:00
Zhiwei LiandCursor 66cd21e6ec feat: add SiliconFlow provider support
Add SiliconFlow (硅基流动) as an OpenAI-compatible gateway provider.
SiliconFlow hosts multiple models (Qwen, DeepSeek, etc.) via an
OpenAI-compatible API at https://api.siliconflow.cn/v1.

Changes:
- Add ProviderSpec for siliconflow in providers/registry.py
- Add siliconflow field to ProvidersConfig in config/schema.py

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:27:10 +11:00
Ahwei 5e082690d8 refactor(feishu): support both direct and localized post content formats 2026-02-14 14:37:23 +08:00
Ahwei 4e4eb21d23 feat(feishu): Add rich text message content extraction feature
Newly added the _extract_post_text function to extract plain text content from Feishu rich text messages, supporting the parsing of titles, text, links, and @mentions.
2026-02-14 12:14:31 +08:00
Ahwei d3f6c95ceb refactor(cron): simplify timezone logic and merge conditional branches
With tz: Use the specified timezone (e.g., "Asia/Shanghai").
Without tz: Use the local timezone (datetime.now().astimezone().tzinfo) instead of defaulting to UTC
2026-02-14 10:27:09 +08:00
Ahwei 153c83e340 fix(cron): add timezone support for accurate next run time calculation
When schedule.tz is present, use the specified timezone to calculate the next execution time, ensuring scheduled tasks trigger correctly across different timezones.
2026-02-14 10:23:54 +08:00
Re-bin f821e95d3c fix: wire max_tokens/temperature to all chat calls, clean up redundant comments 2026-02-14 01:40:37 +00:00
Re-bin 155fc48b29 merge: resolve conflict with main, keep extracted _run_agent_loop with temperature 2026-02-14 01:22:17 +00:00
Re-bin 59d5e3cc4f docs: update line count 2026-02-14 01:14:47 +00:00
Re-bin 2f2c55f921 fix: add missing comma and type annotation for temperature param 2026-02-14 01:13:49 +00:00
Re-bin 9a83301ea6 Merge branch 'main' into pr-560 2026-02-14 01:10:51 +00:00
Re-bin d6d73c8167 docs: update .gitignore to remove tests 2026-02-14 01:03:16 +00:00
Re-bin 3b580fd6c8 tests: update test_commands.py 2026-02-14 01:02:58 +00:00
Re-bin 12540ba8cb feat: improve onboard with merge-or-overwrite prompt 2026-02-14 00:58:43 +00:00
Re-bin 835a10e1a9 merge: resolve conflict with main, keep load-merge-save approach 2026-02-14 00:51:29 +00:00
The Mavik 10e9e0cdc9 fix(providers): clamp max_tokens to >= 1 before calling LiteLLM (#523) 2026-02-13 17:08:10 -05:00
Xubin RenandGitHub bc045fae1f Merge pull request #604 to add custom provider and non-destructive onboard
feat: add custom provider and non-destructive onboard
2026-02-14 00:08:40 +08:00
Re-bin b76cf05c3a feat: add custom provider and non-destructive onboard 2026-02-13 16:05:00 +00:00
chengyongru a3f4bb74ff fix: increase max_messages to 500 as temporary workaround
Temporarily increase default max_messages from 50 to 500 to allow
more context in conversations until a proper consolidation strategy
is implemented.
2026-02-13 22:10:53 +08:00
Luke Milby bd55bf5278 cleaned up logic for onboarding 2026-02-13 08:56:37 -05:00
Luke MilbyandGitHub a9d911c80d Merge branch 'HKUDS:main' into feature/onboard_workspace 2026-02-13 08:45:31 -05:00
Luke Milby 8a11490798 updated logic for onboard function not ask for to overwrite workspace since the logic already ensures nothing will be overwritten. Added onboard command tests and removed tests from gitignore 2026-02-13 08:43:49 -05:00
qiupinhua 442136a313 fix: remove uv.lock 2026-02-13 18:52:43 +08:00
qiupinhua 1ae47058d9 fix: refactor code structure for improved readability and maintainability 2026-02-13 18:51:30 +08:00
qiupinhua 09c7e7aded feat: change OAuth login command for providers 2026-02-13 18:37:21 +08:00
Xubin RenandGitHub 3f59a8e234 Merge pull request #593 from C-Li/feishu_fix
Optimize the display of Markdown titles in Lark card information.
2026-02-13 17:02:27 +08:00
chengyongru afc8d50659 test: add comprehensive tests for consolidate offset functionality
Add 26 new test cases covering:
- Consolidation trigger conditions (exceed window, within keep count, no new messages)
- last_consolidated edge cases (exceeds message count, negative value, new messages after consolidation)
- archive_all mode (/new command behavior)
- Cache immutability (messages list never modified during consolidation)
- Slice logic (messages[last_consolidated:-keep_count])
- Empty and boundary sessions (empty, single message, exact keep count, very large)

Refactor tests with helper functions to reduce code duplication by 25%:
- create_session_with_messages() - creates session with specified message count
- assert_messages_content() - validates message content range
- get_old_messages() - encapsulates standard slice logic

All 35 tests passing.
2026-02-13 16:30:43 +08:00
chengyongru 98a762452a fix: useasyncio.create_task to avoid block 2026-02-13 15:36:04 +08:00
Ahwei ccf9a6c146 fix(feishu): convert markdown headings to div elements in card messages
Markdown heading syntax (#) is not properly rendered in Feishu interactive
cards. Convert headings to div elements with lark_md format (bold text) for
proper display.

- Add _HEADING_RE regex to match markdown headings (h1-h6)
- Add _split_headings() method to parse and convert headings to div elements
- Update _build_card_elements() to process headings before markdown content
2026-02-13 15:31:30 +08:00
chengyongru 740294fd74 fix: history messages should not be change[kvcache] 2026-02-13 15:10:07 +08:00
Re-bin 43e2f2605b docs: update v0.1.3.post7 news 2026-02-13 06:26:12 +00:00
wymcmhandGitHub 3e9f6d0b6b Merge branch 'main' into fix/config-temperature 2026-02-13 13:07:37 +08:00
Ahwei e1c359a198 chore: add venv/ to .gitignore 2026-02-13 12:29:45 +08:00
Luke Milby f016025f63 add feature to onboarding that will ask to generate missing workspace files 2026-02-12 22:20:56 -05:00
lemon a3599b97b9 fix: bug #370, support temperature configuration 2026-02-12 19:12:38 +08:00
Sergio Sánchez Vallés d30523f460 fix(mcp): clean up connections on exit in interactive and gateway modes 2026-02-12 10:44:25 +01:00
Sergio Sánchez Vallés 61e9f7f58a chore: revert unrelated changes, keep only MCP support 2026-02-12 10:17:44 +01:00
Sergio Sánchez Vallés 16af3dd1cb Merge branch 'main' into feature/mcp-support 2026-02-12 10:12:49 +01:00
Sergio Sánchez Vallés 7052387f07 Merge branch 'feature/mcp-support' of github.com:SergioSV96/nanobot into feature/mcp-support 2026-02-12 10:12:10 +01:00
Sergio Sánchez Vallés e89afe61f1 feat(tools): add mcp support 2026-02-12 10:09:00 +01:00
Sergio Sánchez Vallés cb5964c201 feat(tools): add mcp support 2026-02-12 10:01:30 +01:00
pinhua33 c6915d27e9 Merge remote-tracking branch 'upstream/main' into feature/codex-oauth 2026-02-10 00:44:03 +08:00
pinhua33 51f97efcb8 refactor: simplify Codex URL handling by removing unnecessary function 2026-02-09 16:04:04 +08:00
pinhua33 fc67d11da9 feat: add OAuth login command for OpenAI Codex 2026-02-09 15:39:30 +08:00
pinhua33 ae908e0dcd Merge upstream/main: resolve conflicts with OAuth support 2026-02-09 15:13:11 +08:00
pinhua33 08efe6ad3f refactor: add OAuth support to provider registry system
- Add is_oauth and oauth_provider fields to ProviderSpec
- Update _make_provider() to use registry for OAuth provider detection
- Update get_provider() to support OAuth providers (no API key required)
- Mark OpenAI Codex as OAuth-based provider in registry

This improves the provider registry architecture to support OAuth-based
authentication flows, making it extensible for future OAuth providers.

Benefits:
- OAuth providers are now registry-driven (not hardcoded)
- Extensible design: new OAuth providers only need registry entry
- Backward compatible: existing API key providers unaffected
- Clean separation: OAuth logic centralized in registry
2026-02-08 16:48:11 +08:00
pinhua33 c1dc8d3f55 fix: integrate OpenAI Codex provider with new registry system
- Add OpenAI Codex ProviderSpec to registry.py
- Add openai_codex config field to ProvidersConfig in schema.py
- Mark Codex as OAuth-based (no API key required)
- Set appropriate default_api_base for Codex API

This integrates the Codex OAuth provider with the refactored
provider registry system introduced in upstream commit 299d8b3.
2026-02-08 16:33:46 +08:00
pinhua33 6bca38b89d Merge remote-tracking branch 'upstream/main' into feature/codex-oauth 2026-02-08 15:47:10 +08:00
pinhua33 5bcfb550d5 Merge remote-tracking branch 'origin/main' into feature/codex-oauth 2026-02-08 13:49:25 +08:00
pinhua33 42c2d83d70 refactor: remove Codex OAuth implementation and integrate oauth-cli-kit 2026-02-08 13:41:47 +08:00
pinhua33 b639192e46 fix: codex tool calling failed unexpectedly 2026-02-06 11:52:03 +08:00
pinhua33 f20afc8d2f feat: add Codex login status to nanobot status command 2026-02-06 00:39:02 +08:00
pinhua33 01420f4dd6 refactor: remove unused functions and simplify code 2026-02-06 00:26:02 +08:00
qiupinhua d4e65319ee refactor: split codex oauth logic to several files 2026-02-05 17:53:00 +08:00
qiupinhua 5bff24096c feat: implement OpenAI Codex OAuth login and provider integration 2026-02-05 17:39:18 +08:00
32 changed files with 2208 additions and 484 deletions
+2 -1
View File
@@ -14,8 +14,9 @@ docs/
*.pywz *.pywz
*.pyzz *.pyzz
.venv/ .venv/
venv/
__pycache__/ __pycache__/
poetry.lock poetry.lock
.pytest_cache/ .pytest_cache/
tests/
botpy.log botpy.log
tests/
+176 -55
View File
@@ -16,11 +16,16 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime) 📏 Real-time line count: **3,761 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News ## 📢 News
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details.
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
@@ -106,14 +111,22 @@ nanobot onboard
**2. Configure** (`~/.nanobot/config.json`) **2. Configure** (`~/.nanobot/config.json`)
For OpenRouter - recommended for global users: Add or merge these **two parts** into your config (other options have defaults).
*Set your API key* (e.g. OpenRouter, recommended for global users):
```json ```json
{ {
"providers": { "providers": {
"openrouter": { "openrouter": {
"apiKey": "sk-or-v1-xxx" "apiKey": "sk-or-v1-xxx"
} }
}, }
}
```
*Set your model*:
```json
{
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5"
@@ -125,63 +138,26 @@ For OpenRouter - recommended for global users:
**3. Chat** **3. Chat**
```bash ```bash
nanobot agent -m "What is 2+2?" nanobot agent
``` ```
That's it! You have a working AI assistant in 2 minutes. That's it! You have a working AI assistant in 2 minutes.
## 🖥️ Local Models (vLLM)
Run nanobot with your own local models using vLLM or any OpenAI-compatible server.
**1. Start your vLLM server**
```bash
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
```
**2. Configure** (`~/.nanobot/config.json`)
```json
{
"providers": {
"vllm": {
"apiKey": "dummy",
"apiBase": "http://localhost:8000/v1"
}
},
"agents": {
"defaults": {
"model": "meta-llama/Llama-3.1-8B-Instruct"
}
}
}
```
**3. Chat**
```bash
nanobot agent -m "Hello from my local LLM!"
```
> [!TIP]
> The `apiKey` can be any non-empty string for local servers that don't require authentication.
## 💬 Chat Apps ## 💬 Chat Apps
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere. Connect nanobot to your favorite chat platform.
| Channel | Setup | | Channel | What you need |
|---------|-------| |---------|---------------|
| **Telegram** | Easy (just a token) | | **Telegram** | Bot token from @BotFather |
| **Discord** | Easy (bot token + intents) | | **Discord** | Bot token + Message Content intent |
| **WhatsApp** | Medium (scan QR) | | **WhatsApp** | QR code scan |
| **Feishu** | Medium (app credentials) | | **Feishu** | App ID + App Secret |
| **Mochat** | Medium (claw token + websocket) | | **Mochat** | Claw token (auto-setup available) |
| **DingTalk** | Medium (app credentials) | | **DingTalk** | App Key + App Secret |
| **Slack** | Medium (bot + app tokens) | | **Slack** | Bot token + App-Level token |
| **Email** | Medium (IMAP/SMTP credentials) | | **Email** | IMAP/SMTP credentials |
| **QQ** | Easy (app credentials) | | **QQ** | App ID + App Secret |
<details> <details>
<summary><b>Telegram</b> (Recommended)</summary> <summary><b>Telegram</b> (Recommended)</summary>
@@ -598,6 +574,7 @@ Config file: `~/.nanobot/config.json`
| Provider | Purpose | Get API Key | | Provider | Purpose | Get API Key |
|----------|---------|-------------| |----------|---------|-------------|
| `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
@@ -606,10 +583,105 @@ Config file: `~/.nanobot/config.json`
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) | | `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `siliconflow` | LLM (SiliconFlow/硅基流动, API gateway) | [siliconflow.cn](https://siliconflow.cn) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — | | `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
<details>
<summary><b>OpenAI Codex (OAuth)</b></summary>
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account.
**1. Login:**
```bash
nanobot provider login openai-codex
```
**2. Set model** (merge into `~/.nanobot/config.json`):
```json
{
"agents": {
"defaults": {
"model": "openai-codex/gpt-5.1-codex"
}
}
}
```
**3. Chat:**
```bash
nanobot agent -m "Hello!"
```
> Docker users: use `docker run -it` for interactive OAuth login.
</details>
<details>
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Bypasses LiteLLM; model name is passed as-is.
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.your-provider.com/v1"
}
},
"agents": {
"defaults": {
"model": "your-model-name"
}
}
}
```
> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
</details>
<details>
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
Run your own model with vLLM or any OpenAI-compatible server, then add to config:
**1. Start the server** (example):
```bash
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
```
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
*Provider (key can be any non-empty string for local):*
```json
{
"providers": {
"vllm": {
"apiKey": "dummy",
"apiBase": "http://localhost:8000/v1"
}
}
}
```
*Model:*
```json
{
"agents": {
"defaults": {
"model": "meta-llama/Llama-3.1-8B-Instruct"
}
}
}
```
</details>
<details> <details>
<summary><b>Adding a New Provider (Developer Guide)</b></summary> <summary><b>Adding a New Provider (Developer Guide)</b></summary>
@@ -656,8 +728,43 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
</details> </details>
### MCP (Model Context Protocol)
> [!TIP]
> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README.
nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external tool servers and use them as native agent tools.
Add MCP servers to your `config.json`:
```json
{
"tools": {
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
}
}
}
}
```
Two transport modes are supported:
| Mode | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
| **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) |
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
### Security ### Security
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
| Option | Default | Description | | Option | Default | Description |
@@ -677,6 +784,7 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
| `nanobot agent --logs` | Show runtime logs during chat | | `nanobot agent --logs` | Show runtime logs during chat |
| `nanobot gateway` | Start the gateway | | `nanobot gateway` | Start the gateway |
| `nanobot status` | Show status | | `nanobot status` | Show status |
| `nanobot provider login openai-codex` | OAuth login for providers |
| `nanobot channels login` | Link WhatsApp (scan QR) | | `nanobot channels login` | Link WhatsApp (scan QR) |
| `nanobot channels status` | Show channel status | | `nanobot channels status` | Show channel status |
@@ -704,7 +812,21 @@ nanobot cron remove <job_id>
> [!TIP] > [!TIP]
> The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts. > The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
Build and run nanobot in a container: ### Docker Compose
```bash
docker compose run --rm nanobot-cli onboard # first-time setup
vim ~/.nanobot/config.json # add API keys
docker compose up -d nanobot-gateway # start gateway
```
```bash
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
docker compose logs -f nanobot-gateway # view logs
docker compose down # stop
```
### Docker
```bash ```bash
# Build the image # Build the image
@@ -752,7 +874,6 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)! **Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)!
- [x] **Voice Transcription** — Support for Groq Whisper (Issue #13)
- [ ] **Multi-modal** — See and hear (images, voice, video) - [ ] **Multi-modal** — See and hear (images, voice, video)
- [ ] **Long-term memory** — Never forget important context - [ ] **Long-term memory** — Never forget important context
- [ ] **Better reasoning** — Multi-step planning and reflection - [ ] **Better reasoning** — Multi-step planning and reflection
+1 -1
View File
@@ -5,7 +5,7 @@
If you discover a security vulnerability in nanobot, please report it by: If you discover a security vulnerability in nanobot, please report it by:
1. **DO NOT** open a public GitHub issue 1. **DO NOT** open a public GitHub issue
2. Create a private security advisory on GitHub or contact the repository maintainers 2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com)
3. Include: 3. Include:
- Description of the vulnerability - Description of the vulnerability
- Steps to reproduce - Steps to reproduce
+31
View File
@@ -0,0 +1,31 @@
x-common-config: &common-config
build:
context: .
dockerfile: Dockerfile
volumes:
- ~/.nanobot:/root/.nanobot
services:
nanobot-gateway:
container_name: nanobot-gateway
<<: *common-config
command: ["gateway"]
restart: unless-stopped
ports:
- 18790:18790
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-cli:
<<: *common-config
profiles:
- cli
command: ["status"]
stdin_open: true
tty: true
+1 -1
View File
@@ -2,5 +2,5 @@
nanobot - A lightweight AI agent framework nanobot - A lightweight AI agent framework
""" """
__version__ = "0.1.0" __version__ = "0.1.4"
__logo__ = "🐈" __logo__ = "🐈"
+10 -6
View File
@@ -105,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool. For normal conversation, just respond with text - do not call the message tool.
Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool. Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
When remembering something important, write to {workspace_path}/memory/MEMORY.md When remembering something important, write to {workspace_path}/memory/MEMORY.md
To recall past events, grep {workspace_path}/memory/HISTORY.md""" To recall past events, grep {workspace_path}/memory/HISTORY.md"""
@@ -225,14 +225,18 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
Returns: Returns:
Updated message list. Updated message list.
""" """
msg: dict[str, Any] = {"role": "assistant", "content": content or ""} msg: dict[str, Any] = {"role": "assistant"}
# Omit empty content — some backends reject empty text blocks
if content:
msg["content"] = content
if tool_calls: if tool_calls:
msg["tool_calls"] = tool_calls msg["tool_calls"] = tool_calls
# Thinking models reject history without this # Include reasoning content when provided (required by some thinking models)
if reasoning_content: if reasoning_content:
msg["reasoning_content"] = reasoning_content msg["reasoning_content"] = reasoning_content
messages.append(msg) messages.append(msg)
return messages return messages
+240 -194
View File
@@ -1,9 +1,12 @@
"""Agent loop: the core processing engine.""" """Agent loop: the core processing engine."""
import asyncio import asyncio
from contextlib import AsyncExitStack
import json import json
import json_repair
from pathlib import Path from pathlib import Path
from typing import Any import re
from typing import Any, Awaitable, Callable
from loguru import logger from loguru import logger
@@ -20,13 +23,13 @@ from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.session.manager import SessionManager from nanobot.session.manager import Session, SessionManager
class AgentLoop: class AgentLoop:
""" """
The agent loop is the core processing engine. The agent loop is the core processing engine.
It: It:
1. Receives messages from the bus 1. Receives messages from the bus
2. Builds context with history, memory, skills 2. Builds context with history, memory, skills
@@ -34,7 +37,7 @@ class AgentLoop:
4. Executes tool calls 4. Executes tool calls
5. Sends responses back 5. Sends responses back
""" """
def __init__( def __init__(
self, self,
bus: MessageBus, bus: MessageBus,
@@ -42,12 +45,15 @@ class AgentLoop:
workspace: Path, workspace: Path,
model: str | None = None, model: str | None = None,
max_iterations: int = 20, max_iterations: int = 20,
temperature: float = 0.7,
max_tokens: int = 4096,
memory_window: int = 50, memory_window: int = 50,
brave_api_key: str | None = None, brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None, exec_config: "ExecToolConfig | None" = None,
cron_service: "CronService | None" = None, cron_service: "CronService | None" = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None, session_manager: SessionManager | None = None,
mcp_servers: dict | None = None,
): ):
from nanobot.config.schema import ExecToolConfig from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
@@ -56,12 +62,14 @@ class AgentLoop:
self.workspace = workspace self.workspace = workspace
self.model = model or provider.get_default_model() self.model = model or provider.get_default_model()
self.max_iterations = max_iterations self.max_iterations = max_iterations
self.temperature = temperature
self.max_tokens = max_tokens
self.memory_window = memory_window self.memory_window = memory_window
self.brave_api_key = brave_api_key self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig() self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.context = ContextBuilder(workspace) self.context = ContextBuilder(workspace)
self.sessions = session_manager or SessionManager(workspace) self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry() self.tools = ToolRegistry()
@@ -70,12 +78,17 @@ class AgentLoop:
workspace=workspace, workspace=workspace,
bus=bus, bus=bus,
model=self.model, model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
brave_api_key=brave_api_key, brave_api_key=brave_api_key,
exec_config=self.exec_config, exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace, restrict_to_workspace=restrict_to_workspace,
) )
self._running = False self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._register_default_tools() self._register_default_tools()
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
@@ -110,126 +123,90 @@ class AgentLoop:
if self.cron_service: if self.cron_service:
self.tools.register(CronTool(self.cron_service)) self.tools.register(CronTool(self.cron_service))
async def run(self) -> None: async def _connect_mcp(self) -> None:
"""Run the agent loop, processing messages from the bus.""" """Connect to configured MCP servers (one-time, lazy)."""
self._running = True if self._mcp_connected or not self._mcp_servers:
logger.info("Agent loop started") return
self._mcp_connected = True
while self._running: from nanobot.agent.tools.mcp import connect_mcp_servers
try: self._mcp_stack = AsyncExitStack()
# Wait for next message await self._mcp_stack.__aenter__()
msg = await asyncio.wait_for( await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
self.bus.consume_inbound(),
timeout=1.0 def _set_tool_context(self, channel: str, chat_id: str) -> None:
) """Update context for all tools that need routing info."""
if message_tool := self.tools.get("message"):
# Process it if isinstance(message_tool, MessageTool):
try: message_tool.set_context(channel, chat_id)
response = await self._process_message(msg)
if response: if spawn_tool := self.tools.get("spawn"):
await self.bus.publish_outbound(response) if isinstance(spawn_tool, SpawnTool):
except Exception as e: spawn_tool.set_context(channel, chat_id)
logger.error(f"Error processing message: {e}")
# Send error response if cron_tool := self.tools.get("cron"):
await self.bus.publish_outbound(OutboundMessage( if isinstance(cron_tool, CronTool):
channel=msg.channel, cron_tool.set_context(channel, chat_id)
chat_id=msg.chat_id,
content=f"Sorry, I encountered an error: {str(e)}" @staticmethod
)) def _strip_think(text: str | None) -> str | None:
except asyncio.TimeoutError: """Remove <think>…</think> blocks that some models embed in content."""
continue if not text:
return None
def stop(self) -> None: return re.sub(r"<think>[\s\S]*?</think>", "", text).strip() or None
"""Stop the agent loop."""
self._running = False @staticmethod
logger.info("Agent loop stopping") def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint, e.g. 'web_search("query")'."""
async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None: def _fmt(tc):
val = next(iter(tc.arguments.values()), None) if tc.arguments else None
if not isinstance(val, str):
return tc.name
return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
async def _run_agent_loop(
self,
initial_messages: list[dict],
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[str | None, list[str]]:
""" """
Process a single inbound message. Run the agent iteration loop.
Args: Args:
msg: The inbound message to process. initial_messages: Starting messages for the LLM conversation.
session_key: Override session key (used by process_direct). on_progress: Optional callback to push intermediate content to the user.
Returns: Returns:
The response message, or None if no response needed. Tuple of (final_content, list_of_tools_used).
""" """
# Handle system messages (subagent announces) messages = initial_messages
# The chat_id contains the original "channel:chat_id" to route back to
if msg.channel == "system":
return await self._process_system_message(msg)
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
# Get or create session
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
await self._consolidate_memory(session, archive_all=True)
session.clear()
self.sessions.save(session)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 New session started. Memory consolidated.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
# Consolidate memory before processing if session is too large
if len(session.messages) > self.memory_window:
await self._consolidate_memory(session)
# Update tool contexts
message_tool = self.tools.get("message")
if isinstance(message_tool, MessageTool):
message_tool.set_context(msg.channel, msg.chat_id)
spawn_tool = self.tools.get("spawn")
if isinstance(spawn_tool, SpawnTool):
spawn_tool.set_context(msg.channel, msg.chat_id)
cron_tool = self.tools.get("cron")
if isinstance(cron_tool, CronTool):
cron_tool.set_context(msg.channel, msg.chat_id)
# Build initial messages (use get_history for LLM-formatted messages)
messages = self.context.build_messages(
history=session.get_history(),
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
)
# Agent loop
iteration = 0 iteration = 0
final_content = None final_content = None
tools_used: list[str] = [] tools_used: list[str] = []
while iteration < self.max_iterations: while iteration < self.max_iterations:
iteration += 1 iteration += 1
# Call LLM
response = await self.provider.chat( response = await self.provider.chat(
messages=messages, messages=messages,
tools=self.tools.get_definitions(), tools=self.tools.get_definitions(),
model=self.model model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
) )
# Handle tool calls
if response.has_tool_calls: if response.has_tool_calls:
# Add assistant message with tool calls if on_progress:
clean = self._strip_think(response.content)
await on_progress(clean or self._tool_hint(response.tool_calls))
tool_call_dicts = [ tool_call_dicts = [
{ {
"id": tc.id, "id": tc.id,
"type": "function", "type": "function",
"function": { "function": {
"name": tc.name, "name": tc.name,
"arguments": json.dumps(tc.arguments) # Must be JSON string "arguments": json.dumps(tc.arguments)
} }
} }
for tc in response.tool_calls for tc in response.tool_calls
@@ -238,8 +215,7 @@ class AgentLoop:
messages, response.content, tool_call_dicts, messages, response.content, tool_call_dicts,
reasoning_content=response.reasoning_content, reasoning_content=response.reasoning_content,
) )
# Execute tools
for tool_call in response.tool_calls: for tool_call in response.tool_calls:
tools_used.append(tool_call.name) tools_used.append(tool_call.name)
args_str = json.dumps(tool_call.arguments, ensure_ascii=False) args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
@@ -248,24 +224,128 @@ class AgentLoop:
messages = self.context.add_tool_result( messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result messages, tool_call.id, tool_call.name, result
) )
# Interleaved CoT: reflect before next action
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else: else:
# No tool calls, we're done final_content = self._strip_think(response.content)
final_content = response.content
break break
return final_content, tools_used
async def run(self) -> None:
"""Run the agent loop, processing messages from the bus."""
self._running = True
await self._connect_mcp()
logger.info("Agent loop started")
while self._running:
try:
msg = await asyncio.wait_for(
self.bus.consume_inbound(),
timeout=1.0
)
try:
response = await self._process_message(msg)
if response:
await self.bus.publish_outbound(response)
except Exception as e:
logger.error(f"Error processing message: {e}")
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=f"Sorry, I encountered an error: {str(e)}"
))
except asyncio.TimeoutError:
continue
async def close_mcp(self) -> None:
"""Close MCP connections."""
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass # MCP SDK cancel scope cleanup is noisy but harmless
self._mcp_stack = None
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
async def _process_message(
self,
msg: InboundMessage,
session_key: str | None = None,
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> OutboundMessage | None:
"""
Process a single inbound message.
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
on_progress: Optional callback for intermediate output (defaults to bus publish).
Returns:
The response message, or None if no response needed.
"""
# System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
# Capture messages before clearing (avoid race condition with background task)
messages_to_archive = session.messages.copy()
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session.key)
async def _consolidate_and_cleanup():
temp_session = Session(key=session.key)
temp_session.messages = messages_to_archive
await self._consolidate_memory(temp_session, archive_all=True)
asyncio.create_task(_consolidate_and_cleanup())
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started. Memory consolidation in progress.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
if len(session.messages) > self.memory_window:
asyncio.create_task(self._consolidate_memory(session))
self._set_tool_context(msg.channel, msg.chat_id)
initial_messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
)
async def _bus_progress(content: str) -> None:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
metadata=msg.metadata or {},
))
final_content, tools_used = await self._run_agent_loop(
initial_messages, on_progress=on_progress or _bus_progress,
)
if final_content is None: if final_content is None:
if iteration >= self.max_iterations: final_content = "I've completed processing but have no response to give."
final_content = f"Reached {self.max_iterations} iterations without completion."
else:
final_content = "I've completed processing but have no response to give."
# Log response preview
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}") logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
# Save to session (include tool names so consolidation sees what happened)
session.add_message("user", msg.content) session.add_message("user", msg.content)
session.add_message("assistant", final_content, session.add_message("assistant", final_content,
tools_used=tools_used if tools_used else None) tools_used=tools_used if tools_used else None)
@@ -297,78 +377,20 @@ class AgentLoop:
origin_channel = "cli" origin_channel = "cli"
origin_chat_id = msg.chat_id origin_chat_id = msg.chat_id
# Use the origin session for context
session_key = f"{origin_channel}:{origin_chat_id}" session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key) session = self.sessions.get_or_create(session_key)
self._set_tool_context(origin_channel, origin_chat_id)
# Update tool contexts initial_messages = self.context.build_messages(
message_tool = self.tools.get("message") history=session.get_history(max_messages=self.memory_window),
if isinstance(message_tool, MessageTool):
message_tool.set_context(origin_channel, origin_chat_id)
spawn_tool = self.tools.get("spawn")
if isinstance(spawn_tool, SpawnTool):
spawn_tool.set_context(origin_channel, origin_chat_id)
cron_tool = self.tools.get("cron")
if isinstance(cron_tool, CronTool):
cron_tool.set_context(origin_channel, origin_chat_id)
# Build messages with the announce content
messages = self.context.build_messages(
history=session.get_history(),
current_message=msg.content, current_message=msg.content,
channel=origin_channel, channel=origin_channel,
chat_id=origin_chat_id, chat_id=origin_chat_id,
) )
final_content, _ = await self._run_agent_loop(initial_messages)
# Agent loop (limited for announce handling)
iteration = 0
final_content = None
while iteration < self.max_iterations:
iteration += 1
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
model=self.model
)
if response.has_tool_calls:
tool_call_dicts = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments)
}
}
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
messages, response.content, tool_call_dicts,
reasoning_content=response.reasoning_content,
)
for tool_call in response.tool_calls:
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
result = await self.tools.execute(tool_call.name, tool_call.arguments)
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
# Interleaved CoT: reflect before next action
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
final_content = response.content
break
if final_content is None: if final_content is None:
final_content = "Background task completed." final_content = "Background task completed."
# Save to session (mark as system message in history)
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content) session.add_message("assistant", final_content)
self.sessions.save(session) self.sessions.save(session)
@@ -380,21 +402,34 @@ class AgentLoop:
) )
async def _consolidate_memory(self, session, archive_all: bool = False) -> None: async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md, then trim session.""" """Consolidate old messages into MEMORY.md + HISTORY.md.
if not session.messages:
return Args:
archive_all: If True, clear all messages and reset session (for /new command).
If False, only write to files without modifying session.
"""
memory = MemoryStore(self.workspace) memory = MemoryStore(self.workspace)
if archive_all: if archive_all:
old_messages = session.messages old_messages = session.messages
keep_count = 0 keep_count = 0
logger.info(f"Memory consolidation (archive_all): {len(session.messages)} total messages archived")
else: else:
keep_count = min(10, max(2, self.memory_window // 2)) keep_count = self.memory_window // 2
old_messages = session.messages[:-keep_count] if len(session.messages) <= keep_count:
if not old_messages: logger.debug(f"Session {session.key}: No consolidation needed (messages={len(session.messages)}, keep={keep_count})")
return return
logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
messages_to_process = len(session.messages) - session.last_consolidated
if messages_to_process <= 0:
logger.debug(f"Session {session.key}: No new messages to consolidate (last_consolidated={session.last_consolidated}, total={len(session.messages)})")
return
old_messages = session.messages[session.last_consolidated:-keep_count]
if not old_messages:
return
logger.info(f"Memory consolidation started: {len(session.messages)} total, {len(old_messages)} new to consolidate, {keep_count} keep")
# Format messages for LLM (include tool names when available)
lines = [] lines = []
for m in old_messages: for m in old_messages:
if not m.get("content"): if not m.get("content"):
@@ -427,9 +462,15 @@ Respond with ONLY valid JSON, no markdown fences."""
model=self.model, model=self.model,
) )
text = (response.content or "").strip() text = (response.content or "").strip()
if not text:
logger.warning("Memory consolidation: LLM returned empty response, skipping")
return
if text.startswith("```"): if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = json.loads(text) result = json_repair.loads(text)
if not isinstance(result, dict):
logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}")
return
if entry := result.get("history_entry"): if entry := result.get("history_entry"):
memory.append_history(entry) memory.append_history(entry)
@@ -437,9 +478,11 @@ Respond with ONLY valid JSON, no markdown fences."""
if update != current_memory: if update != current_memory:
memory.write_long_term(update) memory.write_long_term(update)
session.messages = session.messages[-keep_count:] if keep_count else [] if archive_all:
self.sessions.save(session) session.last_consolidated = 0
logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages") else:
session.last_consolidated = len(session.messages) - keep_count
logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}")
except Exception as e: except Exception as e:
logger.error(f"Memory consolidation failed: {e}") logger.error(f"Memory consolidation failed: {e}")
@@ -449,6 +492,7 @@ Respond with ONLY valid JSON, no markdown fences."""
session_key: str = "cli:direct", session_key: str = "cli:direct",
channel: str = "cli", channel: str = "cli",
chat_id: str = "direct", chat_id: str = "direct",
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> str: ) -> str:
""" """
Process a message directly (for CLI or cron usage). Process a message directly (for CLI or cron usage).
@@ -458,10 +502,12 @@ Respond with ONLY valid JSON, no markdown fences."""
session_key: Session identifier (overrides channel:chat_id for session lookup). session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing). channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing). chat_id: Source chat ID (for tool context routing).
on_progress: Optional callback for intermediate output.
Returns: Returns:
The agent's response. The agent's response.
""" """
await self._connect_mcp()
msg = InboundMessage( msg = InboundMessage(
channel=channel, channel=channel,
sender_id="user", sender_id="user",
@@ -469,5 +515,5 @@ Respond with ONLY valid JSON, no markdown fences."""
content=content content=content
) )
response = await self._process_message(msg, session_key=session_key) response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else "" return response.content if response else ""
+2 -2
View File
@@ -167,10 +167,10 @@ class SkillsLoader:
return content return content
def _parse_nanobot_metadata(self, raw: str) -> dict: def _parse_nanobot_metadata(self, raw: str) -> dict:
"""Parse nanobot metadata JSON from frontmatter.""" """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
try: try:
data = json.loads(raw) data = json.loads(raw)
return data.get("nanobot", {}) if isinstance(data, dict) else {} return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return {} return {}
+6
View File
@@ -32,6 +32,8 @@ class SubagentManager:
workspace: Path, workspace: Path,
bus: MessageBus, bus: MessageBus,
model: str | None = None, model: str | None = None,
temperature: float = 0.7,
max_tokens: int = 4096,
brave_api_key: str | None = None, brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None, exec_config: "ExecToolConfig | None" = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
@@ -41,6 +43,8 @@ class SubagentManager:
self.workspace = workspace self.workspace = workspace
self.bus = bus self.bus = bus
self.model = model or provider.get_default_model() self.model = model or provider.get_default_model()
self.temperature = temperature
self.max_tokens = max_tokens
self.brave_api_key = brave_api_key self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig() self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
@@ -130,6 +134,8 @@ class SubagentManager:
messages=messages, messages=messages,
tools=tools.get_definitions(), tools=tools.get_definitions(),
model=self.model, model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
) )
if response.has_tool_calls: if response.has_tool_calls:
+23 -3
View File
@@ -50,6 +50,10 @@ class CronTool(Tool):
"type": "string", "type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)" "description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
}, },
"tz": {
"type": "string",
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
},
"at": { "at": {
"type": "string", "type": "string",
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')" "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
@@ -68,30 +72,46 @@ class CronTool(Tool):
message: str = "", message: str = "",
every_seconds: int | None = None, every_seconds: int | None = None,
cron_expr: str | None = None, cron_expr: str | None = None,
tz: str | None = None,
at: str | None = None, at: str | None = None,
job_id: str | None = None, job_id: str | None = None,
**kwargs: Any **kwargs: Any
) -> str: ) -> str:
if action == "add": if action == "add":
return self._add_job(message, every_seconds, cron_expr, at) return self._add_job(message, every_seconds, cron_expr, tz, at)
elif action == "list": elif action == "list":
return self._list_jobs() return self._list_jobs()
elif action == "remove": elif action == "remove":
return self._remove_job(job_id) return self._remove_job(job_id)
return f"Unknown action: {action}" return f"Unknown action: {action}"
def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str: def _add_job(
self,
message: str,
every_seconds: int | None,
cron_expr: str | None,
tz: str | None,
at: str | None,
) -> str:
if not message: if not message:
return "Error: message is required for add" return "Error: message is required for add"
if not self._channel or not self._chat_id: if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)" return "Error: no session context (channel/chat_id)"
if tz and not cron_expr:
return "Error: tz can only be used with cron_expr"
if tz:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
# Build schedule # Build schedule
delete_after = False delete_after = False
if every_seconds: if every_seconds:
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif cron_expr: elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr) schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at: elif at:
from datetime import datetime from datetime import datetime
dt = datetime.fromisoformat(at) dt = datetime.fromisoformat(at)
+80
View File
@@ -0,0 +1,80 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
from contextlib import AsyncExitStack
from typing import Any
from loguru import logger
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
class MCPToolWrapper(Tool):
"""Wraps a single MCP server tool as a nanobot Tool."""
def __init__(self, session, server_name: str, tool_def):
self._session = session
self._original_name = tool_def.name
self._name = f"mcp_{server_name}_{tool_def.name}"
self._description = tool_def.description or tool_def.name
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def parameters(self) -> dict[str, Any]:
return self._parameters
async def execute(self, **kwargs: Any) -> str:
from mcp import types
result = await self._session.call_tool(self._original_name, arguments=kwargs)
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
) -> None:
"""Connect to configured MCP servers and register their tools."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
for name, cfg in mcp_servers.items():
try:
if cfg.command:
params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=cfg.env or None
)
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url)
)
else:
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
continue
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
wrapper = MCPToolWrapper(session, name, tool_def)
registry.register(wrapper)
logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
except Exception as e:
logger.error(f"MCP server '{name}': failed to connect: {e}")
+10 -2
View File
@@ -52,6 +52,11 @@ class MessageTool(Tool):
"chat_id": { "chat_id": {
"type": "string", "type": "string",
"description": "Optional: target chat/user ID" "description": "Optional: target chat/user ID"
},
"media": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: list of file paths to attach (images, audio, documents)"
} }
}, },
"required": ["content"] "required": ["content"]
@@ -62,6 +67,7 @@ class MessageTool(Tool):
content: str, content: str,
channel: str | None = None, channel: str | None = None,
chat_id: str | None = None, chat_id: str | None = None,
media: list[str] | None = None,
**kwargs: Any **kwargs: Any
) -> str: ) -> str:
channel = channel or self._default_channel channel = channel or self._default_channel
@@ -76,11 +82,13 @@ class MessageTool(Tool):
msg = OutboundMessage( msg = OutboundMessage(
channel=channel, channel=channel,
chat_id=chat_id, chat_id=chat_id,
content=content content=content,
media=media or []
) )
try: try:
await self._send_callback(msg) await self._send_callback(msg)
return f"Message sent to {channel}:{chat_id}" media_info = f" with {len(media)} attachments" if media else ""
return f"Message sent to {channel}:{chat_id}{media_info}"
except Exception as e: except Exception as e:
return f"Error sending message: {str(e)}" return f"Error sending message: {str(e)}"
+97 -5
View File
@@ -39,6 +39,53 @@ MSG_TYPE_MAP = {
} }
def _extract_post_text(content_json: dict) -> str:
"""Extract plain text from Feishu post (rich text) message content.
Supports two formats:
1. Direct format: {"title": "...", "content": [...]}
2. Localized format: {"zh_cn": {"title": "...", "content": [...]}}
"""
def extract_from_lang(lang_content: dict) -> str | None:
if not isinstance(lang_content, dict):
return None
title = lang_content.get("title", "")
content_blocks = lang_content.get("content", [])
if not isinstance(content_blocks, list):
return None
text_parts = []
if title:
text_parts.append(title)
for block in content_blocks:
if not isinstance(block, list):
continue
for element in block:
if isinstance(element, dict):
tag = element.get("tag")
if tag == "text":
text_parts.append(element.get("text", ""))
elif tag == "a":
text_parts.append(element.get("text", ""))
elif tag == "at":
text_parts.append(f"@{element.get('user_name', 'user')}")
return " ".join(text_parts).strip() if text_parts else None
# Try direct format first
if "content" in content_json:
result = extract_from_lang(content_json)
if result:
return result
# Try localized format
for lang_key in ("zh_cn", "en_us", "ja_jp"):
lang_content = content_json.get(lang_key)
result = extract_from_lang(lang_content)
if result:
return result
return ""
class FeishuChannel(BaseChannel): class FeishuChannel(BaseChannel):
""" """
Feishu/Lark channel using WebSocket long connection. Feishu/Lark channel using WebSocket long connection.
@@ -166,6 +213,10 @@ class FeishuChannel(BaseChannel):
re.MULTILINE, re.MULTILINE,
) )
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
_CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
@staticmethod @staticmethod
def _parse_md_table(table_text: str) -> dict | None: def _parse_md_table(table_text: str) -> dict | None:
"""Parse a markdown table into a Feishu table element.""" """Parse a markdown table into a Feishu table element."""
@@ -185,17 +236,52 @@ class FeishuChannel(BaseChannel):
} }
def _build_card_elements(self, content: str) -> list[dict]: def _build_card_elements(self, content: str) -> list[dict]:
"""Split content into markdown + table elements for Feishu card.""" """Split content into div/markdown + table elements for Feishu card."""
elements, last_end = [], 0 elements, last_end = [], 0
for m in self._TABLE_RE.finditer(content): for m in self._TABLE_RE.finditer(content):
before = content[last_end:m.start()].strip() before = content[last_end:m.start()]
if before: if before.strip():
elements.append({"tag": "markdown", "content": before}) elements.extend(self._split_headings(before))
elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}) elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)})
last_end = m.end() last_end = m.end()
remaining = content[last_end:].strip() remaining = content[last_end:]
if remaining.strip():
elements.extend(self._split_headings(remaining))
return elements or [{"tag": "markdown", "content": content}]
def _split_headings(self, content: str) -> list[dict]:
"""Split content by headings, converting headings to div elements."""
protected = content
code_blocks = []
for m in self._CODE_BLOCK_RE.finditer(content):
code_blocks.append(m.group(1))
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks)-1}\x00", 1)
elements = []
last_end = 0
for m in self._HEADING_RE.finditer(protected):
before = protected[last_end:m.start()].strip()
if before:
elements.append({"tag": "markdown", "content": before})
level = len(m.group(1))
text = m.group(2).strip()
elements.append({
"tag": "div",
"text": {
"tag": "lark_md",
"content": f"**{text}**",
},
})
last_end = m.end()
remaining = protected[last_end:].strip()
if remaining: if remaining:
elements.append({"tag": "markdown", "content": remaining}) elements.append({"tag": "markdown", "content": remaining})
for i, cb in enumerate(code_blocks):
for el in elements:
if el.get("tag") == "markdown":
el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
return elements or [{"tag": "markdown", "content": content}] return elements or [{"tag": "markdown", "content": content}]
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
@@ -287,6 +373,12 @@ class FeishuChannel(BaseChannel):
content = json.loads(message.content).get("text", "") content = json.loads(message.content).get("text", "")
except json.JSONDecodeError: except json.JSONDecodeError:
content = message.content or "" content = message.content or ""
elif msg_type == "post":
try:
content_json = json.loads(message.content)
content = _extract_post_text(content_json)
except (json.JSONDecodeError, TypeError):
content = message.content or ""
else: else:
content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]") content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
+35 -3
View File
@@ -10,6 +10,8 @@ from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.socket_mode.response import SocketModeResponse
from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_client import AsyncWebClient
from slackify_markdown import slackify_markdown
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
@@ -84,7 +86,7 @@ class SlackChannel(BaseChannel):
use_thread = thread_ts and channel_type != "im" use_thread = thread_ts and channel_type != "im"
await self._web_client.chat_postMessage( await self._web_client.chat_postMessage(
channel=msg.chat_id, channel=msg.chat_id,
text=msg.content or "", text=self._to_mrkdwn(msg.content),
thread_ts=thread_ts if use_thread else None, thread_ts=thread_ts if use_thread else None,
) )
except Exception as e: except Exception as e:
@@ -150,13 +152,15 @@ class SlackChannel(BaseChannel):
text = self._strip_bot_mention(text) text = self._strip_bot_mention(text)
thread_ts = event.get("thread_ts") or event.get("ts") thread_ts = event.get("thread_ts")
if self.config.reply_in_thread and not thread_ts:
thread_ts = event.get("ts")
# Add :eyes: reaction to the triggering message (best-effort) # Add :eyes: reaction to the triggering message (best-effort)
try: try:
if self._web_client and event.get("ts"): if self._web_client and event.get("ts"):
await self._web_client.reactions_add( await self._web_client.reactions_add(
channel=chat_id, channel=chat_id,
name="eyes", name=self.config.react_emoji,
timestamp=event.get("ts"), timestamp=event.get("ts"),
) )
except Exception as e: except Exception as e:
@@ -203,3 +207,31 @@ class SlackChannel(BaseChannel):
if not text or not self._bot_user_id: if not text or not self._bot_user_id:
return text return text
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
_TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*")
@classmethod
def _to_mrkdwn(cls, text: str) -> str:
"""Convert Markdown to Slack mrkdwn, including tables."""
if not text:
return ""
text = cls._TABLE_RE.sub(cls._convert_table, text)
return slackify_markdown(text)
@staticmethod
def _convert_table(match: re.Match) -> str:
"""Convert a Markdown table to a Slack-readable list."""
lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()]
if len(lines) < 2:
return match.group(0)
headers = [h.strip() for h in lines[0].strip("|").split("|")]
start = 2 if re.fullmatch(r"[|\s:\-]+", lines[1]) else 1
rows: list[str] = []
for line in lines[start:]:
cells = [c.strip() for c in line.strip("|").split("|")]
cells = (cells + [""] * len(headers))[: len(headers)]
parts = [f"**{headers[i]}**: {cells[i]}" for i in range(len(headers)) if cells[i]]
if parts:
rows.append(" · ".join(parts))
return "\n".join(rows)
+72 -26
View File
@@ -78,6 +78,26 @@ def _markdown_to_telegram_html(text: str) -> str:
return text return text
def _split_message(content: str, max_len: int = 4000) -> list[str]:
"""Split content into chunks within max_len, preferring line breaks."""
if len(content) <= max_len:
return [content]
chunks: list[str] = []
while content:
if len(content) <= max_len:
chunks.append(content)
break
cut = content[:max_len]
pos = cut.rfind('\n')
if pos == -1:
pos = cut.rfind(' ')
if pos == -1:
pos = max_len
chunks.append(content[:pos])
content = content[pos:].lstrip()
return chunks
class TelegramChannel(BaseChannel): class TelegramChannel(BaseChannel):
""" """
Telegram channel using long polling. Telegram channel using long polling.
@@ -178,37 +198,61 @@ class TelegramChannel(BaseChannel):
await self._app.shutdown() await self._app.shutdown()
self._app = None self._app = None
@staticmethod
def _get_media_type(path: str) -> str:
"""Guess media type from file extension."""
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
if ext in ("jpg", "jpeg", "png", "gif", "webp"):
return "photo"
if ext == "ogg":
return "voice"
if ext in ("mp3", "m4a", "wav", "aac"):
return "audio"
return "document"
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Telegram.""" """Send a message through Telegram."""
if not self._app: if not self._app:
logger.warning("Telegram bot not running") logger.warning("Telegram bot not running")
return return
# Stop typing indicator for this chat
self._stop_typing(msg.chat_id) self._stop_typing(msg.chat_id)
try: try:
# chat_id should be the Telegram chat ID (integer)
chat_id = int(msg.chat_id) chat_id = int(msg.chat_id)
# Convert markdown to Telegram HTML
html_content = _markdown_to_telegram_html(msg.content)
await self._app.bot.send_message(
chat_id=chat_id,
text=html_content,
parse_mode="HTML"
)
except ValueError: except ValueError:
logger.error(f"Invalid chat_id: {msg.chat_id}") logger.error(f"Invalid chat_id: {msg.chat_id}")
except Exception as e: return
# Fallback to plain text if HTML parsing fails
logger.warning(f"HTML parse failed, falling back to plain text: {e}") # Send media files
for media_path in (msg.media or []):
try: try:
await self._app.bot.send_message( media_type = self._get_media_type(media_path)
chat_id=int(msg.chat_id), sender = {
text=msg.content "photo": self._app.bot.send_photo,
) "voice": self._app.bot.send_voice,
except Exception as e2: "audio": self._app.bot.send_audio,
logger.error(f"Error sending Telegram message: {e2}") }.get(media_type, self._app.bot.send_document)
param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document"
with open(media_path, 'rb') as f:
await sender(chat_id=chat_id, **{param: f})
except Exception as e:
filename = media_path.rsplit("/", 1)[-1]
logger.error(f"Failed to send media {media_path}: {e}")
await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]")
# Send text content
if msg.content and msg.content != "[empty message]":
for chunk in _split_message(msg.content):
try:
html = _markdown_to_telegram_html(chunk)
await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML")
except Exception as e:
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
try:
await self._app.bot.send_message(chat_id=chat_id, text=chunk)
except Exception as e2:
logger.error(f"Error sending Telegram message: {e2}")
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command.""" """Handle /start command."""
@@ -222,12 +266,18 @@ class TelegramChannel(BaseChannel):
"Type /help to see available commands." "Type /help to see available commands."
) )
@staticmethod
def _sender_id(user) -> str:
"""Build sender_id with username for allowlist matching."""
sid = str(user.id)
return f"{sid}|{user.username}" if user.username else sid
async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Forward slash commands to the bus for unified handling in AgentLoop.""" """Forward slash commands to the bus for unified handling in AgentLoop."""
if not update.message or not update.effective_user: if not update.message or not update.effective_user:
return return
await self._handle_message( await self._handle_message(
sender_id=str(update.effective_user.id), sender_id=self._sender_id(update.effective_user),
chat_id=str(update.message.chat_id), chat_id=str(update.message.chat_id),
content=update.message.text, content=update.message.text,
) )
@@ -240,11 +290,7 @@ class TelegramChannel(BaseChannel):
message = update.message message = update.message
user = update.effective_user user = update.effective_user
chat_id = message.chat_id chat_id = message.chat_id
sender_id = self._sender_id(user)
# Use stable numeric ID, but keep username for allowlist compatibility
sender_id = str(user.id)
if user.username:
sender_id = f"{sender_id}|{user.username}"
# Store chat_id for replies # Store chat_id for replies
self._chat_ids[sender_id] = chat_id self._chat_ids[sender_id] = chat_id
+189 -42
View File
@@ -19,6 +19,7 @@ from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.patch_stdout import patch_stdout
from nanobot import __version__, __logo__ from nanobot import __version__, __logo__
from nanobot.config.schema import Config
app = typer.Typer( app = typer.Typer(
name="nanobot", name="nanobot",
@@ -155,7 +156,7 @@ def main(
@app.command() @app.command()
def onboard(): def onboard():
"""Initialize nanobot configuration and workspace.""" """Initialize nanobot configuration and workspace."""
from nanobot.config.loader import get_config_path, save_config from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path from nanobot.utils.helpers import get_workspace_path
@@ -163,17 +164,26 @@ def onboard():
if config_path.exists(): if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]") console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
if not typer.confirm("Overwrite?"): console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
raise typer.Exit() console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields")
if typer.confirm("Overwrite?"):
# Create default config config = Config()
config = Config() save_config(config)
save_config(config) console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
console.print(f"[green]✓[/green] Created config at {config_path}") else:
config = load_config()
save_config(config)
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
else:
save_config(Config())
console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace # Create workspace
workspace = get_workspace_path() workspace = get_workspace_path()
console.print(f"[green]✓[/green] Created workspace at {workspace}")
if not workspace.exists():
workspace.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace}")
# Create default bootstrap files # Create default bootstrap files
_create_workspace_templates(workspace) _create_workspace_templates(workspace)
@@ -269,21 +279,41 @@ This file stores important information that should persist across sessions.
skills_dir.mkdir(exist_ok=True) skills_dir.mkdir(exist_ok=True)
def _make_provider(config): def _make_provider(config: Config):
"""Create LiteLLMProvider from config. Exits if no API key found.""" """Create the appropriate LLM provider from config."""
from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.litellm_provider import LiteLLMProvider
p = config.get_provider() from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.custom_provider import CustomProvider
model = config.agents.defaults.model model = config.agents.defaults.model
if not (p and p.api_key) and not model.startswith("bedrock/"): provider_name = config.get_provider_name(model)
p = config.get_provider(model)
# OpenAI Codex (OAuth)
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
# Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
if provider_name == "custom":
return CustomProvider(
api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model,
)
from nanobot.providers.registry import find_by_name
spec = find_by_name(provider_name)
if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth):
console.print("[red]Error: No API key configured.[/red]") console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section") console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1) raise typer.Exit(1)
return LiteLLMProvider( return LiteLLMProvider(
api_key=p.api_key if p else None, api_key=p.api_key if p else None,
api_base=config.get_api_base(), api_base=config.get_api_base(model),
default_model=model, default_model=model,
extra_headers=p.extra_headers if p else None, extra_headers=p.extra_headers if p else None,
provider_name=config.get_provider_name(), provider_name=provider_name,
) )
@@ -328,6 +358,8 @@ def gateway(
provider=provider, provider=provider,
workspace=config.workspace_path, workspace=config.workspace_path,
model=config.agents.defaults.model, model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations, max_iterations=config.agents.defaults.max_tool_iterations,
memory_window=config.agents.defaults.memory_window, memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None, brave_api_key=config.tools.web.search.api_key or None,
@@ -335,6 +367,7 @@ def gateway(
cron_service=cron, cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager, session_manager=session_manager,
mcp_servers=config.tools.mcp_servers,
) )
# Set cron callback (needs agent) # Set cron callback (needs agent)
@@ -392,6 +425,8 @@ def gateway(
) )
except KeyboardInterrupt: except KeyboardInterrupt:
console.print("\nShutting down...") console.print("\nShutting down...")
finally:
await agent.close_mcp()
heartbeat.stop() heartbeat.stop()
cron.stop() cron.stop()
agent.stop() agent.stop()
@@ -415,9 +450,10 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
): ):
"""Interact with the agent directly.""" """Interact with the agent directly."""
from nanobot.config.loader import load_config from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.cron.service import CronService
from loguru import logger from loguru import logger
config = load_config() config = load_config()
@@ -425,6 +461,10 @@ def agent(
bus = MessageBus() bus = MessageBus()
provider = _make_provider(config) provider = _make_provider(config)
# Create cron service for tool usage (no callback needed for CLI unless running)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
if logs: if logs:
logger.enable("nanobot") logger.enable("nanobot")
else: else:
@@ -435,11 +475,15 @@ def agent(
provider=provider, provider=provider,
workspace=config.workspace_path, workspace=config.workspace_path,
model=config.agents.defaults.model, model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations, max_iterations=config.agents.defaults.max_tool_iterations,
memory_window=config.agents.defaults.memory_window, memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None, brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec, exec_config=config.tools.exec,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
) )
# Show spinner when logs are off (no output to miss); skip when logs are on # Show spinner when logs are off (no output to miss); skip when logs are on
@@ -450,12 +494,16 @@ def agent(
# Animated spinner is safe to use with prompt_toolkit input handling # Animated spinner is safe to use with prompt_toolkit input handling
return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
async def _cli_progress(content: str) -> None:
console.print(f" [dim]↳ {content}[/dim]")
if message: if message:
# Single message mode # Single message mode
async def run_once(): async def run_once():
with _thinking_ctx(): with _thinking_ctx():
response = await agent_loop.process_direct(message, session_id) response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
_print_agent_response(response, render_markdown=markdown) _print_agent_response(response, render_markdown=markdown)
await agent_loop.close_mcp()
asyncio.run(run_once()) asyncio.run(run_once())
else: else:
@@ -471,30 +519,33 @@ def agent(
signal.signal(signal.SIGINT, _exit_on_sigint) signal.signal(signal.SIGINT, _exit_on_sigint)
async def run_interactive(): async def run_interactive():
while True: try:
try: while True:
_flush_pending_tty_input() try:
user_input = await _read_interactive_input_async() _flush_pending_tty_input()
command = user_input.strip() user_input = await _read_interactive_input_async()
if not command: command = user_input.strip()
continue if not command:
continue
if _is_exit_command(command): if _is_exit_command(command):
_restore_terminal()
console.print("\nGoodbye!")
break
with _thinking_ctx():
response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
_print_agent_response(response, render_markdown=markdown)
except KeyboardInterrupt:
_restore_terminal() _restore_terminal()
console.print("\nGoodbye!") console.print("\nGoodbye!")
break break
except EOFError:
with _thinking_ctx(): _restore_terminal()
response = await agent_loop.process_direct(user_input, session_id) console.print("\nGoodbye!")
_print_agent_response(response, render_markdown=markdown) break
except KeyboardInterrupt: finally:
_restore_terminal() await agent_loop.close_mcp()
console.print("\nGoodbye!")
break
except EOFError:
_restore_terminal()
console.print("\nGoodbye!")
break
asyncio.run(run_interactive()) asyncio.run(run_interactive())
@@ -689,20 +740,26 @@ def cron_list(
table.add_column("Next Run") table.add_column("Next Run")
import time import time
from datetime import datetime as _dt
from zoneinfo import ZoneInfo
for job in jobs: for job in jobs:
# Format schedule # Format schedule
if job.schedule.kind == "every": if job.schedule.kind == "every":
sched = f"every {(job.schedule.every_ms or 0) // 1000}s" sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
elif job.schedule.kind == "cron": elif job.schedule.kind == "cron":
sched = job.schedule.expr or "" sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
else: else:
sched = "one-time" sched = "one-time"
# Format next run # Format next run
next_run = "" next_run = ""
if job.state.next_run_at_ms: if job.state.next_run_at_ms:
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)) ts = job.state.next_run_at_ms / 1000
next_run = next_time try:
tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
except Exception:
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
@@ -717,6 +774,7 @@ def cron_add(
message: str = typer.Option(..., "--message", "-m", help="Message for agent"), message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"),
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
to: str = typer.Option(None, "--to", help="Recipient for delivery"), to: str = typer.Option(None, "--to", help="Recipient for delivery"),
@@ -727,11 +785,15 @@ def cron_add(
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule from nanobot.cron.types import CronSchedule
if tz and not cron_expr:
console.print("[red]Error: --tz can only be used with --cron[/red]")
raise typer.Exit(1)
# Determine schedule type # Determine schedule type
if every: if every:
schedule = CronSchedule(kind="every", every_ms=every * 1000) schedule = CronSchedule(kind="every", every_ms=every * 1000)
elif cron_expr: elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr) schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at: elif at:
import datetime import datetime
dt = datetime.datetime.fromisoformat(at) dt = datetime.datetime.fromisoformat(at)
@@ -842,7 +904,9 @@ def status():
p = getattr(config.providers, spec.name, None) p = getattr(config.providers, spec.name, None)
if p is None: if p is None:
continue continue
if spec.is_local: if spec.is_oauth:
console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
elif spec.is_local:
# Local deployments show api_base instead of api_key # Local deployments show api_base instead of api_key
if p.api_base: if p.api_base:
console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]") console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
@@ -853,5 +917,88 @@ def status():
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}") console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
# ============================================================================
# OAuth Login
# ============================================================================
provider_app = typer.Typer(help="Manage providers")
app.add_typer(provider_app, name="provider")
_LOGIN_HANDLERS: dict[str, callable] = {}
def _register_login(name: str):
def decorator(fn):
_LOGIN_HANDLERS[name] = fn
return fn
return decorator
@provider_app.command("login")
def provider_login(
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
):
"""Authenticate with an OAuth provider."""
from nanobot.providers.registry import PROVIDERS
key = provider.replace("-", "_")
spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
if not spec:
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
raise typer.Exit(1)
handler = _LOGIN_HANDLERS.get(spec.name)
if not handler:
console.print(f"[red]Login not implemented for {spec.label}[/red]")
raise typer.Exit(1)
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
handler()
@_register_login("openai_codex")
def _login_openai_codex() -> None:
try:
from oauth_cli_kit import get_token, login_oauth_interactive
token = None
try:
token = get_token()
except Exception:
pass
if not (token and token.access):
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
token = login_oauth_interactive(
print_fn=lambda s: console.print(s),
prompt_fn=lambda s: typer.prompt(s),
)
if not (token and token.access):
console.print("[red]✗ Authentication failed[/red]")
raise typer.Exit(1)
console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]")
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
raise typer.Exit(1)
@_register_login("github_copilot")
def _login_github_copilot() -> None:
import asyncio
console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
async def _trigger():
from litellm import acompletion
await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1)
try:
asyncio.run(_trigger())
console.print("[green]✓ Authenticated with GitHub Copilot[/green]")
except Exception as e:
console.print(f"[red]Authentication error: {e}[/red]")
raise typer.Exit(1)
if __name__ == "__main__": if __name__ == "__main__":
app() app()
+9 -46
View File
@@ -2,7 +2,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Any
from nanobot.config.schema import Config from nanobot.config.schema import Config
@@ -21,43 +20,41 @@ def get_data_dir() -> Path:
def load_config(config_path: Path | None = None) -> Config: def load_config(config_path: Path | None = None) -> Config:
""" """
Load configuration from file or create default. Load configuration from file or create default.
Args: Args:
config_path: Optional path to config file. Uses default if not provided. config_path: Optional path to config file. Uses default if not provided.
Returns: Returns:
Loaded configuration object. Loaded configuration object.
""" """
path = config_path or get_config_path() path = config_path or get_config_path()
if path.exists(): if path.exists():
try: try:
with open(path) as f: with open(path) as f:
data = json.load(f) data = json.load(f)
data = _migrate_config(data) data = _migrate_config(data)
return Config.model_validate(convert_keys(data)) return Config.model_validate(data)
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
print(f"Warning: Failed to load config from {path}: {e}") print(f"Warning: Failed to load config from {path}: {e}")
print("Using default configuration.") print("Using default configuration.")
return Config() return Config()
def save_config(config: Config, config_path: Path | None = None) -> None: def save_config(config: Config, config_path: Path | None = None) -> None:
""" """
Save configuration to file. Save configuration to file.
Args: Args:
config: Configuration to save. config: Configuration to save.
config_path: Optional path to save to. Uses default if not provided. config_path: Optional path to save to. Uses default if not provided.
""" """
path = config_path or get_config_path() path = config_path or get_config_path()
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
# Convert to camelCase format data = config.model_dump(by_alias=True)
data = config.model_dump()
data = convert_to_camel(data)
with open(path, "w") as f: with open(path, "w") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@@ -70,37 +67,3 @@ def _migrate_config(data: dict) -> dict:
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
return data return data
def convert_keys(data: Any) -> Any:
"""Convert camelCase keys to snake_case for Pydantic."""
if isinstance(data, dict):
return {camel_to_snake(k): convert_keys(v) for k, v in data.items()}
if isinstance(data, list):
return [convert_keys(item) for item in data]
return data
def convert_to_camel(data: Any) -> Any:
"""Convert snake_case keys to camelCase."""
if isinstance(data, dict):
return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()}
if isinstance(data, list):
return [convert_to_camel(item) for item in data]
return data
def camel_to_snake(name: str) -> str:
"""Convert camelCase to snake_case."""
result = []
for i, char in enumerate(name):
if char.isupper() and i > 0:
result.append("_")
result.append(char.lower())
return "".join(result)
def snake_to_camel(name: str) -> str:
"""Convert snake_case to camelCase."""
components = name.split("_")
return components[0] + "".join(x.title() for x in components[1:])
+82 -32
View File
@@ -2,27 +2,37 @@
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
class WhatsAppConfig(BaseModel): class Base(BaseModel):
"""Base model that accepts both camelCase and snake_case keys."""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class WhatsAppConfig(Base):
"""WhatsApp channel configuration.""" """WhatsApp channel configuration."""
enabled: bool = False enabled: bool = False
bridge_url: str = "ws://localhost:3001" bridge_url: str = "ws://localhost:3001"
bridge_token: str = "" # Shared token for bridge auth (optional, recommended) bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
class TelegramConfig(BaseModel): class TelegramConfig(Base):
"""Telegram channel configuration.""" """Telegram channel configuration."""
enabled: bool = False enabled: bool = False
token: str = "" # Bot token from @BotFather token: str = "" # Bot token from @BotFather
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
class FeishuConfig(BaseModel): class FeishuConfig(Base):
"""Feishu/Lark channel configuration using WebSocket long connection.""" """Feishu/Lark channel configuration using WebSocket long connection."""
enabled: bool = False enabled: bool = False
app_id: str = "" # App ID from Feishu Open Platform app_id: str = "" # App ID from Feishu Open Platform
app_secret: str = "" # App Secret from Feishu Open Platform app_secret: str = "" # App Secret from Feishu Open Platform
@@ -31,24 +41,28 @@ class FeishuConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
class DingTalkConfig(BaseModel): class DingTalkConfig(Base):
"""DingTalk channel configuration using Stream mode.""" """DingTalk channel configuration using Stream mode."""
enabled: bool = False enabled: bool = False
client_id: str = "" # AppKey client_id: str = "" # AppKey
client_secret: str = "" # AppSecret client_secret: str = "" # AppSecret
allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
class DiscordConfig(BaseModel): class DiscordConfig(Base):
"""Discord channel configuration.""" """Discord channel configuration."""
enabled: bool = False enabled: bool = False
token: str = "" # Bot token from Discord Developer Portal token: str = "" # Bot token from Discord Developer Portal
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
class EmailConfig(BaseModel):
class EmailConfig(Base):
"""Email channel configuration (IMAP inbound + SMTP outbound).""" """Email channel configuration (IMAP inbound + SMTP outbound)."""
enabled: bool = False enabled: bool = False
consent_granted: bool = False # Explicit owner permission to access mailbox data consent_granted: bool = False # Explicit owner permission to access mailbox data
@@ -78,18 +92,21 @@ class EmailConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
class MochatMentionConfig(BaseModel): class MochatMentionConfig(Base):
"""Mochat mention behavior configuration.""" """Mochat mention behavior configuration."""
require_in_groups: bool = False require_in_groups: bool = False
class MochatGroupRule(BaseModel): class MochatGroupRule(Base):
"""Mochat per-group mention requirement.""" """Mochat per-group mention requirement."""
require_mention: bool = False require_mention: bool = False
class MochatConfig(BaseModel): class MochatConfig(Base):
"""Mochat channel configuration.""" """Mochat channel configuration."""
enabled: bool = False enabled: bool = False
base_url: str = "https://mochat.io" base_url: str = "https://mochat.io"
socket_url: str = "" socket_url: str = ""
@@ -114,36 +131,42 @@ class MochatConfig(BaseModel):
reply_delay_ms: int = 120000 reply_delay_ms: int = 120000
class SlackDMConfig(BaseModel): class SlackDMConfig(Base):
"""Slack DM policy configuration.""" """Slack DM policy configuration."""
enabled: bool = True enabled: bool = True
policy: str = "open" # "open" or "allowlist" policy: str = "open" # "open" or "allowlist"
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
class SlackConfig(BaseModel): class SlackConfig(Base):
"""Slack channel configuration.""" """Slack channel configuration."""
enabled: bool = False enabled: bool = False
mode: str = "socket" # "socket" supported mode: str = "socket" # "socket" supported
webhook_path: str = "/slack/events" webhook_path: str = "/slack/events"
bot_token: str = "" # xoxb-... bot_token: str = "" # xoxb-...
app_token: str = "" # xapp-... app_token: str = "" # xapp-...
user_token_read_only: bool = True user_token_read_only: bool = True
reply_in_thread: bool = True
react_emoji: str = "eyes"
group_policy: str = "mention" # "mention", "open", "allowlist" group_policy: str = "mention" # "mention", "open", "allowlist"
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
dm: SlackDMConfig = Field(default_factory=SlackDMConfig) dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
class QQConfig(BaseModel): class QQConfig(Base):
"""QQ channel configuration using botpy SDK.""" """QQ channel configuration using botpy SDK."""
enabled: bool = False enabled: bool = False
app_id: str = "" # 机器人 ID (AppID) from q.qq.com app_id: str = "" # 机器人 ID (AppID) from q.qq.com
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access)
class ChannelsConfig(BaseModel): class ChannelsConfig(Base):
"""Configuration for chat channels.""" """Configuration for chat channels."""
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
telegram: TelegramConfig = Field(default_factory=TelegramConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig)
discord: DiscordConfig = Field(default_factory=DiscordConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig)
@@ -155,8 +178,9 @@ class ChannelsConfig(BaseModel):
qq: QQConfig = Field(default_factory=QQConfig) qq: QQConfig = Field(default_factory=QQConfig)
class AgentDefaults(BaseModel): class AgentDefaults(Base):
"""Default agent configuration.""" """Default agent configuration."""
workspace: str = "~/.nanobot/workspace" workspace: str = "~/.nanobot/workspace"
model: str = "anthropic/claude-opus-4-5" model: str = "anthropic/claude-opus-4-5"
max_tokens: int = 8192 max_tokens: int = 8192
@@ -165,20 +189,24 @@ class AgentDefaults(BaseModel):
memory_window: int = 50 memory_window: int = 50
class AgentsConfig(BaseModel): class AgentsConfig(Base):
"""Agent configuration.""" """Agent configuration."""
defaults: AgentDefaults = Field(default_factory=AgentDefaults) defaults: AgentDefaults = Field(default_factory=AgentDefaults)
class ProviderConfig(BaseModel): class ProviderConfig(Base):
"""LLM provider configuration.""" """LLM provider configuration."""
api_key: str = "" api_key: str = ""
api_base: str | None = None api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
class ProvidersConfig(BaseModel): class ProvidersConfig(Base):
"""Configuration for LLM providers.""" """Configuration for LLM providers."""
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
anthropic: ProviderConfig = Field(default_factory=ProviderConfig) anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
openai: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig)
openrouter: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
@@ -191,63 +219,87 @@ class ProvidersConfig(BaseModel):
moonshot: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
minimax: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
class GatewayConfig(BaseModel): class GatewayConfig(Base):
"""Gateway/server configuration.""" """Gateway/server configuration."""
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 18790 port: int = 18790
class WebSearchConfig(BaseModel): class WebSearchConfig(Base):
"""Web search tool configuration.""" """Web search tool configuration."""
api_key: str = "" # Brave Search API key api_key: str = "" # Brave Search API key
max_results: int = 5 max_results: int = 5
class WebToolsConfig(BaseModel): class WebToolsConfig(Base):
"""Web tools configuration.""" """Web tools configuration."""
search: WebSearchConfig = Field(default_factory=WebSearchConfig) search: WebSearchConfig = Field(default_factory=WebSearchConfig)
class ExecToolConfig(BaseModel): class ExecToolConfig(Base):
"""Shell exec tool configuration.""" """Shell exec tool configuration."""
timeout: int = 60 timeout: int = 60
class ToolsConfig(BaseModel): class MCPServerConfig(Base):
"""MCP server connection configuration (stdio or HTTP)."""
command: str = "" # Stdio: command to run (e.g. "npx")
args: list[str] = Field(default_factory=list) # Stdio: command arguments
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
url: str = "" # HTTP: streamable HTTP endpoint URL
class ToolsConfig(Base):
"""Tools configuration.""" """Tools configuration."""
web: WebToolsConfig = Field(default_factory=WebToolsConfig) web: WebToolsConfig = Field(default_factory=WebToolsConfig)
exec: ExecToolConfig = Field(default_factory=ExecToolConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
class Config(BaseSettings): class Config(BaseSettings):
"""Root configuration for nanobot.""" """Root configuration for nanobot."""
agents: AgentsConfig = Field(default_factory=AgentsConfig) agents: AgentsConfig = Field(default_factory=AgentsConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
providers: ProvidersConfig = Field(default_factory=ProvidersConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
gateway: GatewayConfig = Field(default_factory=GatewayConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig)
@property @property
def workspace_path(self) -> Path: def workspace_path(self) -> Path:
"""Get expanded workspace path.""" """Get expanded workspace path."""
return Path(self.agents.defaults.workspace).expanduser() return Path(self.agents.defaults.workspace).expanduser()
def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
"""Match provider config and its registry name. Returns (config, spec_name).""" """Match provider config and its registry name. Returns (config, spec_name)."""
from nanobot.providers.registry import PROVIDERS from nanobot.providers.registry import PROVIDERS
model_lower = (model or self.agents.defaults.model).lower() model_lower = (model or self.agents.defaults.model).lower()
# Match by keyword (order follows PROVIDERS registry) # Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS: for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None) p = getattr(self.providers, spec.name, None)
if p and any(kw in model_lower for kw in spec.keywords) and p.api_key: if p and any(kw in model_lower for kw in spec.keywords):
return p, spec.name if spec.is_oauth or p.api_key:
return p, spec.name
# Fallback: gateways first, then others (follows registry order) # Fallback: gateways first, then others (follows registry order)
# OAuth providers are NOT valid fallbacks — they require explicit model selection
for spec in PROVIDERS: for spec in PROVIDERS:
if spec.is_oauth:
continue
p = getattr(self.providers, spec.name, None) p = getattr(self.providers, spec.name, None)
if p and p.api_key: if p and p.api_key:
return p, spec.name return p, spec.name
@@ -267,10 +319,11 @@ class Config(BaseSettings):
"""Get API key for the given model. Falls back to first available key.""" """Get API key for the given model. Falls back to first available key."""
p = self.get_provider(model) p = self.get_provider(model)
return p.api_key if p else None return p.api_key if p else None
def get_api_base(self, model: str | None = None) -> str | None: def get_api_base(self, model: str | None = None) -> str | None:
"""Get API base URL for the given model. Applies default URLs for known gateways.""" """Get API base URL for the given model. Applies default URLs for known gateways."""
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import find_by_name
p, name = self._match_provider(model) p, name = self._match_provider(model)
if p and p.api_base: if p and p.api_base:
return p.api_base return p.api_base
@@ -282,8 +335,5 @@ class Config(BaseSettings):
if spec and spec.is_gateway and spec.default_api_base: if spec and spec.is_gateway and spec.default_api_base:
return spec.default_api_base return spec.default_api_base
return None return None
model_config = ConfigDict( model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__")
env_prefix="NANOBOT_",
env_nested_delimiter="__"
)
+9 -3
View File
@@ -4,6 +4,7 @@ import asyncio
import json import json
import time import time
import uuid import uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Coroutine from typing import Any, Callable, Coroutine
@@ -30,9 +31,14 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
if schedule.kind == "cron" and schedule.expr: if schedule.kind == "cron" and schedule.expr:
try: try:
from croniter import croniter from croniter import croniter
cron = croniter(schedule.expr, time.time()) from zoneinfo import ZoneInfo
next_time = cron.get_next() # Use caller-provided reference time for deterministic scheduling
return int(next_time * 1000) base_time = now_ms / 1000
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
base_dt = datetime.fromtimestamp(base_time, tz=tz)
cron = croniter(schedule.expr, base_dt)
next_dt = cron.get_next(datetime)
return int(next_dt.timestamp() * 1000)
except Exception: except Exception:
return None return None
+2 -1
View File
@@ -2,5 +2,6 @@
from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider"] __all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider", "OpenAICodexProvider"]
+47
View File
@@ -0,0 +1,47 @@
"""Direct OpenAI-compatible provider — bypasses LiteLLM."""
from __future__ import annotations
from typing import Any
import json_repair
from openai import AsyncOpenAI
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
class CustomProvider(LLMProvider):
def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"):
super().__init__(api_key, api_base)
self.default_model = default_model
self._client = AsyncOpenAI(api_key=api_key, base_url=api_base)
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse:
kwargs: dict[str, Any] = {"model": model or self.default_model, "messages": messages,
"max_tokens": max(1, max_tokens), "temperature": temperature}
if tools:
kwargs.update(tools=tools, tool_choice="auto")
try:
return self._parse(await self._client.chat.completions.create(**kwargs))
except Exception as e:
return LLMResponse(content=f"Error: {e}", finish_reason="error")
def _parse(self, response: Any) -> LLMResponse:
choice = response.choices[0]
msg = choice.message
tool_calls = [
ToolCallRequest(id=tc.id, name=tc.function.name,
arguments=json_repair.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments)
for tc in (msg.tool_calls or [])
]
u = response.usage
return LLMResponse(
content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop",
usage={"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens, "total_tokens": u.total_tokens} if u else {},
reasoning_content=getattr(msg, "reasoning_content", None),
)
def get_default_model(self) -> str:
return self.default_model
+9 -4
View File
@@ -1,6 +1,7 @@
"""LiteLLM provider implementation for multi-provider support.""" """LiteLLM provider implementation for multi-provider support."""
import json import json
import json_repair
import os import os
from typing import Any from typing import Any
@@ -54,6 +55,9 @@ class LiteLLMProvider(LLMProvider):
spec = self._gateway or find_by_model(model) spec = self._gateway or find_by_model(model)
if not spec: if not spec:
return return
if not spec.env_key:
# OAuth/provider-only specs (for example: openai_codex)
return
# Gateway/local overrides existing env; standard provider doesn't # Gateway/local overrides existing env; standard provider doesn't
if self._gateway: if self._gateway:
@@ -122,6 +126,10 @@ class LiteLLMProvider(LLMProvider):
""" """
model = self._resolve_model(model or self.default_model) model = self._resolve_model(model or self.default_model)
# Clamp max_tokens to at least 1 — negative or zero values cause
# LiteLLM to reject the request with "max_tokens must be at least 1".
max_tokens = max(1, max_tokens)
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"model": model, "model": model,
"messages": messages, "messages": messages,
@@ -169,10 +177,7 @@ class LiteLLMProvider(LLMProvider):
# Parse arguments from JSON string if needed # Parse arguments from JSON string if needed
args = tc.function.arguments args = tc.function.arguments
if isinstance(args, str): if isinstance(args, str):
try: args = json_repair.loads(args)
args = json.loads(args)
except json.JSONDecodeError:
args = {"raw": args}
tool_calls.append(ToolCallRequest( tool_calls.append(ToolCallRequest(
id=tc.id, id=tc.id,
+312
View File
@@ -0,0 +1,312 @@
"""OpenAI Codex Responses Provider."""
from __future__ import annotations
import asyncio
import hashlib
import json
from typing import Any, AsyncGenerator
import httpx
from loguru import logger
from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_ORIGINATOR = "nanobot"
class OpenAICodexProvider(LLMProvider):
"""Use Codex OAuth to call the Responses API."""
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
super().__init__(api_key=None, api_base=None)
self.default_model = default_model
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
model = model or self.default_model
system_prompt, input_items = _convert_messages(messages)
token = await asyncio.to_thread(get_codex_token)
headers = _build_headers(token.account_id, token.access)
body: dict[str, Any] = {
"model": _strip_model_prefix(model),
"store": False,
"stream": True,
"instructions": system_prompt,
"input": input_items,
"text": {"verbosity": "medium"},
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": _prompt_cache_key(messages),
"tool_choice": "auto",
"parallel_tool_calls": True,
}
if tools:
body["tools"] = _convert_tools(tools)
url = DEFAULT_CODEX_URL
try:
try:
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise
logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
)
except Exception as e:
return LLMResponse(
content=f"Error calling Codex: {str(e)}",
finish_reason="error",
)
def get_default_model(self) -> str:
return self.default_model
def _strip_model_prefix(model: str) -> str:
if model.startswith("openai-codex/"):
return model.split("/", 1)[1]
return model
def _build_headers(account_id: str, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"chatgpt-account-id": account_id,
"OpenAI-Beta": "responses=experimental",
"originator": DEFAULT_ORIGINATOR,
"User-Agent": "nanobot (python)",
"accept": "text/event-stream",
"content-type": "application/json",
}
async def _request_codex(
url: str,
headers: dict[str, str],
body: dict[str, Any],
verify: bool,
) -> tuple[str, list[ToolCallRequest], str]:
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
if response.status_code != 200:
text = await response.aread()
raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
return await _consume_sse(response)
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert OpenAI function-calling schema to Codex flat format."""
converted: list[dict[str, Any]] = []
for tool in tools:
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
name = fn.get("name")
if not name:
continue
params = fn.get("parameters") or {}
converted.append({
"type": "function",
"name": name,
"description": fn.get("description") or "",
"parameters": params if isinstance(params, dict) else {},
})
return converted
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
system_prompt = ""
input_items: list[dict[str, Any]] = []
for idx, msg in enumerate(messages):
role = msg.get("role")
content = msg.get("content")
if role == "system":
system_prompt = content if isinstance(content, str) else ""
continue
if role == "user":
input_items.append(_convert_user_message(content))
continue
if role == "assistant":
# Handle text first.
if isinstance(content, str) and content:
input_items.append(
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
"status": "completed",
"id": f"msg_{idx}",
}
)
# Then handle tool calls.
for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {}
call_id, item_id = _split_tool_call_id(tool_call.get("id"))
call_id = call_id or f"call_{idx}"
item_id = item_id or f"fc_{idx}"
input_items.append(
{
"type": "function_call",
"id": item_id,
"call_id": call_id,
"name": fn.get("name"),
"arguments": fn.get("arguments") or "{}",
}
)
continue
if role == "tool":
call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
output_text = content if isinstance(content, str) else json.dumps(content)
input_items.append(
{
"type": "function_call_output",
"call_id": call_id,
"output": output_text,
}
)
continue
return system_prompt, input_items
def _convert_user_message(content: Any) -> dict[str, Any]:
if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
if isinstance(content, list):
converted: list[dict[str, Any]] = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text":
converted.append({"type": "input_text", "text": item.get("text", "")})
elif item.get("type") == "image_url":
url = (item.get("image_url") or {}).get("url")
if url:
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
if converted:
return {"role": "user", "content": converted}
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
if isinstance(tool_call_id, str) and tool_call_id:
if "|" in tool_call_id:
call_id, item_id = tool_call_id.split("|", 1)
return call_id, item_id or None
return tool_call_id, None
return "call_0", None
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
buffer: list[str] = []
async for line in response.aiter_lines():
if line == "":
if buffer:
data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")]
buffer = []
if not data_lines:
continue
data = "\n".join(data_lines).strip()
if not data or data == "[DONE]":
continue
try:
yield json.loads(data)
except Exception:
continue
continue
buffer.append(line)
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
finish_reason = "stop"
async for event in _iter_sse(response):
event_type = event.get("type")
if event_type == "response.output_item.added":
item = event.get("item") or {}
if item.get("type") == "function_call":
call_id = item.get("call_id")
if not call_id:
continue
tool_call_buffers[call_id] = {
"id": item.get("id") or "fc_0",
"name": item.get("name"),
"arguments": item.get("arguments") or "",
}
elif event_type == "response.output_text.delta":
content += event.get("delta") or ""
elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
elif event_type == "response.output_item.done":
item = event.get("item") or {}
if item.get("type") == "function_call":
call_id = item.get("call_id")
if not call_id:
continue
buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
try:
args = json.loads(args_raw)
except Exception:
args = {"raw": args_raw}
tool_calls.append(
ToolCallRequest(
id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
name=buf.get("name") or item.get("name"),
arguments=args,
)
)
elif event_type == "response.completed":
status = (event.get("response") or {}).get("status")
finish_reason = _map_finish_reason(status)
elif event_type in {"error", "response.failed"}:
raise RuntimeError("Codex response failed")
return content, tool_calls, finish_reason
_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"}
def _map_finish_reason(status: str | None) -> str:
return _FINISH_REASON_MAP.get(status or "completed", "stop")
def _friendly_error(status_code: int, raw: str) -> str:
if status_code == 429:
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
return f"HTTP {status_code}: {raw}"
+72
View File
@@ -51,6 +51,12 @@ class ProviderSpec:
# per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),)
model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () model_overrides: tuple[tuple[str, dict[str, Any]], ...] = ()
# OAuth-based providers (e.g., OpenAI Codex) don't use API keys
is_oauth: bool = False # if True, uses OAuth flow instead of API key
# Direct providers bypass LiteLLM entirely (e.g., CustomProvider)
is_direct: bool = False
@property @property
def label(self) -> str: def label(self) -> str:
return self.display_name or self.name.title() return self.display_name or self.name.title()
@@ -62,6 +68,16 @@ class ProviderSpec:
PROVIDERS: tuple[ProviderSpec, ...] = ( PROVIDERS: tuple[ProviderSpec, ...] = (
# === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ======
ProviderSpec(
name="custom",
keywords=(),
env_key="",
display_name="Custom",
litellm_prefix="",
is_direct=True,
),
# === Gateways (detected by api_key / api_base, not model name) ========= # === Gateways (detected by api_key / api_base, not model name) =========
# Gateways can route any model, so they win in fallback. # Gateways can route any model, so they win in fallback.
@@ -103,6 +119,24 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(), model_overrides=(),
), ),
# SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix
ProviderSpec(
name="siliconflow",
keywords=("siliconflow",),
env_key="OPENAI_API_KEY",
display_name="SiliconFlow",
litellm_prefix="openai",
skip_prefixes=(),
env_extras=(),
is_gateway=True,
is_local=False,
detect_by_key_prefix="",
detect_by_base_keyword="siliconflow",
default_api_base="https://api.siliconflow.cn/v1",
strip_model_prefix=False,
model_overrides=(),
),
# === Standard providers (matched by model-name keywords) =============== # === Standard providers (matched by model-name keywords) ===============
# Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
@@ -141,6 +175,44 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(), model_overrides=(),
), ),
# OpenAI Codex: uses OAuth, not API key.
ProviderSpec(
name="openai_codex",
keywords=("openai-codex", "codex"),
env_key="", # OAuth-based, no API key
display_name="OpenAI Codex",
litellm_prefix="", # Not routed through LiteLLM
skip_prefixes=(),
env_extras=(),
is_gateway=False,
is_local=False,
detect_by_key_prefix="",
detect_by_base_keyword="codex",
default_api_base="https://chatgpt.com/backend-api",
strip_model_prefix=False,
model_overrides=(),
is_oauth=True, # OAuth-based authentication
),
# Github Copilot: uses OAuth, not API key.
ProviderSpec(
name="github_copilot",
keywords=("github_copilot", "copilot"),
env_key="", # OAuth-based, no API key
display_name="Github Copilot",
litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model
skip_prefixes=("github_copilot/",),
env_extras=(),
is_gateway=False,
is_local=False,
detect_by_key_prefix="",
detect_by_base_keyword="",
default_api_base="",
strip_model_prefix=False,
model_overrides=(),
is_oauth=True, # OAuth-based authentication
),
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing. # DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
ProviderSpec( ProviderSpec(
name="deepseek", name="deepseek",
+50 -54
View File
@@ -15,15 +15,20 @@ from nanobot.utils.helpers import ensure_dir, safe_filename
class Session: class Session:
""" """
A conversation session. A conversation session.
Stores messages in JSONL format for easy reading and persistence. Stores messages in JSONL format for easy reading and persistence.
Important: Messages are append-only for LLM cache efficiency.
The consolidation process writes summaries to MEMORY.md/HISTORY.md
but does NOT modify the messages list or get_history() output.
""" """
key: str # channel:chat_id key: str # channel:chat_id
messages: list[dict[str, Any]] = field(default_factory=list) messages: list[dict[str, Any]] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files
def add_message(self, role: str, content: str, **kwargs: Any) -> None: def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session.""" """Add a message to the session."""
@@ -36,44 +41,46 @@ class Session:
self.messages.append(msg) self.messages.append(msg)
self.updated_at = datetime.now() self.updated_at = datetime.now()
def get_history(self, max_messages: int = 50) -> list[dict[str, Any]]: def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
""" """Get recent messages in LLM format, preserving tool metadata."""
Get message history for LLM context. out: list[dict[str, Any]] = []
for m in self.messages[-max_messages:]:
Args: entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")}
max_messages: Maximum messages to return. for k in ("tool_calls", "tool_call_id", "name"):
if k in m:
Returns: entry[k] = m[k]
List of messages in LLM format. out.append(entry)
""" return out
# Get recent messages
recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
# Convert to LLM format (just role and content)
return [{"role": m["role"], "content": m["content"]} for m in recent]
def clear(self) -> None: def clear(self) -> None:
"""Clear all messages in the session.""" """Clear all messages and reset session to initial state."""
self.messages = [] self.messages = []
self.last_consolidated = 0
self.updated_at = datetime.now() self.updated_at = datetime.now()
class SessionManager: class SessionManager:
""" """
Manages conversation sessions. Manages conversation sessions.
Sessions are stored as JSONL files in the sessions directory. Sessions are stored as JSONL files in the sessions directory.
""" """
def __init__(self, workspace: Path): def __init__(self, workspace: Path):
self.workspace = workspace self.workspace = workspace
self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions") self.sessions_dir = ensure_dir(self.workspace / "sessions")
self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
self._cache: dict[str, Session] = {} self._cache: dict[str, Session] = {}
def _get_session_path(self, key: str) -> Path: def _get_session_path(self, key: str) -> Path:
"""Get the file path for a session.""" """Get the file path for a session."""
safe_key = safe_filename(key.replace(":", "_")) safe_key = safe_filename(key.replace(":", "_"))
return self.sessions_dir / f"{safe_key}.jsonl" return self.sessions_dir / f"{safe_key}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path:
"""Legacy global session path (~/.nanobot/sessions/)."""
safe_key = safe_filename(key.replace(":", "_"))
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
def get_or_create(self, key: str) -> Session: def get_or_create(self, key: str) -> Session:
""" """
@@ -85,11 +92,9 @@ class SessionManager:
Returns: Returns:
The session. The session.
""" """
# Check cache
if key in self._cache: if key in self._cache:
return self._cache[key] return self._cache[key]
# Try to load from disk
session = self._load(key) session = self._load(key)
if session is None: if session is None:
session = Session(key=key) session = Session(key=key)
@@ -100,34 +105,43 @@ class SessionManager:
def _load(self, key: str) -> Session | None: def _load(self, key: str) -> Session | None:
"""Load a session from disk.""" """Load a session from disk."""
path = self._get_session_path(key) path = self._get_session_path(key)
if not path.exists():
legacy_path = self._get_legacy_session_path(key)
if legacy_path.exists():
import shutil
shutil.move(str(legacy_path), str(path))
logger.info(f"Migrated session {key} from legacy path")
if not path.exists(): if not path.exists():
return None return None
try: try:
messages = [] messages = []
metadata = {} metadata = {}
created_at = None created_at = None
last_consolidated = 0
with open(path) as f: with open(path) as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
data = json.loads(line) data = json.loads(line)
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
last_consolidated = data.get("last_consolidated", 0)
else: else:
messages.append(data) messages.append(data)
return Session( return Session(
key=key, key=key,
messages=messages, messages=messages,
created_at=created_at or datetime.now(), created_at=created_at or datetime.now(),
metadata=metadata metadata=metadata,
last_consolidated=last_consolidated
) )
except Exception as e: except Exception as e:
logger.warning(f"Failed to load session {key}: {e}") logger.warning(f"Failed to load session {key}: {e}")
@@ -136,42 +150,24 @@ class SessionManager:
def save(self, session: Session) -> None: def save(self, session: Session) -> None:
"""Save a session to disk.""" """Save a session to disk."""
path = self._get_session_path(session.key) path = self._get_session_path(session.key)
with open(path, "w") as f: with open(path, "w") as f:
# Write metadata first
metadata_line = { metadata_line = {
"_type": "metadata", "_type": "metadata",
"created_at": session.created_at.isoformat(), "created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(), "updated_at": session.updated_at.isoformat(),
"metadata": session.metadata "metadata": session.metadata,
"last_consolidated": session.last_consolidated
} }
f.write(json.dumps(metadata_line) + "\n") f.write(json.dumps(metadata_line) + "\n")
# Write messages
for msg in session.messages: for msg in session.messages:
f.write(json.dumps(msg) + "\n") f.write(json.dumps(msg) + "\n")
self._cache[session.key] = session self._cache[session.key] = session
def delete(self, key: str) -> bool: def invalidate(self, key: str) -> None:
""" """Remove a session from the in-memory cache."""
Delete a session.
Args:
key: Session key.
Returns:
True if deleted, False if not found.
"""
# Remove from cache
self._cache.pop(key, None) self._cache.pop(key, None)
# Remove file
path = self._get_session_path(key)
if path.exists():
path.unlink()
return True
return False
def list_sessions(self) -> list[dict[str, Any]]: def list_sessions(self) -> list[dict[str, Any]]:
""" """
+1
View File
@@ -21,4 +21,5 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai
| `weather` | Get weather info using wttr.in and Open-Meteo | | `weather` | Get weather info using wttr.in and Open-Meteo |
| `summarize` | Summarize URLs, files, and YouTube videos | | `summarize` | Summarize URLs, files, and YouTube videos |
| `tmux` | Remote-control tmux sessions | | `tmux` | Remote-control tmux sessions |
| `clawhub` | Search and install skills from ClawHub registry |
| `skill-creator` | Create new skills | | `skill-creator` | Create new skills |
+53
View File
@@ -0,0 +1,53 @@
---
name: clawhub
description: Search and install agent skills from ClawHub, the public skill registry.
homepage: https://clawhub.ai
metadata: {"nanobot":{"emoji":"🦞"}}
---
# ClawHub
Public skill registry for AI agents. Search by natural language (vector search).
## When to use
Use this skill when the user asks any of:
- "find a skill for …"
- "search for skills"
- "install a skill"
- "what skills are available?"
- "update my skills"
## Search
```bash
npx --yes clawhub@latest search "web scraping" --limit 5
```
## Install
```bash
npx --yes clawhub@latest install <slug> --workdir ~/.nanobot/workspace
```
Replace `<slug>` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`.
## Update
```bash
npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace
```
## List installed
```bash
npx --yes clawhub@latest list --workdir ~/.nanobot/workspace
```
## Notes
- Requires Node.js (`npx` comes with it).
- No API key needed for search and install.
- Login (`npx --yes clawhub@latest login`) is only required for publishing.
- `--workdir ~/.nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace.
- After install, remind the user to start a new session to load the skill.
+10
View File
@@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time):
cron(action="add", message="Remind me about the meeting", at="<ISO datetime>") cron(action="add", message="Remind me about the meeting", at="<ISO datetime>")
``` ```
Timezone-aware cron:
```
cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver")
```
List/remove: List/remove:
``` ```
cron(action="list") cron(action="list")
@@ -44,4 +49,9 @@ cron(action="remove", job_id="abc123")
| every hour | every_seconds: 3600 | | every hour | every_seconds: 3600 |
| every day at 8am | cron_expr: "0 8 * * *" | | every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" | | weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
| at a specific time | at: ISO datetime string (compute from current time) | | at a specific time | at: ISO datetime string (compute from current time) |
## Timezone
Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used.
+6 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nanobot-ai" name = "nanobot-ai"
version = "0.1.3.post7" version = "0.1.4"
description = "A lightweight personal AI assistant framework" description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11" requires-python = ">=3.11"
license = {text = "MIT"} license = {text = "MIT"}
@@ -23,7 +23,8 @@ dependencies = [
"pydantic-settings>=2.0.0", "pydantic-settings>=2.0.0",
"websockets>=12.0", "websockets>=12.0",
"websocket-client>=1.6.0", "websocket-client>=1.6.0",
"httpx[socks]>=0.25.0", "httpx>=0.25.0",
"oauth-cli-kit>=0.1.1",
"loguru>=0.7.0", "loguru>=0.7.0",
"readability-lxml>=0.8.0", "readability-lxml>=0.8.0",
"rich>=13.0.0", "rich>=13.0.0",
@@ -35,9 +36,12 @@ dependencies = [
"python-socketio>=5.11.0", "python-socketio>=5.11.0",
"msgpack>=1.0.8", "msgpack>=1.0.8",
"slack-sdk>=3.26.0", "slack-sdk>=3.26.0",
"slackify-markdown>=0.2.0",
"qq-botpy>=1.0.0", "qq-botpy>=1.0.0",
"python-socks[asyncio]>=2.4.0", "python-socks[asyncio]>=2.4.0",
"prompt-toolkit>=3.0.0", "prompt-toolkit>=3.0.0",
"mcp>=1.0.0",
"json-repair>=0.30.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+2 -1
View File
@@ -12,7 +12,8 @@ def mock_prompt_session():
"""Mock the global prompt session.""" """Mock the global prompt session."""
mock_session = MagicMock() mock_session = MagicMock()
mock_session.prompt_async = AsyncMock() mock_session.prompt_async = AsyncMock()
with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session): with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session), \
patch("nanobot.cli.commands.patch_stdout"):
yield mock_session yield mock_session
+92
View File
@@ -0,0 +1,92 @@
import shutil
from pathlib import Path
from unittest.mock import patch
import pytest
from typer.testing import CliRunner
from nanobot.cli.commands import app
runner = CliRunner()
@pytest.fixture
def mock_paths():
"""Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \
patch("nanobot.config.loader.load_config") as mock_lc, \
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
base_dir = Path("./test_onboard_data")
if base_dir.exists():
shutil.rmtree(base_dir)
base_dir.mkdir()
config_file = base_dir / "config.json"
workspace_dir = base_dir / "workspace"
mock_cp.return_value = config_file
mock_ws.return_value = workspace_dir
mock_sc.side_effect = lambda config: config_file.write_text("{}")
yield config_file, workspace_dir
if base_dir.exists():
shutil.rmtree(base_dir)
def test_onboard_fresh_install(mock_paths):
"""No existing config — should create from scratch."""
config_file, workspace_dir = mock_paths
result = runner.invoke(app, ["onboard"])
assert result.exit_code == 0
assert "Created config" in result.stdout
assert "Created workspace" in result.stdout
assert "nanobot is ready" in result.stdout
assert config_file.exists()
assert (workspace_dir / "AGENTS.md").exists()
assert (workspace_dir / "memory" / "MEMORY.md").exists()
def test_onboard_existing_config_refresh(mock_paths):
"""Config exists, user declines overwrite — should refresh (load-merge-save)."""
config_file, workspace_dir = mock_paths
config_file.write_text('{"existing": true}')
result = runner.invoke(app, ["onboard"], input="n\n")
assert result.exit_code == 0
assert "Config already exists" in result.stdout
assert "existing values preserved" in result.stdout
assert workspace_dir.exists()
assert (workspace_dir / "AGENTS.md").exists()
def test_onboard_existing_config_overwrite(mock_paths):
"""Config exists, user confirms overwrite — should reset to defaults."""
config_file, workspace_dir = mock_paths
config_file.write_text('{"existing": true}')
result = runner.invoke(app, ["onboard"], input="y\n")
assert result.exit_code == 0
assert "Config already exists" in result.stdout
assert "Config reset to defaults" in result.stdout
assert workspace_dir.exists()
def test_onboard_existing_workspace_safe_create(mock_paths):
"""Workspace exists — should not recreate, but still add missing templates."""
config_file, workspace_dir = mock_paths
workspace_dir.mkdir(parents=True)
config_file.write_text("{}")
result = runner.invoke(app, ["onboard"], input="n\n")
assert result.exit_code == 0
assert "Created workspace" not in result.stdout
assert "Created AGENTS.md" in result.stdout
assert (workspace_dir / "AGENTS.md").exists()
+477
View File
@@ -0,0 +1,477 @@
"""Test session management with cache-friendly message handling."""
import pytest
from pathlib import Path
from nanobot.session.manager import Session, SessionManager
# Test constants
MEMORY_WINDOW = 50
KEEP_COUNT = MEMORY_WINDOW // 2 # 25
def create_session_with_messages(key: str, count: int, role: str = "user") -> Session:
"""Create a session and add the specified number of messages.
Args:
key: Session identifier
count: Number of messages to add
role: Message role (default: "user")
Returns:
Session with the specified messages
"""
session = Session(key=key)
for i in range(count):
session.add_message(role, f"msg{i}")
return session
def assert_messages_content(messages: list, start_index: int, end_index: int) -> None:
"""Assert that messages contain expected content from start to end index.
Args:
messages: List of message dictionaries
start_index: Expected first message index
end_index: Expected last message index
"""
assert len(messages) > 0
assert messages[0]["content"] == f"msg{start_index}"
assert messages[-1]["content"] == f"msg{end_index}"
def get_old_messages(session: Session, last_consolidated: int, keep_count: int) -> list:
"""Extract messages that would be consolidated using the standard slice logic.
Args:
session: The session containing messages
last_consolidated: Index of last consolidated message
keep_count: Number of recent messages to keep
Returns:
List of messages that would be consolidated
"""
return session.messages[last_consolidated:-keep_count]
class TestSessionLastConsolidated:
"""Test last_consolidated tracking to avoid duplicate processing."""
def test_initial_last_consolidated_zero(self) -> None:
"""Test that new session starts with last_consolidated=0."""
session = Session(key="test:initial")
assert session.last_consolidated == 0
def test_last_consolidated_persistence(self, tmp_path) -> None:
"""Test that last_consolidated persists across save/load."""
manager = SessionManager(Path(tmp_path))
session1 = create_session_with_messages("test:persist", 20)
session1.last_consolidated = 15
manager.save(session1)
session2 = manager.get_or_create("test:persist")
assert session2.last_consolidated == 15
assert len(session2.messages) == 20
def test_clear_resets_last_consolidated(self) -> None:
"""Test that clear() resets last_consolidated to 0."""
session = create_session_with_messages("test:clear", 10)
session.last_consolidated = 5
session.clear()
assert len(session.messages) == 0
assert session.last_consolidated == 0
class TestSessionImmutableHistory:
"""Test Session message immutability for cache efficiency."""
def test_initial_state(self) -> None:
"""Test that new session has empty messages list."""
session = Session(key="test:initial")
assert len(session.messages) == 0
def test_add_messages_appends_only(self) -> None:
"""Test that adding messages only appends, never modifies."""
session = Session(key="test:preserve")
session.add_message("user", "msg1")
session.add_message("assistant", "resp1")
session.add_message("user", "msg2")
assert len(session.messages) == 3
assert session.messages[0]["content"] == "msg1"
def test_get_history_returns_most_recent(self) -> None:
"""Test get_history returns the most recent messages."""
session = Session(key="test:history")
for i in range(10):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
history = session.get_history(max_messages=6)
assert len(history) == 6
assert history[0]["content"] == "msg7"
assert history[-1]["content"] == "resp9"
def test_get_history_with_all_messages(self) -> None:
"""Test get_history with max_messages larger than actual."""
session = create_session_with_messages("test:all", 5)
history = session.get_history(max_messages=100)
assert len(history) == 5
assert history[0]["content"] == "msg0"
def test_get_history_stable_for_same_session(self) -> None:
"""Test that get_history returns same content for same max_messages."""
session = create_session_with_messages("test:stable", 20)
history1 = session.get_history(max_messages=10)
history2 = session.get_history(max_messages=10)
assert history1 == history2
def test_messages_list_never_modified(self) -> None:
"""Test that messages list is never modified after creation."""
session = create_session_with_messages("test:immutable", 5)
original_len = len(session.messages)
session.get_history(max_messages=2)
assert len(session.messages) == original_len
for _ in range(10):
session.get_history(max_messages=3)
assert len(session.messages) == original_len
class TestSessionPersistence:
"""Test Session persistence and reload."""
@pytest.fixture
def temp_manager(self, tmp_path):
return SessionManager(Path(tmp_path))
def test_persistence_roundtrip(self, temp_manager):
"""Test that messages persist across save/load."""
session1 = create_session_with_messages("test:persistence", 20)
temp_manager.save(session1)
session2 = temp_manager.get_or_create("test:persistence")
assert len(session2.messages) == 20
assert session2.messages[0]["content"] == "msg0"
assert session2.messages[-1]["content"] == "msg19"
def test_get_history_after_reload(self, temp_manager):
"""Test that get_history works correctly after reload."""
session1 = create_session_with_messages("test:reload", 30)
temp_manager.save(session1)
session2 = temp_manager.get_or_create("test:reload")
history = session2.get_history(max_messages=10)
assert len(history) == 10
assert history[0]["content"] == "msg20"
assert history[-1]["content"] == "msg29"
def test_clear_resets_session(self, temp_manager):
"""Test that clear() properly resets session."""
session = create_session_with_messages("test:clear", 10)
assert len(session.messages) == 10
session.clear()
assert len(session.messages) == 0
class TestConsolidationTriggerConditions:
"""Test consolidation trigger conditions and logic."""
def test_consolidation_needed_when_messages_exceed_window(self):
"""Test consolidation logic: should trigger when messages > memory_window."""
session = create_session_with_messages("test:trigger", 60)
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert total_messages > MEMORY_WINDOW
assert messages_to_process > 0
expected_consolidate_count = total_messages - KEEP_COUNT
assert expected_consolidate_count == 35
def test_consolidation_skipped_when_within_keep_count(self):
"""Test consolidation skipped when total messages <= keep_count."""
session = create_session_with_messages("test:skip", 20)
total_messages = len(session.messages)
assert total_messages <= KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_consolidation_skipped_when_no_new_messages(self):
"""Test consolidation skipped when messages_to_process <= 0."""
session = create_session_with_messages("test:already_consolidated", 40)
session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
# Add a few more messages
for i in range(40, 42):
session.add_message("user", f"msg{i}")
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert messages_to_process > 0
# Simulate last_consolidated catching up
session.last_consolidated = total_messages - KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
class TestLastConsolidatedEdgeCases:
"""Test last_consolidated edge cases and data corruption scenarios."""
def test_last_consolidated_exceeds_message_count(self):
"""Test behavior when last_consolidated > len(messages) (data corruption)."""
session = create_session_with_messages("test:corruption", 10)
session.last_consolidated = 20
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert messages_to_process <= 0
old_messages = get_old_messages(session, session.last_consolidated, 5)
assert len(old_messages) == 0
def test_last_consolidated_negative_value(self):
"""Test behavior with negative last_consolidated (invalid state)."""
session = create_session_with_messages("test:negative", 10)
session.last_consolidated = -5
keep_count = 3
old_messages = get_old_messages(session, session.last_consolidated, keep_count)
# messages[-5:-3] with 10 messages gives indices 5,6
assert len(old_messages) == 2
assert old_messages[0]["content"] == "msg5"
assert old_messages[-1]["content"] == "msg6"
def test_messages_added_after_consolidation(self):
"""Test correct behavior when new messages arrive after consolidation."""
session = create_session_with_messages("test:new_messages", 40)
session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
# Add new messages after consolidation
for i in range(40, 50):
session.add_message("user", f"msg{i}")
total_messages = len(session.messages)
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
expected_consolidate_count = total_messages - KEEP_COUNT - session.last_consolidated
assert len(old_messages) == expected_consolidate_count
assert_messages_content(old_messages, 15, 24)
def test_slice_behavior_when_indices_overlap(self):
"""Test slice behavior when last_consolidated >= total - keep_count."""
session = create_session_with_messages("test:overlap", 30)
session.last_consolidated = 12
old_messages = get_old_messages(session, session.last_consolidated, 20)
assert len(old_messages) == 0
class TestArchiveAllMode:
"""Test archive_all mode (used by /new command)."""
def test_archive_all_consolidates_everything(self):
"""Test archive_all=True consolidates all messages."""
session = create_session_with_messages("test:archive_all", 50)
archive_all = True
if archive_all:
old_messages = session.messages
assert len(old_messages) == 50
assert session.last_consolidated == 0
def test_archive_all_resets_last_consolidated(self):
"""Test that archive_all mode resets last_consolidated to 0."""
session = create_session_with_messages("test:reset", 40)
session.last_consolidated = 15
archive_all = True
if archive_all:
session.last_consolidated = 0
assert session.last_consolidated == 0
assert len(session.messages) == 40
def test_archive_all_vs_normal_consolidation(self):
"""Test difference between archive_all and normal consolidation."""
# Normal consolidation
session1 = create_session_with_messages("test:normal", 60)
session1.last_consolidated = len(session1.messages) - KEEP_COUNT
# archive_all mode
session2 = create_session_with_messages("test:all", 60)
session2.last_consolidated = 0
assert session1.last_consolidated == 35
assert len(session1.messages) == 60
assert session2.last_consolidated == 0
assert len(session2.messages) == 60
class TestCacheImmutability:
"""Test that consolidation doesn't modify session.messages (cache safety)."""
def test_consolidation_does_not_modify_messages_list(self):
"""Test that consolidation leaves messages list unchanged."""
session = create_session_with_messages("test:immutable", 50)
original_messages = session.messages.copy()
original_len = len(session.messages)
session.last_consolidated = original_len - KEEP_COUNT
assert len(session.messages) == original_len
assert session.messages == original_messages
def test_get_history_does_not_modify_messages(self):
"""Test that get_history doesn't modify messages list."""
session = create_session_with_messages("test:history_immutable", 40)
original_messages = [m.copy() for m in session.messages]
for _ in range(5):
history = session.get_history(max_messages=10)
assert len(history) == 10
assert len(session.messages) == 40
for i, msg in enumerate(session.messages):
assert msg["content"] == original_messages[i]["content"]
def test_consolidation_only_updates_last_consolidated(self):
"""Test that consolidation only updates last_consolidated field."""
session = create_session_with_messages("test:field_only", 60)
original_messages = session.messages.copy()
original_key = session.key
original_metadata = session.metadata.copy()
session.last_consolidated = len(session.messages) - KEEP_COUNT
assert session.messages == original_messages
assert session.key == original_key
assert session.metadata == original_metadata
assert session.last_consolidated == 35
class TestSliceLogic:
"""Test the slice logic: messages[last_consolidated:-keep_count]."""
def test_slice_extracts_correct_range(self):
"""Test that slice extracts the correct message range."""
session = create_session_with_messages("test:slice", 60)
old_messages = get_old_messages(session, 0, KEEP_COUNT)
assert len(old_messages) == 35
assert_messages_content(old_messages, 0, 34)
remaining = session.messages[-KEEP_COUNT:]
assert len(remaining) == 25
assert_messages_content(remaining, 35, 59)
def test_slice_with_partial_consolidation(self):
"""Test slice when some messages already consolidated."""
session = create_session_with_messages("test:partial", 70)
last_consolidated = 30
old_messages = get_old_messages(session, last_consolidated, KEEP_COUNT)
assert len(old_messages) == 15
assert_messages_content(old_messages, 30, 44)
def test_slice_with_various_keep_counts(self):
"""Test slice behavior with different keep_count values."""
session = create_session_with_messages("test:keep_counts", 50)
test_cases = [(10, 40), (20, 30), (30, 20), (40, 10)]
for keep_count, expected_count in test_cases:
old_messages = session.messages[0:-keep_count]
assert len(old_messages) == expected_count
def test_slice_when_keep_count_exceeds_messages(self):
"""Test slice when keep_count > len(messages)."""
session = create_session_with_messages("test:exceed", 10)
old_messages = session.messages[0:-20]
assert len(old_messages) == 0
class TestEmptyAndBoundarySessions:
"""Test empty sessions and boundary conditions."""
def test_empty_session_consolidation(self):
"""Test consolidation behavior with empty session."""
session = Session(key="test:empty")
assert len(session.messages) == 0
assert session.last_consolidated == 0
messages_to_process = len(session.messages) - session.last_consolidated
assert messages_to_process == 0
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_single_message_session(self):
"""Test consolidation with single message."""
session = Session(key="test:single")
session.add_message("user", "only message")
assert len(session.messages) == 1
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_exactly_keep_count_messages(self):
"""Test session with exactly keep_count messages."""
session = create_session_with_messages("test:exact", KEEP_COUNT)
assert len(session.messages) == KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_just_over_keep_count(self):
"""Test session with one message over keep_count."""
session = create_session_with_messages("test:over", KEEP_COUNT + 1)
assert len(session.messages) == 26
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 1
assert old_messages[0]["content"] == "msg0"
def test_very_large_session(self):
"""Test consolidation with very large message count."""
session = create_session_with_messages("test:large", 1000)
assert len(session.messages) == 1000
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 975
assert_messages_content(old_messages, 0, 974)
remaining = session.messages[-KEEP_COUNT:]
assert len(remaining) == 25
assert_messages_content(remaining, 975, 999)
def test_session_with_gaps_in_consolidation(self):
"""Test session with potential gaps in consolidation history."""
session = create_session_with_messages("test:gaps", 50)
session.last_consolidated = 10
# Add more messages
for i in range(50, 60):
session.add_message("user", f"msg{i}")
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
expected_count = 60 - KEEP_COUNT - 10
assert len(old_messages) == expected_count
assert_messages_content(old_messages, 10, 34)