Compare commits

...
Author SHA1 Message Date
Re-bin 17de3699ab chore: bump version to 0.1.4.post2 2026-02-24 16:24:47 +00:00
Re-bin abc7b0aeb2 Merge PR #1107: fix(slack): post-process slackify_markdown output to catch leftover artifacts 2026-02-24 16:20:28 +00:00
Re-bin 96e1730af5 style: simplify _fixup_mrkdwn and trim docstring in SlackChannel 2026-02-24 16:20:28 +00:00
Re-bin a3f7cce416 Merge branch 'main' into pr-1107 2026-02-24 16:19:14 +00:00
Re-bin f223a4c5a3 Merge PR #1115: fix: stabilize system prompt for better cache reuse 2026-02-24 16:15:21 +00:00
Re-bin f294e9d065 refactor: merge runtime context helpers and move imports to top 2026-02-24 16:15:21 +00:00
rickthemad4 56b9b33c6d fix: stabilize system prompt for better cache reuse 2026-02-24 14:18:50 +00:00
Re-bin a818fff8fa chore: trim verbose docstrings 2026-02-24 13:47:17 +00:00
Re-bin a54b0853f0 Merge PR #1071: refactor(web): resolve api_key via property instead of inline 2026-02-24 13:42:35 +00:00
Re-bin 4b9ffea3fc merge origin/main into pr-1071, adopt @property api_key pattern 2026-02-24 13:41:49 +00:00
nanobot-agentandCursor 81b669b36e fix(slack): post-process slackify_markdown output to catch leftover artifacts
The slackify_markdown library (markdown-it) fails to convert **bold** when
the closing ** is immediately followed by non-space text (e.g. **Status:**OK).
This is a very common LLM output pattern that results in raw ** showing up
in Slack messages.

Add _fixup_mrkdwn() post-processor that:
- Converts leftover **bold** → *bold* (Slack mrkdwn)
- Converts leftover ## headers → *bold* (safety net)
- Fixes over-escaped & in bare URLs
- Protects code fences and inline code from being mangled

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 12:44:17 +00:00
nanobot-agentandCursor 8686f060d9 fix(slack): add post-processing to fix mrkdwn conversion edge cases
The slackify_markdown library misses several patterns that LLMs commonly
produce, causing raw Markdown symbols (**bold**, ##headers) to appear
in Slack messages.

Add _fixup_mrkdwn() post-processor that:
- Converts leftover **bold** patterns (e.g. **Status:**OK where closing
  ** is adjacent to non-space chars)
- Fixes &amp; over-escaping in bare URLs
- Protects code blocks from false-positive fixups

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 12:43:21 +00:00
Re-bin 0e4dba8d19 Merge PR #1062: fix(mcp): disable httpx default timeout for HTTP transport 2026-02-24 12:15:33 +00:00
Re-bin e080902d61 Merge remote-tracking branch 'origin/main' into pr-1062 2026-02-24 12:14:00 +00:00
Re-bin f828a1d5d1 fix(gateway): show actual heartbeat interval in startup log 2026-02-24 12:09:19 +00:00
Re-bin e4888d39f7 Merge PR #1077: fix(email): auto_reply_enabled should not block proactive sends 2026-02-24 12:08:13 +00:00
Re-bin c6b933df4a Merge remote-tracking branch 'origin/main' into pr-1077 2026-02-24 11:38:38 +00:00
Re-bin f514ba02e9 Merge PR #1090: feat(feishu): extract and download images from post messages 2026-02-24 11:32:04 +00:00
Re-bin 04218276ab Merge remote-tracking branch 'origin/main' into pr-1090 2026-02-24 11:31:40 +00:00
Re-bin cd5a8ac03d Merge PR #1061: fix(memory): handle JSON-string tool call arguments from providers 2026-02-24 11:23:10 +00:00
Re-bin d546cbac6e style(memory): use loguru {} formatting in warning 2026-02-24 11:23:10 +00:00
Re-bin b9eb9d4963 Merge remote-tracking branch 'origin/main' into pr-1061 2026-02-24 11:22:01 +00:00
Re-bin abd35b1295 Merge PR #1098: fix(web): resolve API key on each call + improve error message 2026-02-24 11:18:33 +00:00
Re-bin cda3a02f68 style(web): inline api key resolution, remove unnecessary method 2026-02-24 11:18:33 +00:00
Re-bin fdf24e8fd2 Merge branch 'main' into pr-1098 2026-02-24 11:14:37 +00:00
Xubin RenandGitHub 8d1eec114a Merge PR #1102 to replace HEARTBEAT_OK token with virtual tool-call decision
fix(heartbeat): replace HEARTBEAT_OK token with virtual tool-call decision
2026-02-24 19:07:55 +08:00
Re-bin ec55f77912 fix(heartbeat): replace HEARTBEAT_OK token with virtual tool-call decision 2026-02-24 11:04:56 +00:00
coldxiangyu ef57225974 fix(web): resolve API key on each call + improve error message
- Defer Brave API key resolution to execute() time instead of __init__,
  so env var or config changes take effect without gateway restart
- Improve error message to reference actual config path
  (tools.web.search.apiKey) instead of only mentioning env var

Fixes #1069 (issues 1 and 2 of 3)
2026-02-24 18:19:47 +08:00
xzq.xuandCursor 4f8033627e feat(feishu): support images in post (rich text) messages
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 13:42:07 +08:00
chengyongru 91e13d91ac fix(email): allow proactive sends when autoReplyEnabled is false
Previously, `autoReplyEnabled=false` would block ALL email sends,
including proactive emails triggered from other channels (e.g., asking
nanobot on Feishu to send an email).

Now `autoReplyEnabled` only controls automatic replies to incoming
emails, not proactive sends. This allows users to disable auto-replies
while still being able to ask nanobot to send emails on demand.

Changes:
- Check if recipient is in `_last_subject_by_chat` to determine if
  it's a reply
- Only skip sending when it's a reply AND auto_reply_enabled is false
- Add test for proactive send with auto_reply_enabled=false
- Update existing test to verify reply behavior
2026-02-24 04:27:14 +08:00
haosenwang1018 eeaad6e0c2 fix: resolve API key at call time so config changes take effect without restart
Previously, WebSearchTool cached the API key in __init__, so keys added
to config.json or env vars after gateway startup were never picked up.
This caused a confusing 'BRAVE_API_KEY not configured' error even after
the key was correctly set (issue #1069).

Changes:
- Store the init-time key separately, resolve via property at each call
- Improve error message to guide users toward the correct fix

Closes #1069
2026-02-24 04:06:22 +08:00
Re-bin 30361c9307 refactor: replace cron usage docs in TOOLS.md with reference to cron skill 2026-02-23 18:28:09 +00:00
dulltackle f8dc6fafa9 **fix(mcp): Remove default timeout for HTTP transport to avoid tool timeout conflicts**
Always provide an explicit httpx client to prevent MCP HTTP transport from inheriting httpx's default 5-second timeout, thereby avoiding conflicts with the upper layer tool's timeout settings.
2026-02-24 01:26:56 +08:00
alairjt 3eeac4e8f8 Fix: handle non-string tool call arguments in memory consolidation
Fixes #1042. When the LLM returns tool call arguments as a dict or
JSON string instead of parsed values, memory consolidation would fail
with "TypeError: data must be str, not dict".

Changes:
- Add type guard in MemoryStore.consolidate() to parse string arguments
  and reject unexpected types gracefully
- Add regression tests covering dict args, string args, and edge cases
2026-02-23 13:59:49 -03:00
Re-bin 2f573e591b fix(session): get_history uses last_consolidated cursor, aligns to user turn 2026-02-23 16:57:08 +00:00
Re-bin 35e3f7ed26 fix(templates): tighten AGENTS.md tool call guidelines to reduce hallucinations 2026-02-23 14:10:43 +00:00
Re-bin c76a8d2e83 Merge PR #1029: fix: break Discord typing loop on persistent HTTP failure 2026-02-23 14:06:36 +00:00
Re-bin 3c2cc3a71c Merge remote-tracking branch 'origin/main' into pr-1029 2026-02-23 14:01:43 +00:00
Re-bin f4b3bbd87c Merge PR #1039: fix(heartbeat): make start idempotent and add tests 2026-02-23 13:59:47 +00:00
Re-bin eae6059889 fix: remove extra blank line 2026-02-23 13:59:47 +00:00
Re-bin 6f4d1c2cdc merge origin/main into pr-1039, adopt HEARTBEAT_OK in-check and on_notify 2026-02-23 13:57:28 +00:00
Xubin RenandGitHub 54e350a496 Merge PR #1054 to deliver agent response to user and fix HEARTBEAT_OK detection
fix(heartbeat): deliver agent response to user and fix HEARTBEAT_OK detection
2026-02-23 21:52:08 +08:00
Re-bin 7671239902 fix(heartbeat): suppress progress messages and deliver agent response to user 2026-02-23 13:45:09 +00:00
Re-bin 2c09f23c02 Merge PR #1048: feat(slack): isolate session context per thread 2026-02-23 13:10:55 +00:00
Re-bin 2b983c708d refactor: pass session_key as explicit param instead of via metadata 2026-02-23 13:10:47 +00:00
Re-bin 0be70b05b1 Merge remote-tracking branch 'origin/main' into pr-1048 2026-02-23 13:04:54 +00:00
Re-bin ea1c4ef025 fix: suppress heartbeat progress messages to external channels 2026-02-23 12:33:29 +00:00
Paul 1f7a81e5ee feat(slack): isolate session context per thread
Each Slack thread now gets its own conversation session instead of
sharing one session per channel. DM sessions are unchanged.

Added as a generic feature to also support if Feishu threads support
is added in the future.
2026-02-23 10:23:55 +00:00
Xubin RenandGitHub b2a1d1208e Merge PR #1046 to improve agent reliability: behavioral constraints, full tool history, error hints
improve agent reliability: behavioral constraints, full tool history, error hints
2026-02-23 17:16:09 +08:00
Re-bin d9462284e1 improve agent reliability: behavioral constraints, full tool history, error hints 2026-02-23 09:13:08 +00:00
Re-bin 491739223d fix: lower default temperature from 0.7 to 0.1 2026-02-23 08:24:53 +00:00
Xubin RenandGitHub e69ff8ac0e Merge pull request #1043 to move workspace/ to nanobot/templates/ for packaging
refactor: move workspace/ to nanobot/templates/ for packaging
2026-02-23 16:11:44 +08:00
Re-bin 577b3d104a refactor: move workspace/ to nanobot/templates/ for packaging 2026-02-23 08:08:01 +00:00
Re-bin f8e8cbee6a Merge PR #1036: fix(heartbeat): route heartbeat runs to enabled chat context 2026-02-23 07:45:20 +00:00
Re-bin e4376896ed Merge remote-tracking branch 'origin/main' into pr-1036 2026-02-23 07:16:40 +00:00
Re-bin 0fdbd5a037 Merge PR #1000: feat(channels): add send_progress option to control progress message delivery 2026-02-23 07:12:55 +00:00
Re-bin df2c837e25 feat(channels): split send_progress into send_progress + send_tool_hints 2026-02-23 07:12:41 +00:00
Re-bin c20b867497 Merge remote-tracking branch 'origin/main' into pr-1000 2026-02-23 06:12:25 +00:00
yzchen bfdae1b177 fix(heartbeat): make start idempotent and check exact OK token 2026-02-23 13:56:37 +08:00
Re-bin bc32e85c25 fix(memory): trigger consolidation by unconsolidated count, not total 2026-02-23 05:51:44 +00:00
Kim 9025c7088f fix(heartbeat): route heartbeat runs to enabled chat context 2026-02-23 12:28:21 +08:00
Yingwen Luo-LUOYW 31a873ca59 Merge branch 'main' of https://github.com/HKUDS/nanobot 2026-02-23 09:41:56 +08:00
Yingwen Luo-LUOYW 0c412b3728 feat(channels): add send_progress option to control progress message delivery
Add a boolean config option `channels.sendProgress` (default: false) to
control whether progress messages (marked with `_progress` metadata) are
sent to chat channels. When disabled, progress messages are filtered
out in the outbound dispatcher.
2026-02-23 09:41:13 +08:00
Nikolas de Hor 4303026e0d fix: break Discord typing loop on persistent HTTP failure
The typing indicator loop catches all exceptions with bare
except/pass, so a permanent HTTP failure (client closed, auth
error, etc.) causes the loop to spin every 8 seconds doing
nothing until the channel is explicitly stopped.

Log the error and exit the loop instead, letting the task
clean up naturally.
2026-02-22 22:01:16 -03:00
Re-bin 25f0a236fd docs: fix MiniMax API key link 2026-02-22 18:29:09 +00:00
Re-bin c6f670809c Merge PR #949: fix(provider): filter empty text content blocks causing API 400 2026-02-22 18:26:42 +00:00
Re-bin b653183bb0 refactor(providers): move empty content sanitization to base class 2026-02-22 18:26:42 +00:00
Re-bin 2f7835a301 Merge remote-tracking branch 'origin/main' into pr-949 2026-02-22 18:21:47 +00:00
Re-bin 6913d541c8 Merge PR #986: fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 18:16:45 +00:00
Re-bin efe89c9091 fix(feishu): pass msg_type as resource_type and clean up style 2026-02-22 18:16:45 +00:00
Re-bin 3d55c9cd03 Merge remote-tracking branch 'origin/main' into pr-986 2026-02-22 18:13:37 +00:00
Re-bin 4f0930f517 Merge PR #955: fix(providers): normalize empty reasoning_content to None at provider level 2026-02-22 18:11:45 +00:00
Re-bin c8881c5d49 Merge remote-tracking branch 'origin/main' into pr-955 2026-02-22 18:08:43 +00:00
Re-bin e46edf2806 Merge PR #950: fix(mcp): add configurable timeout to MCP tool calls 2026-02-22 18:04:13 +00:00
Re-bin 437ebf4e6e feat(mcp): make tool_timeout configurable per server via config 2026-02-22 18:04:13 +00:00
Re-bin 51f6247aed Merge remote-tracking branch 'origin/main' into pr-950 2026-02-22 17:52:24 +00:00
Re-bin 14ba50c172 Merge PR #968: docs: add systemd user service instructions to README 2026-02-22 17:51:23 +00:00
Re-bin 1aa06ea03d docs: improve Linux Service section in README 2026-02-22 17:51:23 +00:00
Re-bin 12af652d5a Merge remote-tracking branch 'origin/main' into pr-968 2026-02-22 17:48:32 +00:00
Re-bin e322f82f9c Merge PR #962: fix(qq): make start() long-running per base channel contract 2026-02-22 17:35:53 +00:00
Re-bin b53c3d39ed fix(qq): remove dead _bot_task field and fix stop() to close client 2026-02-22 17:35:53 +00:00
Re-bin 9efe95970e Merge branch 'main' into pr-962 2026-02-22 17:24:34 +00:00
Re-bin b13d7f853e fix(agent): make tool hint a fallback when no content in on_progress 2026-02-22 17:17:35 +00:00
Re-bin d5e820df98 Merge PR #881: fix(loop): serialize /new consolidation, track task refs, archive before clear 2026-02-22 17:11:59 +00:00
Re-bin 1cfcc647b7 fix(loop): resolve conflicts with main and improve /new handler 2026-02-22 17:11:59 +00:00
Re-bin 60751909cb Merge PR #959: fix(email): evict oldest half of dedup set instead of clearing entirely 2026-02-22 15:48:49 +00:00
Re-bin 4e8c8cc227 fix(email): fix misleading comment and simplify uid eviction 2026-02-22 15:48:49 +00:00
Re-bin d82c292c99 Merge branch 'main' into pr-959 2026-02-22 15:41:09 +00:00
Re-bin 598f7dafd1 Merge PR #958: fix(session): handle errors in legacy session migration 2026-02-22 15:40:17 +00:00
Re-bin 71de1899e6 fix(session): use logger.exception and move import to top 2026-02-22 15:40:17 +00:00
Re-bin b8a06f8d19 Merge branch 'main' into pr-958 2026-02-22 15:39:09 +00:00
Re-bin b161628ad7 Merge PR #957: fix(slack): add exception handling to socket listener 2026-02-22 15:38:19 +00:00
Re-bin b93b77a485 fix(slack): use logger.exception to capture full traceback 2026-02-22 15:38:19 +00:00
Re-bin c53deecdb1 Merge branch 'main' into pr-957 2026-02-22 15:35:26 +00:00
Re-bin ef64739736 Merge PR #956: fix(security): prevent path traversal bypass via startswith check 2026-02-22 15:34:36 +00:00
Re-bin e0743d6345 Merge branch 'main' into pr-956 2026-02-22 15:33:28 +00:00
FloRa 0d3a2963d0 fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 17:37:33 +08:00
FloRa 973061b01e fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 17:15:00 +08:00
Xubin RenandGitHub fff6207c6b Merge PR #982 to add DingTalk, QQ, and Email to channels status output
feat(cli): add DingTalk, QQ, and Email to channels status output
2026-02-22 14:57:44 +08:00
Yingwen Luo-LUOYW b323087631 feat(cli): add DingTalk, QQ, and Email to channels status output 2026-02-22 12:42:33 +08:00
Rok Pergarec 3e40600483 docs: add systemd user service instructions to README 2026-02-21 20:55:54 +01:00
andienguyen-ecoligo 8c55b40b9f fix(qq): make start() long-running per base channel contract
QQ channel's start() created a background task and returned immediately,
violating the base Channel contract which specifies start() should be
"a long-running async task". This caused the gateway to exit prematurely
when QQ was the only enabled channel.

Now directly awaits _run_bot() to stay alive like other channels.

Fixes #894
2026-02-21 12:38:24 -05:00
andienguyen-ecoligo ba66c64750 fix(email): evict oldest half of dedup set instead of clearing entirely
When _processed_uids exceeds 100k entries, the entire set was cleared
with .clear(), allowing all previously seen emails to be re-processed.

Now evicts the oldest 50% of entries, keeping recent UIDs to prevent
duplicate processing while still bounding memory usage.

Fixes #890
2026-02-21 12:36:04 -05:00
andienguyen-ecoligo 54a0f3d038 fix(session): handle errors in legacy session migration
shutil.move() in _load() can fail due to permissions, disk full, or
concurrent access. Without error handling, the exception propagates up
and prevents the session from loading entirely.

Wrap in try/except so migration failures are logged as warnings and the
session falls back to loading from the legacy path on next attempt.

Fixes #863
2026-02-21 12:35:21 -05:00
andienguyen-ecoligo ef96619039 fix(slack): add exception handling to socket listener
_handle_message() in _on_socket_request() had no try/except. If it
throws (bus full, permission error, etc.), the exception propagates up
and crashes the Socket Mode event loop, causing missed messages.

Other channels like Telegram already have explicit error handlers.

Fixes #895
2026-02-21 12:34:50 -05:00
andienguyen-ecoligo 5c9cb3a208 fix(security): prevent path traversal bypass via startswith check
`startswith` string comparison allows bypassing directory restrictions.
For example, `/home/user/workspace_evil` passes the check against
`/home/user/workspace` because the string starts with the allowed path.

Replace with `Path.relative_to()` which correctly validates that the
resolved path is actually inside the allowed directory tree.

Fixes #888
2026-02-21 12:34:14 -05:00
andienguyen-ecoligo de63c31d43 fix(providers): normalize empty reasoning_content to None at provider level
PR #947 fixed the consumer side (context.py) but the root cause is at
the provider level — getattr returns "" (empty string) instead of None
when reasoning_content is empty. This causes DeepSeek API to reject the
request with "Missing reasoning_content field" error.

`"" or None` evaluates to None, preventing empty strings from
propagating downstream.

Fixes #946
2026-02-21 12:30:57 -05:00
Re-bin 0040c62b74 Merge PR #939: Remove redundant tools description from system prompt 2026-02-21 17:07:02 +00:00
Re-bin 13d768cd93 Merge branch 'main' into pr-939 2026-02-21 17:06:05 +00:00
Xubin RenandGitHub 6a9152f0c4 Merge PR #947 to Fix 'Missing reasoning_content field' error for deepseek provider.
fix(context): Fix 'Missing `reasoning_content` field' error for deepseek provider.
2026-02-22 00:47:58 +08:00
Xubin RenandGitHub 9b4273f6a4 Merge PR #951 to change VolcEngine litellm prefix from openai to volcengine
fix: change VolcEngine litellm prefix from openai to volcengine
2026-02-22 00:45:49 +08:00
init-new-world deae84482d fix: change VolcEngine litellm prefix from openai to volcengine 2026-02-22 00:42:41 +08:00
muskliu 6b7d7e2eb8 fix(mcp): add 30s timeout to MCP tool calls to prevent agent hangs 2026-02-22 00:39:53 +08:00
Re-bin edc671a8a3 docs: update format of news section 2026-02-21 16:39:26 +00:00
muskliuandnanobot 83ccdf6186 fix(provider): filter empty text content blocks causing API 400
When MCP tools return empty content, messages may contain empty-string
text blocks. OpenAI-compatible providers reject these with HTTP 400.

Changes:
- Add _prevent_empty_text_blocks() to filter empty text items from
  content lists and handle empty string content
- For assistant messages with tool_calls, set content to None (valid)
- For other messages, replace with '(empty)' placeholder
- Only copy message dict when modification is needed (zero-copy path
  for normal messages)

Co-Authored-By: nanobot <noreply@anthropic.com>
2026-02-22 00:20:00 +08:00
nanobot-bot 01c835aac2 fix(context): Fix 'Missing reasoning_content field' error for deepseek provider. 2026-02-21 23:11:30 +08:00
Re-bin 88ca2e0530 docs: update v.0.1.4.post1 release news 2026-02-21 13:20:55 +00:00
vincentchen b3acd19c7b Remove redundant tools description (because tools information is passed in with each self.provider.chat() call) 2026-02-21 20:28:42 +08:00
Alexander Minges 426ef71ce7 style(loop): drop formatting-only churn against upstream main 2026-02-20 13:57:39 +01:00
Alexander Minges df022febaf refactor(loop): drop redundant Any typing in /new snapshot 2026-02-20 13:33:51 +01:00
Alexander Minges c1b5e8c8d2 fix(loop): lock /new snapshot and prune stale consolidation locks 2026-02-20 13:32:57 +01:00
Alexander Minges 9ada8e6854 fix(loop): require successful archival before /new clear 2026-02-20 13:06:07 +01:00
Alexander Minges 5f9eca4664 style(loop): remove formatting-only changes from upstream PR 881 2026-02-20 12:46:11 +01:00
Alexander Minges 755e424127 fix(loop): serialize /new consolidation and track task refs 2026-02-20 12:40:59 +01:00
42 changed files with 1450 additions and 535 deletions
+93 -2
View File
@@ -16,10 +16,11 @@
⚡️ 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,806 lines** (run `bash core_agent_lines.sh` to verify anytime) 📏 Real-time line count: **3,955 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News ## 📢 News
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood. - **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. - **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. - **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
@@ -33,6 +34,7 @@
<details> <details>
<summary>Earlier news</summary> <summary>Earlier news</summary>
- **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).
@@ -42,6 +44,7 @@
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot! - **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
</details> </details>
## Key Features of nanobot: ## Key Features of nanobot:
@@ -590,7 +593,7 @@ Config file: `~/.nanobot/config.json`
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `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.minimaxi.com](https://platform.minimaxi.com) |
| `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/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | | `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) | | `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) |
@@ -773,6 +776,21 @@ Two transport modes are supported:
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` | | **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) | | **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
```json
{
"tools": {
"mcpServers": {
"my-slow-server": {
"url": "https://example.com/mcp/",
"toolTimeout": 120
}
}
}
}
```
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed. MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
@@ -823,6 +841,26 @@ nanobot cron remove <job_id>
</details> </details>
<details>
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown
## Periodic Tasks
- [ ] Check weather forecast and send a summary
- [ ] Scan inbox for urgent emails
```
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
</details>
## 🐳 Docker ## 🐳 Docker
> [!TIP] > [!TIP]
@@ -862,6 +900,59 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m "Hello!"
docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status
``` ```
## 🐧 Linux Service
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
**1. Find the nanobot binary path:**
```bash
which nanobot # e.g. /home/user/.local/bin/nanobot
```
**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed):
```ini
[Unit]
Description=Nanobot Gateway
After=network.target
[Service]
Type=simple
ExecStart=%h/.local/bin/nanobot gateway
Restart=always
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=%h
[Install]
WantedBy=default.target
```
**3. Enable and start:**
```bash
systemctl --user daemon-reload
systemctl --user enable --now nanobot-gateway
```
**Common operations:**
```bash
systemctl --user status nanobot-gateway # check status
systemctl --user restart nanobot-gateway # restart after config changes
journalctl --user -u nanobot-gateway -f # follow logs
```
If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
>
> ```bash
> loginctl enable-linger $USER
> ```
## 📁 Project Structure ## 📁 Project Structure
``` ```
+1 -1
View File
@@ -2,5 +2,5 @@
nanobot - A lightweight AI agent framework nanobot - A lightweight AI agent framework
""" """
__version__ = "0.1.4" __version__ = "0.1.4.post2"
__logo__ = "🐈" __logo__ = "🐈"
+33 -23
View File
@@ -3,6 +3,8 @@
import base64 import base64
import mimetypes import mimetypes
import platform import platform
import time
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -72,25 +74,13 @@ Skills with available="false" need dependencies installed first - you can try in
def _get_identity(self) -> str: def _get_identity(self) -> str:
"""Get the core identity section.""" """Get the core identity section."""
from datetime import datetime
import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
workspace_path = str(self.workspace.expanduser().resolve()) workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system() system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return f"""# nanobot 🐈 return f"""# nanobot 🐈
You are nanobot, a helpful AI assistant. You have access to tools that allow you to: You are nanobot, a helpful AI assistant.
- Read, write, and edit files
- Execute shell commands
- Search the web and fetch web pages
- Send messages to users on chat channels
- Spawn subagents for complex background tasks
## Current Time
{now} ({tz})
## Runtime ## Runtime
{runtime} {runtime}
@@ -101,14 +91,35 @@ Your workspace is at: {workspace_path}
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
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.
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). ## Tool Call Guidelines
If you need to use tools, call them directly — never send a preliminary message like "Let me check" without actually calling a tool. - Before calling tools, you may briefly state your intent (e.g. "Let me check that"), but NEVER predict or describe the expected result before receiving it.
When remembering something important, write to {workspace_path}/memory/MEMORY.md - Before modifying a file, read it first to confirm its current content.
To recall past events, grep {workspace_path}/memory/HISTORY.md""" - Do not assume a file or directory exists — use list_dir or read_file to verify.
- After writing or editing a file, re-read it if accuracy matters.
- If a tool call fails, analyze the error before retrying with a different approach.
## Memory
- Remember important facts: write to {workspace_path}/memory/MEMORY.md
- Recall past events: grep {workspace_path}/memory/HISTORY.md"""
@staticmethod
def _inject_runtime_context(
user_content: str | list[dict[str, Any]],
channel: str | None,
chat_id: str | None,
) -> str | list[dict[str, Any]]:
"""Append dynamic runtime context to the tail of the user message."""
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = time.strftime("%Z") or "UTC"
lines = [f"Current Time: {now} ({tz})"]
if channel and chat_id:
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
block = "[Runtime Context]\n" + "\n".join(lines)
if isinstance(user_content, str):
return f"{user_content}\n\n{block}"
return [*user_content, {"type": "text", "text": block}]
def _load_bootstrap_files(self) -> str: def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace.""" """Load all bootstrap files from workspace."""
@@ -149,8 +160,6 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
# System prompt # System prompt
system_prompt = self.build_system_prompt(skill_names) system_prompt = self.build_system_prompt(skill_names)
if channel and chat_id:
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "system", "content": system_prompt})
# History # History
@@ -158,6 +167,7 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
# Current message (with optional image attachments) # Current message (with optional image attachments)
user_content = self._build_user_content(current_message, media) user_content = self._build_user_content(current_message, media)
user_content = self._inject_runtime_context(user_content, channel, chat_id)
messages.append({"role": "user", "content": user_content}) messages.append({"role": "user", "content": user_content})
return messages return messages
@@ -236,7 +246,7 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
msg["tool_calls"] = tool_calls msg["tool_calls"] = tool_calls
# Include reasoning content when provided (required by some thinking models) # Include reasoning content when provided (required by some thinking models)
if reasoning_content: if reasoning_content is not None:
msg["reasoning_content"] = reasoning_content msg["reasoning_content"] = reasoning_content
messages.append(msg) messages.append(msg)
+96 -35
View File
@@ -7,7 +7,7 @@ import json
import re import re
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Awaitable, Callable from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger from loguru import logger
@@ -27,7 +27,7 @@ from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.config.schema import ExecToolConfig from nanobot.config.schema import ChannelsConfig, ExecToolConfig
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
@@ -49,19 +49,21 @@ class AgentLoop:
provider: LLMProvider, provider: LLMProvider,
workspace: Path, workspace: Path,
model: str | None = None, model: str | None = None,
max_iterations: int = 20, max_iterations: int = 40,
temperature: float = 0.7, temperature: float = 0.1,
max_tokens: int = 4096, max_tokens: int = 4096,
memory_window: int = 50, memory_window: int = 100,
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, mcp_servers: dict | None = None,
channels_config: ChannelsConfig | None = None,
): ):
from nanobot.config.schema import ExecToolConfig from nanobot.config.schema import ExecToolConfig
self.bus = bus self.bus = bus
self.channels_config = channels_config
self.provider = provider self.provider = provider
self.workspace = workspace self.workspace = workspace
self.model = model or provider.get_default_model() self.model = model or provider.get_default_model()
@@ -95,6 +97,8 @@ class AgentLoop:
self._mcp_connected = False self._mcp_connected = False
self._mcp_connecting = False self._mcp_connecting = False
self._consolidating: set[str] = set() # Session keys with consolidation in progress self._consolidating: set[str] = set() # Session keys with consolidation in progress
self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks
self._consolidation_locks: dict[str, asyncio.Lock] = {}
self._register_default_tools() self._register_default_tools()
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
@@ -170,9 +174,9 @@ class AgentLoop:
async def _run_agent_loop( async def _run_agent_loop(
self, self,
initial_messages: list[dict], initial_messages: list[dict],
on_progress: Callable[[str], Awaitable[None]] | None = None, on_progress: Callable[..., Awaitable[None]] | None = None,
) -> tuple[str | None, list[str]]: ) -> tuple[str | None, list[str], list[dict]]:
"""Run the agent iteration loop. Returns (final_content, tools_used).""" """Run the agent iteration loop. Returns (final_content, tools_used, messages)."""
messages = initial_messages messages = initial_messages
iteration = 0 iteration = 0
final_content = None final_content = None
@@ -194,7 +198,7 @@ class AgentLoop:
clean = self._strip_think(response.content) clean = self._strip_think(response.content)
if clean: if clean:
await on_progress(clean) await on_progress(clean)
await on_progress(self._tool_hint(response.tool_calls)) await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [ tool_call_dicts = [
{ {
@@ -224,7 +228,14 @@ class AgentLoop:
final_content = self._strip_think(response.content) final_content = self._strip_think(response.content)
break break
return final_content, tools_used if final_content is None and iteration >= self.max_iterations:
logger.warning("Max iterations ({}) reached", self.max_iterations)
final_content = (
f"I reached the maximum number of tool call iterations ({self.max_iterations}) "
"without completing the task. You can try breaking the task into smaller steps."
)
return final_content, tools_used, messages
async def run(self) -> None: async def run(self) -> None:
"""Run the agent loop, processing messages from the bus.""" """Run the agent loop, processing messages from the bus."""
@@ -270,6 +281,18 @@ class AgentLoop:
self._running = False self._running = False
logger.info("Agent loop stopping") logger.info("Agent loop stopping")
def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock:
lock = self._consolidation_locks.get(session_key)
if lock is None:
lock = asyncio.Lock()
self._consolidation_locks[session_key] = lock
return lock
def _prune_consolidation_lock(self, session_key: str, lock: asyncio.Lock) -> None:
"""Drop lock entry if no longer in use."""
if not lock.locked():
self._consolidation_locks.pop(session_key, None)
async def _process_message( async def _process_message(
self, self,
msg: InboundMessage, msg: InboundMessage,
@@ -285,13 +308,13 @@ class AgentLoop:
key = f"{channel}:{chat_id}" key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
history = session.get_history(max_messages=self.memory_window)
messages = self.context.build_messages( messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window), history=history,
current_message=msg.content, channel=channel, chat_id=chat_id, current_message=msg.content, channel=channel, chat_id=chat_id,
) )
final_content, _ = await self._run_agent_loop(messages) final_content, _, all_msgs = await self._run_agent_loop(messages)
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") self._save_turn(session, all_msgs, 1 + len(history))
session.add_message("assistant", final_content or "Background task completed.")
self.sessions.save(session) self.sessions.save(session)
return OutboundMessage(channel=channel, chat_id=chat_id, return OutboundMessage(channel=channel, chat_id=chat_id,
content=final_content or "Background task completed.") content=final_content or "Background task completed.")
@@ -305,54 +328,79 @@ class AgentLoop:
# Slash commands # Slash commands
cmd = msg.content.strip().lower() cmd = msg.content.strip().lower()
if cmd == "/new": if cmd == "/new":
messages_to_archive = session.messages.copy() lock = self._get_consolidation_lock(session.key)
self._consolidating.add(session.key)
try:
async with lock:
snapshot = session.messages[session.last_consolidated:]
if snapshot:
temp = Session(key=session.key)
temp.messages = list(snapshot)
if not await self._consolidate_memory(temp, archive_all=True):
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
except Exception:
logger.exception("/new archival failed for {}", session.key)
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
finally:
self._consolidating.discard(session.key)
self._prune_consolidation_lock(session.key, lock)
session.clear() session.clear()
self.sessions.save(session) self.sessions.save(session)
self.sessions.invalidate(session.key) self.sessions.invalidate(session.key)
async def _consolidate_and_cleanup():
temp = Session(key=session.key)
temp.messages = messages_to_archive
await self._consolidate_memory(temp, archive_all=True)
asyncio.create_task(_consolidate_and_cleanup())
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started. Memory consolidation in progress.") content="New session started.")
if cmd == "/help": if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
if len(session.messages) > self.memory_window and session.key not in self._consolidating: unconsolidated = len(session.messages) - session.last_consolidated
if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
self._consolidating.add(session.key) self._consolidating.add(session.key)
lock = self._get_consolidation_lock(session.key)
async def _consolidate_and_unlock(): async def _consolidate_and_unlock():
try: try:
await self._consolidate_memory(session) async with lock:
await self._consolidate_memory(session)
finally: finally:
self._consolidating.discard(session.key) self._consolidating.discard(session.key)
self._prune_consolidation_lock(session.key, lock)
_task = asyncio.current_task()
if _task is not None:
self._consolidation_tasks.discard(_task)
asyncio.create_task(_consolidate_and_unlock()) _task = asyncio.create_task(_consolidate_and_unlock())
self._consolidation_tasks.add(_task)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
if message_tool := self.tools.get("message"): if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool): if isinstance(message_tool, MessageTool):
message_tool.start_turn() message_tool.start_turn()
history = session.get_history(max_messages=self.memory_window)
initial_messages = self.context.build_messages( initial_messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window), history=history,
current_message=msg.content, current_message=msg.content,
media=msg.media if msg.media else None, media=msg.media if msg.media else None,
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
) )
async def _bus_progress(content: str) -> None: async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
meta = dict(msg.metadata or {}) meta = dict(msg.metadata or {})
meta["_progress"] = True meta["_progress"] = True
meta["_tool_hint"] = tool_hint
await self.bus.publish_outbound(OutboundMessage( await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta, channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
)) ))
final_content, tools_used = await self._run_agent_loop( final_content, _, all_msgs = await self._run_agent_loop(
initial_messages, on_progress=on_progress or _bus_progress, initial_messages, on_progress=on_progress or _bus_progress,
) )
@@ -362,9 +410,7 @@ class AgentLoop:
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("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
session.add_message("user", msg.content) self._save_turn(session, all_msgs, 1 + len(history))
session.add_message("assistant", final_content,
tools_used=tools_used if tools_used else None)
self.sessions.save(session) self.sessions.save(session)
if message_tool := self.tools.get("message"): if message_tool := self.tools.get("message"):
@@ -376,9 +422,24 @@ class AgentLoop:
metadata=msg.metadata or {}, metadata=msg.metadata or {},
) )
async def _consolidate_memory(self, session, archive_all: bool = False) -> None: _TOOL_RESULT_MAX_CHARS = 500
"""Delegate to MemoryStore.consolidate()."""
await MemoryStore(self.workspace).consolidate( def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
"""Save new-turn messages into session, truncating large tool results."""
from datetime import datetime
for m in messages[skip:]:
entry = {k: v for k, v in m.items() if k != "reasoning_content"}
if entry.get("role") == "tool" and isinstance(entry.get("content"), str):
content = entry["content"]
if len(content) > self._TOOL_RESULT_MAX_CHARS:
entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
session.updated_at = datetime.now()
async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
"""Delegate to MemoryStore.consolidate(). Returns True on success."""
return await MemoryStore(self.workspace).consolidate(
session, self.provider, self.model, session, self.provider, self.model,
archive_all=archive_all, memory_window=self.memory_window, archive_all=archive_all, memory_window=self.memory_window,
) )
+20 -8
View File
@@ -74,8 +74,11 @@ class MemoryStore:
*, *,
archive_all: bool = False, archive_all: bool = False,
memory_window: int = 50, memory_window: int = 50,
) -> None: ) -> bool:
"""Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.""" """Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.
Returns True on success (including no-op), False on failure.
"""
if archive_all: if archive_all:
old_messages = session.messages old_messages = session.messages
keep_count = 0 keep_count = 0
@@ -83,12 +86,12 @@ class MemoryStore:
else: else:
keep_count = memory_window // 2 keep_count = memory_window // 2
if len(session.messages) <= keep_count: if len(session.messages) <= keep_count:
return return True
if len(session.messages) - session.last_consolidated <= 0: if len(session.messages) - session.last_consolidated <= 0:
return return True
old_messages = session.messages[session.last_consolidated:-keep_count] old_messages = session.messages[session.last_consolidated:-keep_count]
if not old_messages: if not old_messages:
return return True
logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count) logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count)
lines = [] lines = []
@@ -119,9 +122,16 @@ class MemoryStore:
if not response.has_tool_calls: if not response.has_tool_calls:
logger.warning("Memory consolidation: LLM did not call save_memory, skipping") logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
return return False
args = response.tool_calls[0].arguments args = response.tool_calls[0].arguments
# Some providers return arguments as a JSON string instead of dict
if isinstance(args, str):
args = json.loads(args)
if not isinstance(args, dict):
logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__)
return False
if entry := args.get("history_entry"): if entry := args.get("history_entry"):
if not isinstance(entry, str): if not isinstance(entry, str):
entry = json.dumps(entry, ensure_ascii=False) entry = json.dumps(entry, ensure_ascii=False)
@@ -134,5 +144,7 @@ class MemoryStore:
session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count
logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
except Exception as e: return True
logger.error("Memory consolidation failed: {}", e) except Exception:
logger.exception("Memory consolidation failed")
return False
+5 -2
View File
@@ -13,8 +13,11 @@ def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path |
if not p.is_absolute() and workspace: if not p.is_absolute() and workspace:
p = workspace / p p = workspace / p
resolved = p.resolve() resolved = p.resolve()
if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())): if allowed_dir:
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") try:
resolved.relative_to(allowed_dir.resolve())
except ValueError:
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
return resolved return resolved
+23 -16
View File
@@ -1,5 +1,6 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" """MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from typing import Any from typing import Any
@@ -13,12 +14,13 @@ from nanobot.agent.tools.registry import ToolRegistry
class MCPToolWrapper(Tool): class MCPToolWrapper(Tool):
"""Wraps a single MCP server tool as a nanobot Tool.""" """Wraps a single MCP server tool as a nanobot Tool."""
def __init__(self, session, server_name: str, tool_def): def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
self._session = session self._session = session
self._original_name = tool_def.name self._original_name = tool_def.name
self._name = f"mcp_{server_name}_{tool_def.name}" self._name = f"mcp_{server_name}_{tool_def.name}"
self._description = tool_def.description or tool_def.name self._description = tool_def.description or tool_def.name
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
self._tool_timeout = tool_timeout
@property @property
def name(self) -> str: def name(self) -> str:
@@ -34,7 +36,14 @@ class MCPToolWrapper(Tool):
async def execute(self, **kwargs: Any) -> str: async def execute(self, **kwargs: Any) -> str:
from mcp import types from mcp import types
result = await self._session.call_tool(self._original_name, arguments=kwargs) try:
result = await asyncio.wait_for(
self._session.call_tool(self._original_name, arguments=kwargs),
timeout=self._tool_timeout,
)
except asyncio.TimeoutError:
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
return f"(MCP tool call timed out after {self._tool_timeout}s)"
parts = [] parts = []
for block in result.content: for block in result.content:
if isinstance(block, types.TextContent): if isinstance(block, types.TextContent):
@@ -60,20 +69,18 @@ async def connect_mcp_servers(
read, write = await stack.enter_async_context(stdio_client(params)) read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url: elif cfg.url:
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
if cfg.headers: # Always provide an explicit httpx client so MCP HTTP transport does not
http_client = await stack.enter_async_context( # inherit httpx's default 5s timeout and preempt the higher-level tool timeout.
httpx.AsyncClient( http_client = await stack.enter_async_context(
headers=cfg.headers, httpx.AsyncClient(
follow_redirects=True headers=cfg.headers or None,
) follow_redirects=True,
) timeout=None,
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
else:
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url)
) )
)
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
else: else:
logger.warning("MCP server '{}': no command or url configured, skipping", name) logger.warning("MCP server '{}': no command or url configured, skipping", name)
continue continue
@@ -83,7 +90,7 @@ async def connect_mcp_servers(
tools = await session.list_tools() tools = await session.list_tools()
for tool_def in tools.tools: for tool_def in tools.tools:
wrapper = MCPToolWrapper(session, name, tool_def) wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
registry.register(wrapper) registry.register(wrapper)
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
+9 -16
View File
@@ -36,30 +36,23 @@ class ToolRegistry:
return [tool.to_schema() for tool in self._tools.values()] return [tool.to_schema() for tool in self._tools.values()]
async def execute(self, name: str, params: dict[str, Any]) -> str: async def execute(self, name: str, params: dict[str, Any]) -> str:
""" """Execute a tool by name with given parameters."""
Execute a tool by name with given parameters. _HINT = "\n\n[Analyze the error above and try a different approach.]"
Args:
name: Tool name.
params: Tool parameters.
Returns:
Tool execution result as string.
Raises:
KeyError: If tool not found.
"""
tool = self._tools.get(name) tool = self._tools.get(name)
if not tool: if not tool:
return f"Error: Tool '{name}' not found" return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"
try: try:
errors = tool.validate_params(params) errors = tool.validate_params(params)
if errors: if errors:
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _HINT
return await tool.execute(**params) result = await tool.execute(**params)
if isinstance(result, str) and result.startswith("Error"):
return result + _HINT
return result
except Exception as e: except Exception as e:
return f"Error executing {name}: {str(e)}" return f"Error executing {name}: {str(e)}" + _HINT
@property @property
def tool_names(self) -> list[str]: def tool_names(self) -> list[str]:
+1 -6
View File
@@ -9,12 +9,7 @@ if TYPE_CHECKING:
class SpawnTool(Tool): class SpawnTool(Tool):
""" """Tool to spawn a subagent for background task execution."""
Tool to spawn a subagent for background task execution.
The subagent runs asynchronously and announces its result back
to the main agent when complete.
"""
def __init__(self, manager: "SubagentManager"): def __init__(self, manager: "SubagentManager"):
self._manager = manager self._manager = manager
+12 -3
View File
@@ -58,12 +58,21 @@ class WebSearchTool(Tool):
} }
def __init__(self, api_key: str | None = None, max_results: int = 5): def __init__(self, api_key: str | None = None, max_results: int = 5):
self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "") self._init_api_key = api_key
self.max_results = max_results self.max_results = max_results
@property
def api_key(self) -> str:
"""Resolve API key at call time so env/config changes are picked up."""
return self._init_api_key or os.environ.get("BRAVE_API_KEY", "")
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str:
if not self.api_key: if not self.api_key:
return "Error: BRAVE_API_KEY not configured" return (
"Error: Brave Search API key not configured. "
"Set it in ~/.nanobot/config.json under tools.web.search.apiKey "
"(or export BRAVE_API_KEY), then restart the gateway."
)
try: try:
n = min(max(count or self.max_results, 1), 10) n = min(max(count or self.max_results, 1), 10)
@@ -71,7 +80,7 @@ class WebSearchTool(Tool):
r = await client.get( r = await client.get(
"https://api.search.brave.com/res/v1/web/search", "https://api.search.brave.com/res/v1/web/search",
params={"q": query, "count": n}, params={"q": query, "count": n},
headers={"Accept": "application/json", "X-Subscription-Token": self.api_key}, headers={"Accept": "application/json", "X-Subscription-Token": api_key},
timeout=10.0 timeout=10.0
) )
r.raise_for_status() r.raise_for_status()
+2 -1
View File
@@ -16,11 +16,12 @@ class InboundMessage:
timestamp: datetime = field(default_factory=datetime.now) timestamp: datetime = field(default_factory=datetime.now)
media: list[str] = field(default_factory=list) # Media URLs media: list[str] = field(default_factory=list) # Media URLs
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
session_key_override: str | None = None # Optional override for thread-scoped sessions
@property @property
def session_key(self) -> str: def session_key(self) -> str:
"""Unique key for session identification.""" """Unique key for session identification."""
return f"{self.channel}:{self.chat_id}" return self.session_key_override or f"{self.channel}:{self.chat_id}"
@dataclass @dataclass
+5 -2
View File
@@ -89,7 +89,8 @@ class BaseChannel(ABC):
chat_id: str, chat_id: str,
content: str, content: str,
media: list[str] | None = None, media: list[str] | None = None,
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None,
session_key: str | None = None,
) -> None: ) -> None:
""" """
Handle an incoming message from the chat platform. Handle an incoming message from the chat platform.
@@ -102,6 +103,7 @@ class BaseChannel(ABC):
content: Message text content. content: Message text content.
media: Optional list of media URLs. media: Optional list of media URLs.
metadata: Optional channel-specific metadata. metadata: Optional channel-specific metadata.
session_key: Optional session key override (e.g. thread-scoped sessions).
""" """
if not self.is_allowed(sender_id): if not self.is_allowed(sender_id):
logger.warning( logger.warning(
@@ -117,7 +119,8 @@ class BaseChannel(ABC):
chat_id=str(chat_id), chat_id=str(chat_id),
content=content, content=content,
media=media or [], media=media or [],
metadata=metadata or {} metadata=metadata or {},
session_key_override=session_key,
) )
await self.bus.publish_inbound(msg) await self.bus.publish_inbound(msg)
+5 -2
View File
@@ -285,8 +285,11 @@ class DiscordChannel(BaseChannel):
while self._running: while self._running:
try: try:
await self._http.post(url, headers=headers) await self._http.post(url, headers=headers)
except Exception: except asyncio.CancelledError:
pass return
except Exception as e:
logger.debug("Discord typing indicator failed for {}: {}", channel_id, e)
return
await asyncio.sleep(8) await asyncio.sleep(8)
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
+11 -6
View File
@@ -108,11 +108,6 @@ class EmailChannel(BaseChannel):
logger.warning("Skip email send: consent_granted is false") logger.warning("Skip email send: consent_granted is false")
return return
force_send = bool((msg.metadata or {}).get("force_send"))
if not self.config.auto_reply_enabled and not force_send:
logger.info("Skip automatic email reply: auto_reply_enabled is false")
return
if not self.config.smtp_host: if not self.config.smtp_host:
logger.warning("Email channel SMTP host not configured") logger.warning("Email channel SMTP host not configured")
return return
@@ -122,6 +117,15 @@ class EmailChannel(BaseChannel):
logger.warning("Email channel missing recipient address") logger.warning("Email channel missing recipient address")
return return
# Determine if this is a reply (recipient has sent us an email before)
is_reply = to_addr in self._last_subject_by_chat
force_send = bool((msg.metadata or {}).get("force_send"))
# autoReplyEnabled only controls automatic replies, not proactive sends
if is_reply and not self.config.auto_reply_enabled and not force_send:
logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr)
return
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply") base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
subject = self._reply_subject(base_subject) subject = self._reply_subject(base_subject)
if msg.metadata and isinstance(msg.metadata.get("subject"), str): if msg.metadata and isinstance(msg.metadata.get("subject"), str):
@@ -304,7 +308,8 @@ class EmailChannel(BaseChannel):
self._processed_uids.add(uid) self._processed_uids.add(uid)
# mark_seen is the primary dedup; this set is a safety net # mark_seen is the primary dedup; this set is a safety net
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
self._processed_uids.clear() # Evict a random half to cap memory; mark_seen is the primary dedup
self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
if mark_seen: if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen") client.store(imap_id, "+FLAGS", "\\Seen")
+64 -27
View File
@@ -180,21 +180,25 @@ def _extract_element_content(element: dict) -> list[str]:
return parts return parts
def _extract_post_text(content_json: dict) -> str: def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
"""Extract plain text from Feishu post (rich text) message content. """Extract text and image keys from Feishu post (rich text) message content.
Supports two formats: Supports two formats:
1. Direct format: {"title": "...", "content": [...]} 1. Direct format: {"title": "...", "content": [...]}
2. Localized format: {"zh_cn": {"title": "...", "content": [...]}} 2. Localized format: {"zh_cn": {"title": "...", "content": [...]}}
Returns:
(text, image_keys) - extracted text and list of image keys
""" """
def extract_from_lang(lang_content: dict) -> str | None: def extract_from_lang(lang_content: dict) -> tuple[str | None, list[str]]:
if not isinstance(lang_content, dict): if not isinstance(lang_content, dict):
return None return None, []
title = lang_content.get("title", "") title = lang_content.get("title", "")
content_blocks = lang_content.get("content", []) content_blocks = lang_content.get("content", [])
if not isinstance(content_blocks, list): if not isinstance(content_blocks, list):
return None return None, []
text_parts = [] text_parts = []
image_keys = []
if title: if title:
text_parts.append(title) text_parts.append(title)
for block in content_blocks: for block in content_blocks:
@@ -209,22 +213,36 @@ def _extract_post_text(content_json: dict) -> str:
text_parts.append(element.get("text", "")) text_parts.append(element.get("text", ""))
elif tag == "at": elif tag == "at":
text_parts.append(f"@{element.get('user_name', 'user')}") text_parts.append(f"@{element.get('user_name', 'user')}")
return " ".join(text_parts).strip() if text_parts else None elif tag == "img":
img_key = element.get("image_key")
if img_key:
image_keys.append(img_key)
text = " ".join(text_parts).strip() if text_parts else None
return text, image_keys
# Try direct format first # Try direct format first
if "content" in content_json: if "content" in content_json:
result = extract_from_lang(content_json) text, images = extract_from_lang(content_json)
if result: if text or images:
return result return text or "", images
# Try localized format # Try localized format
for lang_key in ("zh_cn", "en_us", "ja_jp"): for lang_key in ("zh_cn", "en_us", "ja_jp"):
lang_content = content_json.get(lang_key) lang_content = content_json.get(lang_key)
result = extract_from_lang(lang_content) text, images = extract_from_lang(lang_content)
if result: if text or images:
return result return text or "", images
return "" return "", []
def _extract_post_text(content_json: dict) -> str:
"""Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text.
"""
text, _ = _extract_post_content(content_json)
return text
class FeishuChannel(BaseChannel): class FeishuChannel(BaseChannel):
@@ -503,18 +521,29 @@ class FeishuChannel(BaseChannel):
logger.error("Error downloading image {}: {}", image_key, e) logger.error("Error downloading image {}: {}", image_key, e)
return None, None return None, None
def _download_file_sync(self, file_key: str) -> tuple[bytes | None, str | None]: def _download_file_sync(
"""Download a file from Feishu by file_key.""" self, message_id: str, file_key: str, resource_type: str = "file"
) -> tuple[bytes | None, str | None]:
"""Download a file/audio/media from a Feishu message by message_id and file_key."""
try: try:
request = GetFileRequest.builder().file_key(file_key).build() request = (
response = self._client.im.v1.file.get(request) GetMessageResourceRequest.builder()
.message_id(message_id)
.file_key(file_key)
.type(resource_type)
.build()
)
response = self._client.im.v1.message_resource.get(request)
if response.success(): if response.success():
return response.file, response.file_name file_data = response.file
if hasattr(file_data, "read"):
file_data = file_data.read()
return file_data, response.file_name
else: else:
logger.error("Failed to download file: code={}, msg={}", response.code, response.msg) logger.error("Failed to download {}: code={}, msg={}", resource_type, response.code, response.msg)
return None, None return None, None
except Exception as e: except Exception:
logger.error("Error downloading file {}: {}", file_key, e) logger.exception("Error downloading {} {}", resource_type, file_key)
return None, None return None, None
async def _download_and_save_media( async def _download_and_save_media(
@@ -544,14 +573,14 @@ class FeishuChannel(BaseChannel):
if not filename: if not filename:
filename = f"{image_key[:16]}.jpg" filename = f"{image_key[:16]}.jpg"
elif msg_type in ("audio", "file"): elif msg_type in ("audio", "file", "media"):
file_key = content_json.get("file_key") file_key = content_json.get("file_key")
if file_key: if file_key and message_id:
data, filename = await loop.run_in_executor( data, filename = await loop.run_in_executor(
None, self._download_file_sync, file_key None, self._download_file_sync, message_id, file_key, msg_type
) )
if not filename: if not filename:
ext = ".opus" if msg_type == "audio" else "" ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "")
filename = f"{file_key[:16]}{ext}" filename = f"{file_key[:16]}{ext}"
if data and filename: if data and filename:
@@ -680,11 +709,19 @@ class FeishuChannel(BaseChannel):
content_parts.append(text) content_parts.append(text)
elif msg_type == "post": elif msg_type == "post":
text = _extract_post_text(content_json) text, image_keys = _extract_post_content(content_json)
if text: if text:
content_parts.append(text) content_parts.append(text)
# Download images embedded in post
for img_key in image_keys:
file_path, content_text = await self._download_and_save_media(
"image", {"image_key": img_key}, message_id
)
if file_path:
media_paths.append(file_path)
content_parts.append(content_text)
elif msg_type in ("image", "audio", "file"): elif msg_type in ("image", "audio", "file", "media"):
file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id)
if file_path: if file_path:
media_paths.append(file_path) media_paths.append(file_path)
+6
View File
@@ -193,6 +193,12 @@ class ChannelManager:
timeout=1.0 timeout=1.0
) )
if msg.metadata.get("_progress"):
if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints:
continue
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
continue
channel = self.channels.get(msg.channel) channel = self.channels.get(msg.channel)
if channel: if channel:
try: try:
+6 -8
View File
@@ -55,7 +55,6 @@ class QQChannel(BaseChannel):
self.config: QQConfig = config self.config: QQConfig = config
self._client: "botpy.Client | None" = None self._client: "botpy.Client | None" = None
self._processed_ids: deque = deque(maxlen=1000) self._processed_ids: deque = deque(maxlen=1000)
self._bot_task: asyncio.Task | None = None
async def start(self) -> None: async def start(self) -> None:
"""Start the QQ bot.""" """Start the QQ bot."""
@@ -71,8 +70,8 @@ class QQChannel(BaseChannel):
BotClass = _make_bot_class(self) BotClass = _make_bot_class(self)
self._client = BotClass() self._client = BotClass()
self._bot_task = asyncio.create_task(self._run_bot())
logger.info("QQ bot started (C2C private message)") logger.info("QQ bot started (C2C private message)")
await self._run_bot()
async def _run_bot(self) -> None: async def _run_bot(self) -> None:
"""Run the bot connection with auto-reconnect.""" """Run the bot connection with auto-reconnect."""
@@ -88,11 +87,10 @@ class QQChannel(BaseChannel):
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the QQ bot.""" """Stop the QQ bot."""
self._running = False self._running = False
if self._bot_task: if self._client:
self._bot_task.cancel()
try: try:
await self._bot_task await self._client.close()
except asyncio.CancelledError: except Exception:
pass pass
logger.info("QQ bot stopped") logger.info("QQ bot stopped")
@@ -130,5 +128,5 @@ class QQChannel(BaseChannel):
content=content, content=content,
metadata={"message_id": data.id}, metadata={"message_id": data.id},
) )
except Exception as e: except Exception:
logger.error("Error handling QQ message: {}", e) logger.exception("Error handling QQ message")
+44 -13
View File
@@ -179,18 +179,25 @@ class SlackChannel(BaseChannel):
except Exception as e: except Exception as e:
logger.debug("Slack reactions_add failed: {}", e) logger.debug("Slack reactions_add failed: {}", e)
await self._handle_message( # Thread-scoped session key for channel/group messages
sender_id=sender_id, session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
chat_id=chat_id,
content=text, try:
metadata={ await self._handle_message(
"slack": { sender_id=sender_id,
"event": event, chat_id=chat_id,
"thread_ts": thread_ts, content=text,
"channel_type": channel_type, metadata={
} "slack": {
}, "event": event,
) "thread_ts": thread_ts,
"channel_type": channel_type,
},
},
session_key=session_key,
)
except Exception:
logger.exception("Error handling Slack message from {}", sender_id)
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
if channel_type == "im": if channel_type == "im":
@@ -222,6 +229,11 @@ class SlackChannel(BaseChannel):
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\|.*\|$)*") _TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*")
_CODE_FENCE_RE = re.compile(r"```[\s\S]*?```")
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
_LEFTOVER_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
_LEFTOVER_HEADER_RE = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
_BARE_URL_RE = re.compile(r"(?<![|<])(https?://\S+)")
@classmethod @classmethod
def _to_mrkdwn(cls, text: str) -> str: def _to_mrkdwn(cls, text: str) -> str:
@@ -229,7 +241,26 @@ class SlackChannel(BaseChannel):
if not text: if not text:
return "" return ""
text = cls._TABLE_RE.sub(cls._convert_table, text) text = cls._TABLE_RE.sub(cls._convert_table, text)
return slackify_markdown(text) return cls._fixup_mrkdwn(slackify_markdown(text))
@classmethod
def _fixup_mrkdwn(cls, text: str) -> str:
"""Fix markdown artifacts that slackify_markdown misses."""
code_blocks: list[str] = []
def _save_code(m: re.Match) -> str:
code_blocks.append(m.group(0))
return f"\x00CB{len(code_blocks) - 1}\x00"
text = cls._CODE_FENCE_RE.sub(_save_code, text)
text = cls._INLINE_CODE_RE.sub(_save_code, text)
text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text)
text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text)
text = cls._BARE_URL_RE.sub(lambda m: m.group(0).replace("&amp;", "&"), text)
for i, block in enumerate(code_blocks):
text = text.replace(f"\x00CB{i}\x00", block)
return text
@staticmethod @staticmethod
def _convert_table(match: re.Match) -> str: def _convert_table(match: re.Match) -> str:
+110 -79
View File
@@ -199,74 +199,26 @@ def onboard():
def _create_workspace_templates(workspace: Path): def _create_workspace_templates(workspace: Path):
"""Create default workspace template files.""" """Create default workspace template files from bundled templates."""
templates = { from importlib.resources import files as pkg_files
"AGENTS.md": """# Agent Instructions
You are a helpful AI assistant. Be concise, accurate, and friendly. templates_dir = pkg_files("nanobot") / "templates"
## Guidelines for item in templates_dir.iterdir():
if not item.name.endswith(".md"):
continue
dest = workspace / item.name
if not dest.exists():
dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8")
console.print(f" [dim]Created {item.name}[/dim]")
- Always explain what you're doing before taking actions
- Ask for clarification when the request is ambiguous
- Use tools to help accomplish tasks
- Remember important information in memory/MEMORY.md; past events are logged in memory/HISTORY.md
""",
"SOUL.md": """# Soul
I am nanobot, a lightweight AI assistant.
## Personality
- Helpful and friendly
- Concise and to the point
- Curious and eager to learn
## Values
- Accuracy over speed
- User privacy and safety
- Transparency in actions
""",
"USER.md": """# User
Information about the user goes here.
## Preferences
- Communication style: (casual/formal)
- Timezone: (your timezone)
- Language: (your preferred language)
""",
}
for filename, content in templates.items():
file_path = workspace / filename
if not file_path.exists():
file_path.write_text(content, encoding="utf-8")
console.print(f" [dim]Created {filename}[/dim]")
# Create memory directory and MEMORY.md
memory_dir = workspace / "memory" memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True) memory_dir.mkdir(exist_ok=True)
memory_template = templates_dir / "memory" / "MEMORY.md"
memory_file = memory_dir / "MEMORY.md" memory_file = memory_dir / "MEMORY.md"
if not memory_file.exists(): if not memory_file.exists():
memory_file.write_text("""# Long-term Memory memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8")
This file stores important information that should persist across sessions.
## User Information
(Important facts about the user)
## Preferences
(User preferences learned over time)
## Important Notes
(Things to remember)
""", encoding="utf-8")
console.print(" [dim]Created memory/MEMORY.md[/dim]") console.print(" [dim]Created memory/MEMORY.md[/dim]")
history_file = memory_dir / "HISTORY.md" history_file = memory_dir / "HISTORY.md"
@@ -274,9 +226,7 @@ This file stores important information that should persist across sessions.
history_file.write_text("", encoding="utf-8") history_file.write_text("", encoding="utf-8")
console.print(" [dim]Created memory/HISTORY.md[/dim]") console.print(" [dim]Created memory/HISTORY.md[/dim]")
# Create skills directory for custom user skills (workspace / "skills").mkdir(exist_ok=True)
skills_dir = workspace / "skills"
skills_dir.mkdir(exist_ok=True)
def _make_provider(config: Config): def _make_provider(config: Config):
@@ -368,6 +318,7 @@ def gateway(
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, mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
) )
# Set cron callback (needs agent) # Set cron callback (needs agent)
@@ -389,21 +340,60 @@ def gateway(
return response return response
cron.on_job = on_cron_job cron.on_job = on_cron_job
# Create heartbeat service
async def on_heartbeat(prompt: str) -> str:
"""Execute heartbeat through the agent."""
return await agent.process_direct(prompt, session_key="heartbeat")
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
enabled=True
)
# Create channel manager # Create channel manager
channels = ChannelManager(config, bus) channels = ChannelManager(config, bus)
def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
enabled = set(channels.enabled_channels)
# Prefer the most recently updated non-internal session on an enabled channel.
for item in session_manager.list_sessions():
key = item.get("key") or ""
if ":" not in key:
continue
channel, chat_id = key.split(":", 1)
if channel in {"cli", "system"}:
continue
if channel in enabled and chat_id:
return channel, chat_id
# Fallback keeps prior behavior but remains explicit.
return "cli", "direct"
# Create heartbeat service
async def on_heartbeat_execute(tasks: str) -> str:
"""Phase 2: execute heartbeat tasks through the full agent loop."""
channel, chat_id = _pick_heartbeat_target()
async def _silent(*_args, **_kwargs):
pass
return await agent.process_direct(
tasks,
session_key="heartbeat",
channel=channel,
chat_id=chat_id,
on_progress=_silent,
)
async def on_heartbeat_notify(response: str) -> None:
"""Deliver a heartbeat response to the user's channel."""
from nanobot.bus.events import OutboundMessage
channel, chat_id = _pick_heartbeat_target()
if channel == "cli":
return # No external channel available to deliver to
await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response))
hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService(
workspace=config.workspace_path,
provider=provider,
model=agent.model,
on_execute=on_heartbeat_execute,
on_notify=on_heartbeat_notify,
interval_s=hb_cfg.interval_s,
enabled=hb_cfg.enabled,
)
if channels.enabled_channels: if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else: else:
@@ -413,7 +403,7 @@ def gateway(
if cron_status["jobs"] > 0: if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print(f"[green]✓[/green] Heartbeat: every 30m") console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
async def run(): async def run():
try: try:
@@ -484,6 +474,7 @@ def agent(
cron_service=cron, 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, mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
) )
# 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
@@ -494,7 +485,12 @@ 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: async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
ch = agent_loop.channels_config
if ch and tool_hint and not ch.send_tool_hints:
return
if ch and not tool_hint and not ch.send_progress:
return
console.print(f" [dim]↳ {content}[/dim]") console.print(f" [dim]↳ {content}[/dim]")
if message: if message:
@@ -535,7 +531,14 @@ def agent(
try: try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
if msg.metadata.get("_progress"): if msg.metadata.get("_progress"):
console.print(f" [dim]↳ {msg.content}[/dim]") is_tool_hint = msg.metadata.get("_tool_hint", False)
ch = agent_loop.channels_config
if ch and is_tool_hint and not ch.send_tool_hints:
pass
elif ch and not is_tool_hint and not ch.send_progress:
pass
else:
console.print(f" [dim]↳ {msg.content}[/dim]")
elif not turn_done.is_set(): elif not turn_done.is_set():
if msg.content: if msg.content:
turn_response.append(msg.content) turn_response.append(msg.content)
@@ -668,6 +671,33 @@ def channels_status():
slack_config slack_config
) )
# DingTalk
dt = config.channels.dingtalk
dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]"
table.add_row(
"DingTalk",
"" if dt.enabled else "",
dt_config
)
# QQ
qq = config.channels.qq
qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]"
table.add_row(
"QQ",
"" if qq.enabled else "",
qq_config
)
# Email
em = config.channels.email
em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]"
table.add_row(
"Email",
"" if em.enabled else "",
em_config
)
console.print(table) console.print(table)
@@ -934,6 +964,7 @@ def cron_run(
exec_config=config.tools.exec, exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
) )
store_path = get_data_dir() / "cron" / "jobs.json" store_path = get_data_dir() / "cron" / "jobs.json"
+14 -3
View File
@@ -168,6 +168,8 @@ class QQConfig(Base):
class ChannelsConfig(Base): class ChannelsConfig(Base):
"""Configuration for chat channels.""" """Configuration for chat channels."""
send_progress: bool = True # stream agent's text progress to the channel
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
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)
@@ -185,9 +187,9 @@ class AgentDefaults(Base):
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
temperature: float = 0.7 temperature: float = 0.1
max_tool_iterations: int = 20 max_tool_iterations: int = 40
memory_window: int = 50 memory_window: int = 100
class AgentsConfig(Base): class AgentsConfig(Base):
@@ -226,11 +228,19 @@ class ProvidersConfig(Base):
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
class HeartbeatConfig(Base):
"""Heartbeat service configuration."""
enabled: bool = True
interval_s: int = 30 * 60 # 30 minutes
class GatewayConfig(Base): 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
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
class WebSearchConfig(Base): class WebSearchConfig(Base):
@@ -260,6 +270,7 @@ class MCPServerConfig(Base):
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
url: str = "" # HTTP: streamable HTTP endpoint URL url: str = "" # HTTP: streamable HTTP endpoint URL
headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers
tool_timeout: int = 30 # Seconds before a tool call is cancelled
class ToolsConfig(Base): class ToolsConfig(Base):
+93 -50
View File
@@ -1,57 +1,70 @@
"""Heartbeat service - periodic agent wake-up to check for tasks.""" """Heartbeat service - periodic agent wake-up to check for tasks."""
from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Coroutine from typing import TYPE_CHECKING, Any, Callable, Coroutine
from loguru import logger from loguru import logger
# Default interval: 30 minutes if TYPE_CHECKING:
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 from nanobot.providers.base import LLMProvider
# The prompt sent to agent during heartbeat _HEARTBEAT_TOOL = [
HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists). {
Follow any instructions or tasks listed there. "type": "function",
If nothing needs attention, reply with just: HEARTBEAT_OK""" "function": {
"name": "heartbeat",
# Token that indicates "nothing to do" "description": "Report heartbeat decision after reviewing tasks.",
HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" "parameters": {
"type": "object",
"properties": {
def _is_heartbeat_empty(content: str | None) -> bool: "action": {
"""Check if HEARTBEAT.md has no actionable content.""" "type": "string",
if not content: "enum": ["skip", "run"],
return True "description": "skip = nothing to do, run = has active tasks",
},
# Lines to skip: empty, headers, HTML comments, empty checkboxes "tasks": {
skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"} "type": "string",
"description": "Natural-language summary of active tasks (required for run)",
for line in content.split("\n"): },
line = line.strip() },
if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns: "required": ["action"],
continue },
return False # Found actionable content },
}
return True ]
class HeartbeatService: class HeartbeatService:
""" """
Periodic heartbeat service that wakes the agent to check for tasks. Periodic heartbeat service that wakes the agent to check for tasks.
The agent reads HEARTBEAT.md from the workspace and executes any Phase 1 (decision): reads HEARTBEAT.md and asks the LLM — via a virtual
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK. tool call — whether there are active tasks. This avoids free-text parsing
and the unreliable HEARTBEAT_OK token.
Phase 2 (execution): only triggered when Phase 1 returns ``run``. The
``on_execute`` callback runs the task through the full agent loop and
returns the result to deliver.
""" """
def __init__( def __init__(
self, self,
workspace: Path, workspace: Path,
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None, provider: LLMProvider,
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S, model: str,
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
interval_s: int = 30 * 60,
enabled: bool = True, enabled: bool = True,
): ):
self.workspace = workspace self.workspace = workspace
self.on_heartbeat = on_heartbeat self.provider = provider
self.model = model
self.on_execute = on_execute
self.on_notify = on_notify
self.interval_s = interval_s self.interval_s = interval_s
self.enabled = enabled self.enabled = enabled
self._running = False self._running = False
@@ -62,7 +75,6 @@ class HeartbeatService:
return self.workspace / "HEARTBEAT.md" return self.workspace / "HEARTBEAT.md"
def _read_heartbeat_file(self) -> str | None: def _read_heartbeat_file(self) -> str | None:
"""Read HEARTBEAT.md content."""
if self.heartbeat_file.exists(): if self.heartbeat_file.exists():
try: try:
return self.heartbeat_file.read_text(encoding="utf-8") return self.heartbeat_file.read_text(encoding="utf-8")
@@ -70,11 +82,37 @@ class HeartbeatService:
return None return None
return None return None
async def _decide(self, content: str) -> tuple[str, str]:
"""Phase 1: ask LLM to decide skip/run via virtual tool call.
Returns (action, tasks) where action is 'skip' or 'run'.
"""
response = await self.provider.chat(
messages=[
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
{"role": "user", "content": (
"Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
f"{content}"
)},
],
tools=_HEARTBEAT_TOOL,
model=self.model,
)
if not response.has_tool_calls:
return "skip", ""
args = response.tool_calls[0].arguments
return args.get("action", "skip"), args.get("tasks", "")
async def start(self) -> None: async def start(self) -> None:
"""Start the heartbeat service.""" """Start the heartbeat service."""
if not self.enabled: if not self.enabled:
logger.info("Heartbeat disabled") logger.info("Heartbeat disabled")
return return
if self._running:
logger.warning("Heartbeat already running")
return
self._running = True self._running = True
self._task = asyncio.create_task(self._run_loop()) self._task = asyncio.create_task(self._run_loop())
@@ -102,29 +140,34 @@ class HeartbeatService:
async def _tick(self) -> None: async def _tick(self) -> None:
"""Execute a single heartbeat tick.""" """Execute a single heartbeat tick."""
content = self._read_heartbeat_file() content = self._read_heartbeat_file()
if not content:
# Skip if HEARTBEAT.md is empty or doesn't exist logger.debug("Heartbeat: HEARTBEAT.md missing or empty")
if _is_heartbeat_empty(content):
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
return return
logger.info("Heartbeat: checking for tasks...") logger.info("Heartbeat: checking for tasks...")
if self.on_heartbeat: try:
try: action, tasks = await self._decide(content)
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
# Check if agent said "nothing to do" if action != "run":
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): logger.info("Heartbeat: OK (nothing to report)")
logger.info("Heartbeat: OK (no action needed)") return
else:
logger.info("Heartbeat: completed task")
except Exception as e: logger.info("Heartbeat: tasks found, executing...")
logger.error("Heartbeat execution failed: {}", e) if self.on_execute:
response = await self.on_execute(tasks)
if response and self.on_notify:
logger.info("Heartbeat: completed, delivering response")
await self.on_notify(response)
except Exception:
logger.exception("Heartbeat execution failed")
async def trigger_now(self) -> str | None: async def trigger_now(self) -> str | None:
"""Manually trigger a heartbeat.""" """Manually trigger a heartbeat."""
if self.on_heartbeat: content = self._read_heartbeat_file()
return await self.on_heartbeat(HEARTBEAT_PROMPT) if not content:
return None return None
action, tasks = await self._decide(content)
if action != "run" or not self.on_execute:
return None
return await self.on_execute(tasks)
+40
View File
@@ -40,6 +40,46 @@ class LLMProvider(ABC):
self.api_key = api_key self.api_key = api_key
self.api_base = api_base self.api_base = api_base
@staticmethod
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Replace empty text content that causes provider 400 errors.
Empty content can appear when MCP tools return nothing. Most providers
reject empty-string content or empty text blocks in list content.
"""
result: list[dict[str, Any]] = []
for msg in messages:
content = msg.get("content")
if isinstance(content, str) and not content:
clean = dict(msg)
clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"
result.append(clean)
continue
if isinstance(content, list):
filtered = [
item for item in content
if not (
isinstance(item, dict)
and item.get("type") in ("text", "input_text", "output_text")
and not item.get("text")
)
]
if len(filtered) != len(content):
clean = dict(msg)
if filtered:
clean["content"] = filtered
elif msg.get("role") == "assistant" and msg.get("tool_calls"):
clean["content"] = None
else:
clean["content"] = "(empty)"
result.append(clean)
continue
result.append(msg)
return result
@abstractmethod @abstractmethod
async def chat( async def chat(
self, self,
+8 -3
View File
@@ -19,8 +19,12 @@ class CustomProvider(LLMProvider):
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, 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: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse:
kwargs: dict[str, Any] = {"model": model or self.default_model, "messages": messages, kwargs: dict[str, Any] = {
"max_tokens": max(1, max_tokens), "temperature": temperature} "model": model or self.default_model,
"messages": self._sanitize_empty_content(messages),
"max_tokens": max(1, max_tokens),
"temperature": temperature,
}
if tools: if tools:
kwargs.update(tools=tools, tool_choice="auto") kwargs.update(tools=tools, tool_choice="auto")
try: try:
@@ -40,8 +44,9 @@ class CustomProvider(LLMProvider):
return LLMResponse( return LLMResponse(
content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop", 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 {}, 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), reasoning_content=getattr(msg, "reasoning_content", None) or None,
) )
def get_default_model(self) -> str: def get_default_model(self) -> str:
return self.default_model return self.default_model
+2 -2
View File
@@ -196,7 +196,7 @@ class LiteLLMProvider(LLMProvider):
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"model": model, "model": model,
"messages": self._sanitize_messages(messages), "messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
"max_tokens": max_tokens, "max_tokens": max_tokens,
"temperature": temperature, "temperature": temperature,
} }
@@ -257,7 +257,7 @@ class LiteLLMProvider(LLMProvider):
"total_tokens": response.usage.total_tokens, "total_tokens": response.usage.total_tokens,
} }
reasoning_content = getattr(message, "reasoning_content", None) reasoning_content = getattr(message, "reasoning_content", None) or None
return LLMResponse( return LLMResponse(
content=message.content, content=message.content,
+1 -1
View File
@@ -147,7 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("volcengine", "volces", "ark"), keywords=("volcengine", "volces", "ark"),
env_key="OPENAI_API_KEY", env_key="OPENAI_API_KEY",
display_name="VolcEngine", display_name="VolcEngine",
litellm_prefix="openai", litellm_prefix="volcengine",
skip_prefixes=(), skip_prefixes=(),
env_extras=(), env_extras=(),
is_gateway=True, is_gateway=True,
+17 -5
View File
@@ -1,6 +1,7 @@
"""Session management for conversation history.""" """Session management for conversation history."""
import json import json
import shutil
from pathlib import Path from pathlib import Path
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -42,9 +43,18 @@ class Session:
self.updated_at = datetime.now() self.updated_at = datetime.now()
def get_history(self, max_messages: int = 500) -> 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.""" """Return unconsolidated messages for LLM input, aligned to a user turn."""
unconsolidated = self.messages[self.last_consolidated:]
sliced = unconsolidated[-max_messages:]
# Drop leading non-user messages to avoid orphaned tool_result blocks
for i, m in enumerate(sliced):
if m.get("role") == "user":
sliced = sliced[i:]
break
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for m in self.messages[-max_messages:]: for m in sliced:
entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")} entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")}
for k in ("tool_calls", "tool_call_id", "name"): for k in ("tool_calls", "tool_call_id", "name"):
if k in m: if k in m:
@@ -108,9 +118,11 @@ class SessionManager:
if not path.exists(): if not path.exists():
legacy_path = self._get_legacy_session_path(key) legacy_path = self._get_legacy_session_path(key)
if legacy_path.exists(): if legacy_path.exists():
import shutil try:
shutil.move(str(legacy_path), str(path)) shutil.move(str(legacy_path), str(path))
logger.info("Migrated session {} from legacy path", key) logger.info("Migrated session {} from legacy path", key)
except Exception:
logger.exception("Failed to migrate session {}", key)
if not path.exists(): if not path.exists():
return None return None
+31
View File
@@ -0,0 +1,31 @@
# Agent Instructions
You are a helpful AI assistant. Be concise, accurate, and friendly.
## Guidelines
- Before calling tools, briefly state your intent — but NEVER predict results before receiving them
- Use precise tense: "I will run X" before the call, "X returned Y" after
- NEVER claim success before a tool result confirms it
- Ask for clarification when the request is ambiguous
- Remember important information in `memory/MEMORY.md`; past events are logged in `memory/HISTORY.md`
## Scheduled Reminders
When user asks for a reminder at a specific time, use `exec` to run:
```
nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL"
```
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
## Heartbeat Tasks
`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks:
- **Add**: `edit_file` to append new tasks
- **Remove**: `edit_file` to delete completed tasks
- **Rewrite**: `write_file` to replace all tasks
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
+15
View File
@@ -0,0 +1,15 @@
# Tool Usage Notes
Tool signatures are provided automatically via function calling.
This file documents non-obvious constraints and usage patterns.
## exec — Safety Limits
- Commands have a configurable timeout (default 60s)
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
- Output is truncated at 10,000 characters
- `restrictToWorkspace` config can limit file access to the workspace
## cron — Scheduled Reminders
- Please refer to cron skill for usage.
View File
+3 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nanobot-ai" name = "nanobot-ai"
version = "0.1.4.post1" version = "0.1.4.post2"
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"}
@@ -64,10 +64,11 @@ packages = ["nanobot"]
[tool.hatch.build.targets.wheel.sources] [tool.hatch.build.targets.wheel.sources]
"nanobot" = "nanobot" "nanobot" = "nanobot"
# Include non-Python files in skills # Include non-Python files in skills and templates
[tool.hatch.build] [tool.hatch.build]
include = [ include = [
"nanobot/**/*.py", "nanobot/**/*.py",
"nanobot/templates/**/*.md",
"nanobot/skills/**/*.md", "nanobot/skills/**/*.md",
"nanobot/skills/**/*.sh", "nanobot/skills/**/*.sh",
] ]
+351
View File
@@ -1,5 +1,8 @@
"""Test session management with cache-friendly message handling.""" """Test session management with cache-friendly message handling."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from pathlib import Path from pathlib import Path
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@@ -475,3 +478,351 @@ class TestEmptyAndBoundarySessions:
expected_count = 60 - KEEP_COUNT - 10 expected_count = 60 - KEEP_COUNT - 10
assert len(old_messages) == expected_count assert len(old_messages) == expected_count
assert_messages_content(old_messages, 10, 34) assert_messages_content(old_messages, 10, 34)
class TestConsolidationDeduplicationGuard:
"""Test that consolidation tasks are deduplicated and serialized."""
@pytest.mark.asyncio
async def test_consolidation_guard_prevents_duplicate_tasks(self, tmp_path: Path) -> None:
"""Concurrent messages above memory_window spawn only one consolidation task."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
consolidation_calls = 0
async def _fake_consolidate(_session, archive_all: bool = False) -> None:
nonlocal consolidation_calls
consolidation_calls += 1
await asyncio.sleep(0.05)
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
await loop._process_message(msg)
await asyncio.sleep(0.1)
assert consolidation_calls == 1, (
f"Expected exactly 1 consolidation, got {consolidation_calls}"
)
@pytest.mark.asyncio
async def test_new_command_guard_prevents_concurrent_consolidation(
self, tmp_path: Path
) -> None:
"""/new command does not run consolidation concurrently with in-flight consolidation."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
consolidation_calls = 0
active = 0
max_active = 0
async def _fake_consolidate(_session, archive_all: bool = False) -> None:
nonlocal consolidation_calls, active, max_active
consolidation_calls += 1
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.05)
active -= 1
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
await loop._process_message(new_msg)
await asyncio.sleep(0.1)
assert consolidation_calls == 2, (
f"Expected normal + /new consolidations, got {consolidation_calls}"
)
assert max_active == 1, (
f"Expected serialized consolidation, observed concurrency={max_active}"
)
@pytest.mark.asyncio
async def test_consolidation_tasks_are_referenced(self, tmp_path: Path) -> None:
"""create_task results are tracked in _consolidation_tasks while in flight."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
started = asyncio.Event()
async def _slow_consolidate(_session, archive_all: bool = False) -> None:
started.set()
await asyncio.sleep(0.1)
loop._consolidate_memory = _slow_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
await started.wait()
assert len(loop._consolidation_tasks) == 1, "Task must be referenced while in-flight"
await asyncio.sleep(0.15)
assert len(loop._consolidation_tasks) == 0, (
"Task reference must be removed after completion"
)
@pytest.mark.asyncio
async def test_new_waits_for_inflight_consolidation_and_preserves_messages(
self, tmp_path: Path
) -> None:
"""/new waits for in-flight consolidation and archives before clear."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
started = asyncio.Event()
release = asyncio.Event()
archived_count = 0
async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
nonlocal archived_count
if archive_all:
archived_count = len(sess.messages)
return True
started.set()
await release.wait()
return True
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
await started.wait()
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
pending_new = asyncio.create_task(loop._process_message(new_msg))
await asyncio.sleep(0.02)
assert not pending_new.done(), "/new should wait while consolidation is in-flight"
release.set()
response = await pending_new
assert response is not None
assert "new session started" in response.content.lower()
assert archived_count > 0, "Expected /new archival to process a non-empty snapshot"
session_after = loop.sessions.get_or_create("cli:test")
assert session_after.messages == [], "Session should be cleared after successful archival"
@pytest.mark.asyncio
async def test_new_does_not_clear_session_when_archive_fails(self, tmp_path: Path) -> None:
"""/new must keep session data if archive step reports failure."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(5):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
before_count = len(session.messages)
async def _failing_consolidate(sess, archive_all: bool = False) -> bool:
if archive_all:
return False
return True
loop._consolidate_memory = _failing_consolidate # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg)
assert response is not None
assert "failed" in response.content.lower()
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == before_count, (
"Session must remain intact when /new archival fails"
)
@pytest.mark.asyncio
async def test_new_archives_only_unconsolidated_messages_after_inflight_task(
self, tmp_path: Path
) -> None:
"""/new should archive only messages not yet consolidated by prior task."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
started = asyncio.Event()
release = asyncio.Event()
archived_count = -1
async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
nonlocal archived_count
if archive_all:
archived_count = len(sess.messages)
return True
started.set()
await release.wait()
sess.last_consolidated = len(sess.messages) - 3
return True
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
await started.wait()
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
pending_new = asyncio.create_task(loop._process_message(new_msg))
await asyncio.sleep(0.02)
assert not pending_new.done()
release.set()
response = await pending_new
assert response is not None
assert "new session started" in response.content.lower()
assert archived_count == 3, (
f"Expected only unconsolidated tail to archive, got {archived_count}"
)
@pytest.mark.asyncio
async def test_new_cleans_up_consolidation_lock_for_invalidated_session(
self, tmp_path: Path
) -> None:
"""/new should remove lock entry for fully invalidated session key."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(3):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
# Ensure lock exists before /new.
_ = loop._get_consolidation_lock(session.key)
assert session.key in loop._consolidation_locks
async def _ok_consolidate(sess, archive_all: bool = False) -> bool:
return True
loop._consolidate_memory = _ok_consolidate # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg)
assert response is not None
assert "new session started" in response.content.lower()
assert session.key not in loop._consolidation_locks
+63
View File
@@ -0,0 +1,63 @@
"""Tests for cache-friendly prompt construction."""
from __future__ import annotations
from datetime import datetime as real_datetime
from pathlib import Path
import datetime as datetime_module
from nanobot.agent.context import ContextBuilder
class _FakeDatetime(real_datetime):
current = real_datetime(2026, 2, 24, 13, 59)
@classmethod
def now(cls, tz=None): # type: ignore[override]
return cls.current
def _make_workspace(tmp_path: Path) -> Path:
workspace = tmp_path / "workspace"
workspace.mkdir(parents=True)
return workspace
def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> None:
"""System prompt should not change just because wall clock minute changes."""
monkeypatch.setattr(datetime_module, "datetime", _FakeDatetime)
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
_FakeDatetime.current = real_datetime(2026, 2, 24, 13, 59)
prompt1 = builder.build_system_prompt()
_FakeDatetime.current = real_datetime(2026, 2, 24, 14, 0)
prompt2 = builder.build_system_prompt()
assert prompt1 == prompt2
def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None:
"""Dynamic runtime details should be added at the tail user message, not system."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
messages = builder.build_messages(
history=[],
current_message="Return exactly: OK",
channel="cli",
chat_id="direct",
)
assert messages[0]["role"] == "system"
assert "## Current Session" not in messages[0]["content"]
assert messages[-1]["role"] == "user"
user_content = messages[-1]["content"]
assert isinstance(user_content, str)
assert "Return exactly: OK" in user_content
assert "Current Time:" in user_content
assert "Channel: cli" in user_content
assert "Chat ID: direct" in user_content
+58 -1
View File
@@ -169,7 +169,8 @@ async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None: async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
"""When auto_reply_enabled=False, replies should be skipped but proactive sends allowed."""
class FakeSMTP: class FakeSMTP:
def __init__(self, _host: str, _port: int, timeout: int = 30) -> None: def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
self.sent_messages: list[EmailMessage] = [] self.sent_messages: list[EmailMessage] = []
@@ -201,6 +202,11 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
cfg = _make_config() cfg = _make_config()
cfg.auto_reply_enabled = False cfg.auto_reply_enabled = False
channel = EmailChannel(cfg, MessageBus()) channel = EmailChannel(cfg, MessageBus())
# Mark alice as someone who sent us an email (making this a "reply")
channel._last_subject_by_chat["alice@example.com"] = "Previous email"
# Reply should be skipped (auto_reply_enabled=False)
await channel.send( await channel.send(
OutboundMessage( OutboundMessage(
channel="email", channel="email",
@@ -210,6 +216,7 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
) )
assert fake_instances == [] assert fake_instances == []
# Reply with force_send=True should be sent
await channel.send( await channel.send(
OutboundMessage( OutboundMessage(
channel="email", channel="email",
@@ -222,6 +229,56 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
assert len(fake_instances[0].sent_messages) == 1 assert len(fake_instances[0].sent_messages) == 1
@pytest.mark.asyncio
async def test_send_proactive_email_when_auto_reply_disabled(monkeypatch) -> None:
"""Proactive emails (not replies) should be sent even when auto_reply_enabled=False."""
class FakeSMTP:
def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
self.sent_messages: list[EmailMessage] = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def starttls(self, context=None):
return None
def login(self, _user: str, _pw: str):
return None
def send_message(self, msg: EmailMessage):
self.sent_messages.append(msg)
fake_instances: list[FakeSMTP] = []
def _smtp_factory(host: str, port: int, timeout: int = 30):
instance = FakeSMTP(host, port, timeout=timeout)
fake_instances.append(instance)
return instance
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
cfg = _make_config()
cfg.auto_reply_enabled = False
channel = EmailChannel(cfg, MessageBus())
# bob@example.com has never sent us an email (proactive send)
# This should be sent even with auto_reply_enabled=False
await channel.send(
OutboundMessage(
channel="email",
chat_id="bob@example.com",
content="Hello, this is a proactive email.",
)
)
assert len(fake_instances) == 1
assert len(fake_instances[0].sent_messages) == 1
sent = fake_instances[0].sent_messages[0]
assert sent["To"] == "bob@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_skips_when_consent_not_granted(monkeypatch) -> None: async def test_send_skips_when_consent_not_granted(monkeypatch) -> None:
class FakeSMTP: class FakeSMTP:
+44
View File
@@ -0,0 +1,44 @@
import asyncio
import pytest
from nanobot.heartbeat.service import (
HEARTBEAT_OK_TOKEN,
HeartbeatService,
)
def test_heartbeat_ok_detection() -> None:
def is_ok(response: str) -> bool:
return HEARTBEAT_OK_TOKEN in response.upper()
assert is_ok("HEARTBEAT_OK")
assert is_ok("`HEARTBEAT_OK`")
assert is_ok("**HEARTBEAT_OK**")
assert is_ok("heartbeat_ok")
assert is_ok("HEARTBEAT_OK.")
assert not is_ok("HEARTBEAT_NOT_OK")
assert not is_ok("all good")
@pytest.mark.asyncio
async def test_start_is_idempotent(tmp_path) -> None:
async def _on_heartbeat(_: str) -> str:
return "HEARTBEAT_OK"
service = HeartbeatService(
workspace=tmp_path,
on_heartbeat=_on_heartbeat,
interval_s=9999,
enabled=True,
)
await service.start()
first_task = service._task
await service.start()
assert service._task is first_task
service.stop()
await asyncio.sleep(0)
+147
View File
@@ -0,0 +1,147 @@
"""Test MemoryStore.consolidate() handles non-string tool call arguments.
Regression test for https://github.com/HKUDS/nanobot/issues/1042
When memory consolidation receives dict values instead of strings from the LLM
tool call response, it should serialize them to JSON instead of raising TypeError.
"""
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.memory import MemoryStore
from nanobot.providers.base import LLMResponse, ToolCallRequest
def _make_session(message_count: int = 30, memory_window: int = 50):
"""Create a mock session with messages."""
session = MagicMock()
session.messages = [
{"role": "user", "content": f"msg{i}", "timestamp": "2026-01-01 00:00"}
for i in range(message_count)
]
session.last_consolidated = 0
return session
def _make_tool_response(history_entry, memory_update):
"""Create an LLMResponse with a save_memory tool call."""
return LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="save_memory",
arguments={
"history_entry": history_entry,
"memory_update": memory_update,
},
)
],
)
class TestMemoryConsolidationTypeHandling:
"""Test that consolidation handles various argument types correctly."""
@pytest.mark.asyncio
async def test_string_arguments_work(self, tmp_path: Path) -> None:
"""Normal case: LLM returns string arguments."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat = AsyncMock(
return_value=_make_tool_response(
history_entry="[2026-01-01] User discussed testing.",
memory_update="# Memory\nUser likes testing.",
)
)
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
assert result is True
assert store.history_file.exists()
assert "[2026-01-01] User discussed testing." in store.history_file.read_text()
assert "User likes testing." in store.memory_file.read_text()
@pytest.mark.asyncio
async def test_dict_arguments_serialized_to_json(self, tmp_path: Path) -> None:
"""Issue #1042: LLM returns dict instead of string — must not raise TypeError."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat = AsyncMock(
return_value=_make_tool_response(
history_entry={"timestamp": "2026-01-01", "summary": "User discussed testing."},
memory_update={"facts": ["User likes testing"], "topics": ["testing"]},
)
)
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
assert result is True
assert store.history_file.exists()
history_content = store.history_file.read_text()
parsed = json.loads(history_content.strip())
assert parsed["summary"] == "User discussed testing."
memory_content = store.memory_file.read_text()
parsed_mem = json.loads(memory_content)
assert "User likes testing" in parsed_mem["facts"]
@pytest.mark.asyncio
async def test_string_arguments_as_raw_json(self, tmp_path: Path) -> None:
"""Some providers return arguments as a JSON string instead of parsed dict."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
# Simulate arguments being a JSON string (not yet parsed)
response = LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="save_memory",
arguments=json.dumps({
"history_entry": "[2026-01-01] User discussed testing.",
"memory_update": "# Memory\nUser likes testing.",
}),
)
],
)
provider.chat = AsyncMock(return_value=response)
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
assert result is True
assert "User discussed testing." in store.history_file.read_text()
@pytest.mark.asyncio
async def test_no_tool_call_returns_false(self, tmp_path: Path) -> None:
"""When LLM doesn't use the save_memory tool, return False."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat = AsyncMock(
return_value=LLMResponse(content="I summarized the conversation.", tool_calls=[])
)
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
assert result is False
assert not store.history_file.exists()
@pytest.mark.asyncio
async def test_skips_when_few_messages(self, tmp_path: Path) -> None:
"""Consolidation should be a no-op when messages < keep_count."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
session = _make_session(message_count=10)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
assert result is True
provider.chat.assert_not_called()
-51
View File
@@ -1,51 +0,0 @@
# Agent Instructions
You are a helpful AI assistant. Be concise, accurate, and friendly.
## Guidelines
- Always explain what you're doing before taking actions
- Ask for clarification when the request is ambiguous
- Use tools to help accomplish tasks
- Remember important information in your memory files
## Tools Available
You have access to:
- File operations (read, write, edit, list)
- Shell commands (exec)
- Web access (search, fetch)
- Messaging (message)
- Background tasks (spawn)
## Memory
- `memory/MEMORY.md` — long-term facts (preferences, context, relationships)
- `memory/HISTORY.md` — append-only event log, search with grep to recall past events
## Scheduled Reminders
When user asks for a reminder at a specific time, use `exec` to run:
```
nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL"
```
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
## Heartbeat Tasks
`HEARTBEAT.md` is checked every 30 minutes. You can manage periodic tasks by editing this file:
- **Add a task**: Use `edit_file` to append new tasks to `HEARTBEAT.md`
- **Remove a task**: Use `edit_file` to remove completed or obsolete tasks
- **Rewrite tasks**: Use `write_file` to completely rewrite the task list
Task format examples:
```
- [ ] Check calendar and remind of upcoming events
- [ ] Scan inbox for urgent emails
- [ ] Check weather forecast for today
```
When the user asks you to add a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time reminder. Keep the file small to minimize token usage.
-150
View File
@@ -1,150 +0,0 @@
# Available Tools
This document describes the tools available to nanobot.
## File Operations
### read_file
Read the contents of a file.
```
read_file(path: str) -> str
```
### write_file
Write content to a file (creates parent directories if needed).
```
write_file(path: str, content: str) -> str
```
### edit_file
Edit a file by replacing specific text.
```
edit_file(path: str, old_text: str, new_text: str) -> str
```
### list_dir
List contents of a directory.
```
list_dir(path: str) -> str
```
## Shell Execution
### exec
Execute a shell command and return output.
```
exec(command: str, working_dir: str = None) -> str
```
**Safety Notes:**
- Commands have a configurable timeout (default 60s)
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
- Output is truncated at 10,000 characters
- Optional `restrictToWorkspace` config to limit paths
## Web Access
### web_search
Search the web using Brave Search API.
```
web_search(query: str, count: int = 5) -> str
```
Returns search results with titles, URLs, and snippets. Requires `tools.web.search.apiKey` in config.
### web_fetch
Fetch and extract main content from a URL.
```
web_fetch(url: str, extractMode: str = "markdown", maxChars: int = 50000) -> str
```
**Notes:**
- Content is extracted using readability
- Supports markdown or plain text extraction
- Output is truncated at 50,000 characters by default
## Communication
### message
Send a message to the user (used internally).
```
message(content: str, channel: str = None, chat_id: str = None) -> str
```
## Background Tasks
### spawn
Spawn a subagent to handle a task in the background.
```
spawn(task: str, label: str = None) -> str
```
Use for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done.
## Scheduled Reminders (Cron)
Use the `exec` tool to create scheduled reminders with `nanobot cron add`:
### Set a recurring reminder
```bash
# Every day at 9am
nanobot cron add --name "morning" --message "Good morning! ☀️" --cron "0 9 * * *"
# Every 2 hours
nanobot cron add --name "water" --message "Drink water! 💧" --every 7200
```
### Set a one-time reminder
```bash
# At a specific time (ISO format)
nanobot cron add --name "meeting" --message "Meeting starts now!" --at "2025-01-31T15:00:00"
```
### Manage reminders
```bash
nanobot cron list # List all jobs
nanobot cron remove <job_id> # Remove a job
```
## Heartbeat Task Management
The `HEARTBEAT.md` file in the workspace is checked every 30 minutes.
Use file operations to manage periodic tasks:
### Add a heartbeat task
```python
# Append a new task
edit_file(
path="HEARTBEAT.md",
old_text="## Example Tasks",
new_text="- [ ] New periodic task here\n\n## Example Tasks"
)
```
### Remove a heartbeat task
```python
# Remove a specific task
edit_file(
path="HEARTBEAT.md",
old_text="- [ ] Task to remove\n",
new_text=""
)
```
### Rewrite all tasks
```python
# Replace the entire file
write_file(
path="HEARTBEAT.md",
content="# Heartbeat Tasks\n\n- [ ] Task 1\n- [ ] Task 2\n"
)
```
---
## Adding Custom Tools
To add custom tools:
1. Create a class that extends `Tool` in `nanobot/agent/tools/`
2. Implement `name`, `description`, `parameters`, and `execute`
3. Register it in `AgentLoop._register_default_tools()`