Compare commits

..
Author SHA1 Message Date
chengyongru 59152877af fix(feishu): support video (media) download by converting type to 'file'
Feishu's GetMessageResource API only accepts 'image' or 'file' as the
type parameter. Video messages have msg_type='media', which was passed
through unchanged, causing error 234001 (Invalid request param). Now
both 'audio' and 'media' are converted to 'file' for download.
2026-04-04 01:39:53 +08:00
chengyongruandchengyongru 791282fc75 test(feishu): add unit tests for reaction add/remove and auto-cleanup 2026-04-03 22:57:11 +08:00
Jiajun Xieandchengyongru a0979d17ee feat: auto-remove reaction after message processing complete
- _add_reaction now returns reaction_id on success
- Add _remove_reaction_sync and _remove_reaction methods
- Remove reaction when stream ends to clear processing indicator
- Store reaction_id in metadata for later removal
2026-04-03 22:57:11 +08:00
FloandGitHub a6aa0b7932 feat(telegram): render tool hints as expandable blockquotes (#2752) 2026-04-03 18:27:53 +08:00
chengyongru a662ace8dd fix(memory): use parent tree in dream-restore revert to properly undo commit
The revert method was using the commit's own tree instead of its parent's,
which meant /dream-restore would restore TO that commit rather than UNDO it.
Also add root commit guard to prevent crash when reverting the initial commit.
2026-04-03 14:14:34 +08:00
Bob JohnsonandGitHub 475ea06294 fix(openai): use max_completion_tokens for OpenAI provider (#2758) 2026-04-03 10:24:03 +08:00
chengyongruandGitHub b2598270bf feat(memory): add git-backed version control for dream memory files (#2753)
- Add GitStore class wrapping dulwich for memory file versioning
- Auto-commit memory changes during Dream consolidation
- Add /dream-log and /dream-restore commands for history browsing
- Pass tracked_files as constructor param, generate .gitignore dynamically
2026-04-02 23:52:13 +08:00
JiajunandGitHub 9d07093e6d feat(provider): add Qianfan provider support (#2699) 2026-04-02 22:16:25 +08:00
chengyongru 4fd4658146 fix(memory): extract successful solutions in consolidate prompt
Add "Solutions" category to consolidate prompt so trial-and-error
workflows that reach a working approach are captured in history for
Dream to persist. Remove overly broad "debug steps" skip rule that
discarded these valuable findings.
2026-04-01 17:53:40 +08:00
chengyongru c93bf114d2 Revert "fix(feishu): handle _resuming in send_delta to prevent duplicate messages (#2667)"
This reverts commit 5d2a13db6a.
2026-04-01 17:30:46 +08:00
FloandGitHub e747d32dda fix(telegram): change drop_pending_updates to False on startup (#2686) 2026-04-01 16:47:41 +08:00
LeftXandGitHub 5d2a13db6a fix(feishu): handle _resuming in send_delta to prevent duplicate messages (#2667)
When a tool call triggers a mid-turn _stream_end(resuming=True), Feishu
channel unconditionally popped the buffer and closed the streaming card.
The next segment then created a new card, causing duplicate/fragmented
replies.

Now send_delta checks _resuming: if True, it flushes current text to
the existing card but keeps the buffer alive so subsequent segments
append to the same card. Only the final _stream_end (resuming=False)
pops the buffer and closes streaming mode.


Made-with: Cursor
2026-04-01 14:54:12 +08:00
FloandGitHub 9a468ab69b fix(tools): strip <think> blocks from message tool content (#2621) 2026-04-01 14:42:18 +08:00
FloandGitHub c41ab9e52e feat(telegram): include author context in reply tags (#2605) (#2606)
* feat(telegram): include author context in reply tags (#2605)

* fix(telegram): handle missing attributes in reply_user safely
2026-04-01 14:16:51 +08:00
FloandGitHub 2780292c9d fix(telegram): remove acknowledgment reaction when response completes (#2564) 2026-04-01 14:14:42 +08:00
FloandGitHub ebf6201b42 fix(telegram): handle RetryAfter delay internally in channel (#2552) 2026-04-01 14:13:08 +08:00
daliu858andGitHub 94e6d569b3 feat(qq): add configurable instant acknowledgment message (#2561)
Add ack_message config field to QQConfig (default: Processing...). When non-empty, sends an instant text reply before agent processing begins, filling the silence gap for users. Uses existing _send_text_only method; failure is logged but never blocks normal message handling.

Made-with: Cursor
2026-04-01 14:10:54 +08:00
FloandGitHub 973fcced2f fix(telegram): support commands with bot username suffix in groups (#2553)
* fix(telegram): support commands with bot username suffix in groups

* fix(command): preserve metadata in builtin command responses
2026-04-01 14:00:52 +08:00
Jiajun Xieandchengyongru 0d6deb9197 fix(cli): prevent spinner ANSI escape codes from being printed verbatim
Fixes #2591

The "nanobot is thinking..." spinner was printing ANSI escape codes
literally in some terminals, causing garbled output like:
  ?[2K?[32m⠧?[0m ?[2mnanobot is thinking...?[0m

Root causes:
1. Console created without force_terminal=True, so Rich couldn't
   reliably detect terminal capabilities
2. Spinner continued running during user input prompt, conflicting
   with prompt_toolkit

Changes:
- Set force_terminal=True in _make_console() for proper ANSI handling
- Add stop_for_input() method to StreamRenderer
- Call stop_for_input() before reading user input in interactive mode
- Add tests for the new functionality
2026-04-01 10:30:16 +08:00
chengyongru 5da86258cc feat(agent): two-stage memory system with Dream consolidation
Replace single-stage MemoryConsolidator with a two-stage architecture:

- Consolidator: lightweight token-budget triggered summarization,
  appends to HISTORY.md with cursor-based tracking
- Dream: cron-scheduled two-phase processor that analyzes HISTORY.md
  and updates SOUL.md, USER.md, MEMORY.md via AgentRunner with
  edit_file tools for surgical, fault-tolerant updates

New files: MemoryStore (pure file I/O), Dream class, DreamConfig,
/dream and /dream-log commands. 89 tests covering all components.
2026-04-01 09:45:24 +08:00
chengyongru cda627f956 Merge branch 'main' into nightly 2026-03-31 17:09:32 +08:00
Xubin RenandXubin Ren 351e3720b6 test(agent): cover disabled subagent exec tool
Add a regression test for the maintainer fix so subagents cannot register ExecTool when exec support is disabled.

Made-with: Cursor
2026-03-31 12:14:28 +08:00
zhangxiaoyu.yorkandXubin Ren c3c1424db3 fix:register exec when enable exec_config 2026-03-31 12:14:28 +08:00
04cbandXubin Ren 929ee09499 fix(utils): ensure reasoning_content present with thinking_blocks (#2579) 2026-03-31 11:49:23 +08:00
04cbandXubin Ren 3f21e83af8 fix(tools): clarify cron message param as agent instruction (#2566) 2026-03-31 11:49:23 +08:00
04cbandXubin Ren 8682b017e2 fix(tools): add Accept header for MCP SSE connections (#2651) 2026-03-31 11:49:23 +08:00
Xubin RenandXubin Ren 7fad14802e feat: add Python SDK facade and per-session isolation 2026-03-31 11:26:43 +08:00
chengyongruandchengyongru 0334fa9944 feat(provider): show cache hit rate in /status (#2645) 2026-03-31 10:57:39 +08:00
chengyongru 4741026538 Merge branch 'main' into nightly 2026-03-31 09:46:09 +08:00
Xubin RenandXubin Ren 842b8b255d fix(agent): preserve core hook failure semantics 2026-03-31 02:19:29 +08:00
Xubin RenandXubin Ren 758c4e74c9 fix(agent): preserve LoopHook error semantics when extra hooks are present 2026-03-31 02:19:29 +08:00
sontianyeandXubin Ren f08de72f18 feat(agent): add CompositeHook for composable lifecycle hooks
Introduce a CompositeHook that fans out lifecycle callbacks to an
ordered list of AgentHook instances with per-hook error isolation.
Extract the nested _LoopHook and _SubagentHook to module scope as
public LoopHook / SubagentHook so downstream users can subclass or
compose them.  Add `hooks` parameter to AgentLoop.__init__ for
registering custom hooks at construction time.

Closes #2603
2026-03-31 02:19:29 +08:00
Xubin RenandGitHub 1814272583 Merge PR #1362: feat: add OpenAI-compatible API
feat: add OpenAI-compatible API
2026-03-30 23:40:04 +08:00
Xubin Ren 5e99b81c6e refactor(api): reduce compatibility and test noise
Make the fixed-session API surface explicit, document its usage, exclude api/ from core agent line counts, and remove implicit aiohttp pytest fixture dependencies from API tests.
2026-03-30 15:05:06 +00:00
Xubin Ren d9a5080d66 refactor(api): tighten fixed-session API contract
Require a single user message, reject mismatched models, document the OpenAI-compatible API, and exclude api/ from core agent line counts so the interface matches nanobot's minimal fixed-session runtime.
2026-03-30 14:43:22 +00:00
Xubin Ren 55501057ac refactor(api): tighten fixed-session chat input contract
Reject mismatched models and require a single user message so the OpenAI-compatible endpoint reflects the fixed-session nanobot runtime without extra compatibility noise.
2026-03-30 14:20:14 +00:00
VanditKumarandGitHub 7c34910fd1 feat(channel): add iMessage integration (#2539)
* feat(channel): add iMessage integration

Add iMessage as a built-in channel via Photon's iMessage platform.

Local mode (macOS) reads ~/Library/Messages/chat.db via sqlite3 with
attachment support, sends via AppleScript, and handles voice
transcription.  Remote mode connects to a Photon endpoint over pure
HTTP with proxy support, implementing send, attachments, tapback
reactions, typing indicators, mark-as-read, polls, groups, contacts,
and health checks.

Paragraph splitting sends each \n\n-separated block as a separate
iMessage bubble.  Startup seeds existing message IDs to avoid
replaying old messages on restart.

Tested end-to-end on both modes.

* fix(imessage): address review feedback

- Add retry with exponential backoff for SQLite lock contention
- Escape newlines/tabs in AppleScript to prevent parse failures
- Lower _MAX_MESSAGE_LEN to 6000 (paragraph splitting handles UX)
- Add privacy note in README for Photon remote mode
- Merge poll interval constants; make local mode configurable too
2026-03-30 15:13:23 +08:00
dd73d5d8df feat(discord): configurable read receipt + subagent working indicator (#2330)
* feat(discord): channel-side read receipt and subagent indicator

- Add 👀 reaction on message receipt, removed after bot reply
- Add 🔧 reaction on first progress message, removed on final reply
- Both managed purely in discord.py channel layer, no subagent.py changes
- Config: read_receipt_emoji, subagent_emoji with sensible defaults

Addresses maintainer feedback on HKUDS/nanobot#2330

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(discord): add both reactions on inbound, not on progress

_progress flag is for streaming chunks, not subagent lifecycle.
Add 👀 + 🔧 immediately on message receipt, clear both on final reply.

* fix: remove stale _subagent_active reference in _clear_reactions

* fix(discord): clean up reactions on message handling failure

Previously, if _handle_message raised an exception, pending reactions
(read receipt + subagent indicator) would remain on the user's message
indefinitely since send() — which handles normal cleanup — would never
be called.

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

* refactor(discord): replace subagent_emoji with delayed working indicator

- Rename subagent_emoji → working_emoji (honest naming: not tied to
  subagent lifecycle)
- Add working_emoji_delay (default 2s) — cosmetic delay so 🔧 appears
  after 👀, cancelled if bot replies before delay fires
- Clean up: cancel pending task + remove both reactions on reply/error

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:02:43 +08:00
chengyongruandchengyongru 0d10c129e8 fix(agent): preserve inbound metadata in streaming callbacks
The on_stream and on_stream_end closures in _dispatch hardcoded
their metadata dicts, dropping channel-specific fields like
message_thread_id. Copy msg.metadata first, then add internal
streaming flags, matching the pattern already used by _bus_progress.
2026-03-30 11:58:46 +08:00
Xubin Ren 5635907e33 feat(api): load serve settings from config
Read serve host, port, and timeout from config by default, keep CLI flags higher priority, and bind the API to localhost by default for safer local usage.
2026-03-29 15:32:33 +00:00
Xubin Ren a0684978fb feat(api): add fixed-session OpenAI-compatible endpoint
Expose OpenAI-compatible chat completions and models endpoints through a single persistent API session, keeping the integration simple without adding multi-session isolation yet.
2026-03-29 14:48:52 +00:00
Paresh MathurchengyongruPares Mathurgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
80403d352b feat(discord): Use discord.py for stable discord channel (#2486)
Co-authored-by: Pares Mathur <paresh.2047@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-29 21:12:22 +08:00
5118ea824a feat(matrix): streaming support (#2447)
* Added streaming message support with incremental updates for Matrix channel

* Improve Matrix message handling and add tests

* Adjust Matrix streaming edit interval to 2 seconds

---------

Co-authored-by: natan <natan@podbielski>
2026-03-29 21:12:14 +08:00
Xubin Ren c8c520cc9a docs: update providers information 2026-03-28 13:28:56 +00:00
CharlesandXubin Ren bee89df422 fix(skill-creator): Fix grammar in SKILL.md: 'another the agent' 2026-03-28 20:37:45 +08:00
Xubin Ren 17d21c8e64 docs: update news section for v0.1.4.post6 release 2026-03-27 15:18:31 +00:00
Xubin Ren aebe928cf0 docs: update v0.1.4.post6 release news 2026-03-27 15:17:22 +00:00
Xubin Ren a42a4e9d83 docs: update v0.1.4.post6 release news 2026-03-27 15:16:28 +00:00
Tink 9d69ba9f56 fix: isolate /new consolidation in API mode 2026-03-13 19:26:50 +08:00
TinkandClaude Opus 4.6 f5cf0bfdee Merge origin/main into feat/openai-compatible-session-isolation (resolve conflicts)
Resolved 6 conflicted files:
- loop.py: adopt MemoryConsolidator pattern from main, keep _isolated_memory_store
- web.py, base.py, helpers.py: merge both sides' imports
- pyproject.toml: keep both api and wecom optional deps
- test_consolidate_offset.py: adopt main's _make_loop helper and consolidate_messages signatures
- test_openai_api.py: remove tests for deleted _consolidate_memory method

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:29:44 +08:00
Tink 37060dea0b Merge origin/main into feat/openai-compatible-session-isolation (resolve conflicts)
# Conflicts:
#	nanobot/agent/context.py
#	nanobot/providers/litellm_provider.py
2026-03-09 10:06:51 +08:00
TinkandClaude Opus 4.6 6b3997c463 fix: add from __future__ import annotations across codebase
Ensure all modules using PEP 604 union syntax (X | Y) include
the future annotations import for Python <3.10 compatibility.
While the project requires >=3.11, this avoids import-time
TypeErrors when running tests on older interpreters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:13:56 +08:00
TinkandClaude Opus 4.6 e868fb32d2 fix: add from __future__ import annotations to fix Python <3.11 compat
These two files from upstream use PEP 604 union syntax (str | None)
without the future annotations import. While the project requires
Python >=3.11, this makes local testing possible on 3.9/3.10.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:09:38 +08:00
Tink f958eb4cc9 Merge remote-tracking branch 'origin/main' into feat/openai-compatible-session-isolation
# Conflicts:
#	nanobot/agent/context.py
#	tests/test_consolidate_offset.py
2026-03-06 19:03:41 +08:00
Tink 80219baf25 feat(api): add OpenAI-compatible endpoint with x-session-key isolation 2026-03-01 10:53:45 +08:00
60 changed files with 7235 additions and 1257 deletions
+199 -12
View File
@@ -21,13 +21,23 @@
## 📢 News ## 📢 News
> [!IMPORTANT] > [!IMPORTANT]
> **Security note:** Due to `litellm` supply chain poisoning, **please check your Python environment ASAP** and refer to this [advisory](https://github.com/HKUDS/nanobot/discussions/2445) for details. We have fully removed the `litellm` dependency in [this commit](https://github.com/HKUDS/nanobot/commit/3dfdab7). > **Security note:** Due to `litellm` supply chain poisoning, **please check your Python environment ASAP** and refer to this [advisory](https://github.com/HKUDS/nanobot/discussions/2445) for details. We have fully removed the `litellm` since **v0.1.4.post6**.
- **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details.
- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries.
- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures.
- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured.
- **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI.
- **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command.
- **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7). - **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).
- **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go. - **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go.
- **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly. - **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly.
- **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details. - **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details.
- **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable. - **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable.
<details>
<summary>Earlier news</summary>
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details. - **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility. - **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling. - **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
@@ -39,10 +49,6 @@
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. - **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish. - **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility. - **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
<details>
<summary>Earlier news</summary>
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes. - **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes. - **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards. - **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
@@ -109,6 +115,8 @@
- [Configuration](#-configuration) - [Configuration](#-configuration)
- [Multiple Instances](#-multiple-instances) - [Multiple Instances](#-multiple-instances)
- [CLI Reference](#-cli-reference) - [CLI Reference](#-cli-reference)
- [Python SDK](#-python-sdk)
- [OpenAI-Compatible API](#-openai-compatible-api)
- [Docker](#-docker) - [Docker](#-docker)
- [Linux Service](#-linux-service) - [Linux Service](#-linux-service)
- [Project Structure](#-project-structure) - [Project Structure](#-project-structure)
@@ -253,6 +261,7 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
| **Email** | IMAP/SMTP credentials | | **Email** | IMAP/SMTP credentials |
| **QQ** | App ID + App Secret | | **QQ** | App ID + App Secret |
| **Wecom** | Bot ID + Bot Secret | | **Wecom** | Bot ID + Bot Secret |
| **iMessage** | macOS (local) or Photon server credentials (remote) |
| **Mochat** | Claw token (auto-setup available) | | **Mochat** | Claw token (auto-setup available) |
<details> <details>
@@ -738,14 +747,10 @@ nanobot gateway
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required. Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
> Weixin support is available from source checkout, but is not included in the current PyPI release yet. **1. Install with WeChat support**
**1. Install from source**
```bash ```bash
git clone https://github.com/HKUDS/nanobot.git pip install "nanobot-ai[weixin]"
cd nanobot
pip install -e ".[weixin]"
``` ```
**2. Configure** **2. Configure**
@@ -827,6 +832,82 @@ nanobot gateway
</details> </details>
<details>
<summary><b>iMessage</b></summary>
Supports two modes via [Photon](https://photon.codes):
- **Local mode**: macOS only. Reads the on-device iMessage database and sends via AppleScript. No external server needed.
- **Remote mode**: Get your endpoint and API key from [Photon](https://photon.codes) and connect from any platform. Supports tapback reactions, typing indicators, mark-as-read, attachments, and inline replies.
**Local mode (macOS)**
1. Grant **Full Disk Access** to your terminal in **System Settings → Privacy & Security → Full Disk Access**
2. Ensure iMessage is signed in and working on the Mac
```json
{
"channels": {
"imessage": {
"enabled": true,
"local": true,
"allowFrom": ["+1234567890"]
}
}
}
```
```bash
nanobot gateway
```
> Local mode supports sending/receiving text, images, and files. For reactions, typing indicators, and inline replies, use remote mode.
**Remote mode**
1. Get your **endpoint URL** and **API key** from [Photon](https://photon.codes)
2. Configure:
```json
{
"channels": {
"imessage": {
"enabled": true,
"local": false,
"serverUrl": "https://xxxxx.imsgd.photon.codes",
"apiKey": "your-api-key",
"allowFrom": ["+1234567890"]
}
}
}
```
```bash
nanobot gateway
```
> `allowFrom`: Add phone numbers or email addresses. Use `["*"]` to allow all senders.
> `groupPolicy`: `"open"` (default — respond to all messages) or `"ignore"` (skip group chats entirely).
> `proxy`: Optional HTTP proxy URL (e.g. `"http://127.0.0.1:7890"`).
> `pollInterval`: Polling interval in seconds (default `2.0`).
> **Note:** Remote mode routes messages through Photon's [advanced-imessage-http-proxy](https://github.com/photon-hq/advanced-imessage-http-proxy). Your messages and attachments transit Photon's infrastructure — the same provider that hosts your iMessage Kit server. If you need full on-device privacy, use local mode instead.
**Feature comparison:**
| Feature | Local | Remote |
|---------|-------|--------|
| Send/receive messages | ✅ | ✅ |
| Images & files | ✅ | ✅ |
| Message history | ✅ | ✅ |
| Reactions (tapbacks) | ❌ | ✅ |
| Typing indicators | ❌ | ✅ |
| Mark as read | ❌ | ✅ |
| Inline replies | ❌ | ✅ (`replyToMessage: true`) |
| Runs on any platform | ❌ | ✅ |
</details>
## 🌐 Agent Social Network ## 🌐 Agent Social Network
🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!** 🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
@@ -852,7 +933,6 @@ Config file: `~/.nanobot/config.json`
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config. > - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config. > - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
> - **Step Fun Step Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.stepfun.ai/step-plan) · [Mainland China](https://platform.stepfun.com/step-plan)
| Provider | Purpose | Get API Key | | Provider | Purpose | Get API Key |
|----------|---------|-------------| |----------|---------|-------------|
@@ -879,6 +959,8 @@ Config file: `~/.nanobot/config.json`
| `vllm` | LLM (local, any OpenAI-compatible server) | — | | `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` | | `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
<details> <details>
<summary><b>OpenAI Codex (OAuth)</b></summary> <summary><b>OpenAI Codex (OAuth)</b></summary>
@@ -1540,6 +1622,7 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
| `nanobot agent` | Interactive chat mode | | `nanobot agent` | Interactive chat mode |
| `nanobot agent --no-markdown` | Show plain-text replies | | `nanobot agent --no-markdown` | Show plain-text replies |
| `nanobot agent --logs` | Show runtime logs during chat | | `nanobot agent --logs` | Show runtime logs during chat |
| `nanobot serve` | Start the OpenAI-compatible API |
| `nanobot gateway` | Start the gateway | | `nanobot gateway` | Start the gateway |
| `nanobot status` | Show status | | `nanobot status` | Show status |
| `nanobot provider login openai-codex` | OAuth login for providers | | `nanobot provider login openai-codex` | OAuth login for providers |
@@ -1568,6 +1651,110 @@ The agent can also manage this file itself — ask it to "add a periodic task" a
</details> </details>
## 🐍 Python SDK
Use nanobot as a library — no CLI, no gateway, just Python:
```python
from nanobot import Nanobot
bot = Nanobot.from_config()
result = await bot.run("Summarize the README")
print(result.content)
```
Each call carries a `session_key` for conversation isolation — different keys get independent history:
```python
await bot.run("hi", session_key="user-alice")
await bot.run("hi", session_key="task-42")
```
Add lifecycle hooks to observe or customize the agent:
```python
from nanobot.agent import AgentHook, AgentHookContext
class AuditHook(AgentHook):
async def before_execute_tools(self, ctx: AgentHookContext) -> None:
for tc in ctx.tool_calls:
print(f"[tool] {tc.name}")
result = await bot.run("Hello", hooks=[AuditHook()])
```
See [docs/PYTHON_SDK.md](docs/PYTHON_SDK.md) for the full SDK reference.
## 🔌 OpenAI-Compatible API
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
```bash
pip install "nanobot-ai[api]"
nanobot serve
```
By default, the API binds to `127.0.0.1:8900`. You can change this in `config.json`.
### Behavior
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
- Single-message input: each request must contain exactly one `user` message
- Fixed model: omit `model`, or pass the same model shown by `/v1/models`
- No streaming: `stream=true` is not supported
### Endpoints
- `GET /health`
- `GET /v1/models`
- `POST /v1/chat/completions`
### curl
```bash
curl http://127.0.0.1:8900/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "hi"}],
"session_id": "my-session"
}'
```
### Python (`requests`)
```python
import requests
resp = requests.post(
"http://127.0.0.1:8900/v1/chat/completions",
json={
"messages": [{"role": "user", "content": "hi"}],
"session_id": "my-session", # optional: isolate conversation
},
timeout=120,
)
resp.raise_for_status()
print(resp.json()["choices"][0]["message"]["content"])
```
### Python (`openai`)
```python
from openai import OpenAI
client = OpenAI(
base_url="http://127.0.0.1:8900/v1",
api_key="dummy",
)
resp = client.chat.completions.create(
model="MiniMax-M2.7",
messages=[{"role": "user", "content": "hi"}],
extra_body={"session_id": "my-session"}, # optional: isolate conversation
)
print(resp.choices[0].message.content)
```
## 🐳 Docker ## 🐳 Docker
> [!TIP] > [!TIP]
+4 -3
View File
@@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
# Count core agent lines (excluding channels/, cli/, providers/ adapters) # Count core agent lines (excluding channels/, cli/, api/, providers/ adapters,
# and the high-level Python SDK facade)
cd "$(dirname "$0")" || exit 1 cd "$(dirname "$0")" || exit 1
echo "nanobot core agent line count" echo "nanobot core agent line count"
@@ -15,7 +16,7 @@ root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
printf " %-16s %5s lines\n" "(root)" "$root" printf " %-16s %5s lines\n" "(root)" "$root"
echo "" echo ""
total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l) total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/api/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" ! -path "nanobot/nanobot.py" | xargs cat | wc -l)
echo " Core total: $total lines" echo " Core total: $total lines"
echo "" echo ""
echo " (excludes: channels/, cli/, command/, providers/, skills/)" echo " (excludes: channels/, cli/, api/, command/, providers/, skills/, nanobot.py)"
+136
View File
@@ -0,0 +1,136 @@
# Python SDK
Use nanobot programmatically — load config, run the agent, get results.
## Quick Start
```python
import asyncio
from nanobot import Nanobot
async def main():
bot = Nanobot.from_config()
result = await bot.run("What time is it in Tokyo?")
print(result.content)
asyncio.run(main())
```
## API
### `Nanobot.from_config(config_path?, *, workspace?)`
Create a `Nanobot` from a config file.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `config_path` | `str \| Path \| None` | `None` | Path to `config.json`. Defaults to `~/.nanobot/config.json`. |
| `workspace` | `str \| Path \| None` | `None` | Override workspace directory from config. |
Raises `FileNotFoundError` if an explicit path doesn't exist.
### `await bot.run(message, *, session_key?, hooks?)`
Run the agent once. Returns a `RunResult`.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `message` | `str` | *(required)* | The user message to process. |
| `session_key` | `str` | `"sdk:default"` | Session identifier for conversation isolation. Different keys get independent history. |
| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. |
```python
# Isolated sessions — each user gets independent conversation history
await bot.run("hi", session_key="user-alice")
await bot.run("hi", session_key="user-bob")
```
### `RunResult`
| Field | Type | Description |
|-------|------|-------------|
| `content` | `str` | The agent's final text response. |
| `tools_used` | `list[str]` | Tool names invoked during the run. |
| `messages` | `list[dict]` | Raw message history (for debugging). |
## Hooks
Hooks let you observe or modify the agent loop without touching internals.
Subclass `AgentHook` and override any method:
| Method | When |
|--------|------|
| `before_iteration(ctx)` | Before each LLM call |
| `on_stream(ctx, delta)` | On each streamed token |
| `on_stream_end(ctx)` | When streaming finishes |
| `before_execute_tools(ctx)` | Before tool execution (inspect `ctx.tool_calls`) |
| `after_iteration(ctx, response)` | After each LLM response |
| `finalize_content(ctx, content)` | Transform final output text |
### Example: Audit Hook
```python
from nanobot.agent import AgentHook, AgentHookContext
class AuditHook(AgentHook):
def __init__(self):
self.calls = []
async def before_execute_tools(self, ctx: AgentHookContext) -> None:
for tc in ctx.tool_calls:
self.calls.append(tc.name)
print(f"[audit] {tc.name}({tc.arguments})")
hook = AuditHook()
result = await bot.run("List files in /tmp", hooks=[hook])
print(f"Tools used: {hook.calls}")
```
### Composing Hooks
Pass multiple hooks — they run in order, errors in one don't block others:
```python
result = await bot.run("hi", hooks=[AuditHook(), MetricsHook()])
```
Under the hood this uses `CompositeHook` for fan-out with error isolation.
### `finalize_content` Pipeline
Unlike the async methods (fan-out), `finalize_content` is a pipeline — each hook's output feeds the next:
```python
class Censor(AgentHook):
def finalize_content(self, ctx, content):
return content.replace("secret", "***") if content else content
```
## Full Example
```python
import asyncio
from nanobot import Nanobot
from nanobot.agent import AgentHook, AgentHookContext
class TimingHook(AgentHook):
async def before_iteration(self, ctx: AgentHookContext) -> None:
import time
ctx.metadata["_t0"] = time.time()
async def after_iteration(self, ctx, response) -> None:
import time
elapsed = time.time() - ctx.metadata.get("_t0", 0)
print(f"[timing] iteration took {elapsed:.2f}s")
async def main():
bot = Nanobot.from_config(workspace="/my/project")
result = await bot.run(
"Explain the main function",
hooks=[TimingHook()],
)
print(result.content)
asyncio.run(main())
```
+4
View File
@@ -4,3 +4,7 @@ nanobot - A lightweight AI agent framework
__version__ = "0.1.4.post6" __version__ = "0.1.4.post6"
__logo__ = "🐈" __logo__ = "🐈"
from nanobot.nanobot import Nanobot, RunResult
__all__ = ["Nanobot", "RunResult"]
+14 -2
View File
@@ -1,8 +1,20 @@
"""Agent core module.""" """Agent core module."""
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import Consolidator, Dream, MemoryStore
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
from nanobot.agent.subagent import SubagentManager
__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"] __all__ = [
"AgentHook",
"AgentHookContext",
"AgentLoop",
"CompositeHook",
"ContextBuilder",
"Dream",
"MemoryStore",
"SkillsLoader",
"SubagentManager",
]
+2 -2
View File
@@ -82,8 +82,8 @@ You are nanobot, a helpful AI assistant.
## Workspace ## Workspace
Your workspace is at: {workspace_path} Your workspace is at: {workspace_path}
- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here) - Long-term memory: {workspace_path}/memory/MEMORY.md (automatically managed by Dream — do not edit directly)
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. - History log: {workspace_path}/memory/history.jsonl (append-only JSONL, not grep-searchable).
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
{platform_policy} {platform_policy}
+307
View File
@@ -0,0 +1,307 @@
"""Git-backed version control for memory files, using dulwich."""
from __future__ import annotations
import io
import time
from dataclasses import dataclass
from pathlib import Path
from loguru import logger
@dataclass
class CommitInfo:
sha: str # Short SHA (8 chars)
message: str
timestamp: str # Formatted datetime
def format(self, diff: str = "") -> str:
"""Format this commit for display, optionally with a diff."""
header = f"## {self.message.splitlines()[0]}\n`{self.sha}` — {self.timestamp}\n"
if diff:
return f"{header}\n```diff\n{diff}\n```"
return f"{header}\n(no file changes)"
class GitStore:
"""Git-backed version control for memory files."""
def __init__(self, workspace: Path, tracked_files: list[str]):
self._workspace = workspace
self._tracked_files = tracked_files
def is_initialized(self) -> bool:
"""Check if the git repo has been initialized."""
return (self._workspace / ".git").is_dir()
# -- init ------------------------------------------------------------------
def init(self) -> bool:
"""Initialize a git repo if not already initialized.
Creates .gitignore and makes an initial commit.
Returns True if a new repo was created, False if already exists.
"""
if self.is_initialized():
return False
try:
from dulwich import porcelain
porcelain.init(str(self._workspace))
# Write .gitignore
gitignore = self._workspace / ".gitignore"
gitignore.write_text(self._build_gitignore(), encoding="utf-8")
# Ensure tracked files exist (touch them if missing) so the initial
# commit has something to track.
for rel in self._tracked_files:
p = self._workspace / rel
p.parent.mkdir(parents=True, exist_ok=True)
if not p.exists():
p.write_text("", encoding="utf-8")
# Initial commit
porcelain.add(str(self._workspace), paths=[".gitignore"] + self._tracked_files)
porcelain.commit(
str(self._workspace),
message=b"init: nanobot memory store",
author=b"nanobot <nanobot@dream>",
committer=b"nanobot <nanobot@dream>",
)
logger.info("Git store initialized at {}", self._workspace)
return True
except Exception:
logger.warning("Git store init failed for {}", self._workspace)
return False
# -- daily operations ------------------------------------------------------
def auto_commit(self, message: str) -> str | None:
"""Stage tracked memory files and commit if there are changes.
Returns the short commit SHA, or None if nothing to commit.
"""
if not self.is_initialized():
return None
try:
from dulwich import porcelain
# .gitignore excludes everything except tracked files,
# so any staged/unstaged change must be in our files.
st = porcelain.status(str(self._workspace))
if not st.unstaged and not any(st.staged.values()):
return None
msg_bytes = message.encode("utf-8") if isinstance(message, str) else message
porcelain.add(str(self._workspace), paths=self._tracked_files)
sha_bytes = porcelain.commit(
str(self._workspace),
message=msg_bytes,
author=b"nanobot <nanobot@dream>",
committer=b"nanobot <nanobot@dream>",
)
if sha_bytes is None:
return None
sha = sha_bytes.hex()[:8]
logger.debug("Git auto-commit: {} ({})", sha, message)
return sha
except Exception:
logger.warning("Git auto-commit failed: {}", message)
return None
# -- internal helpers ------------------------------------------------------
def _resolve_sha(self, short_sha: str) -> bytes | None:
"""Resolve a short SHA prefix to the full SHA bytes."""
try:
from dulwich.repo import Repo
with Repo(str(self._workspace)) as repo:
try:
sha = repo.refs[b"HEAD"]
except KeyError:
return None
while sha:
if sha.hex().startswith(short_sha):
return sha
commit = repo[sha]
if commit.type_name != b"commit":
break
sha = commit.parents[0] if commit.parents else None
return None
except Exception:
return None
def _build_gitignore(self) -> str:
"""Generate .gitignore content from tracked files."""
dirs: set[str] = set()
for f in self._tracked_files:
parent = str(Path(f).parent)
if parent != ".":
dirs.add(parent)
lines = ["/*"]
for d in sorted(dirs):
lines.append(f"!{d}/")
for f in self._tracked_files:
lines.append(f"!{f}")
lines.append("!.gitignore")
return "\n".join(lines) + "\n"
# -- query -----------------------------------------------------------------
def log(self, max_entries: int = 20) -> list[CommitInfo]:
"""Return simplified commit log."""
if not self.is_initialized():
return []
try:
from dulwich.repo import Repo
entries: list[CommitInfo] = []
with Repo(str(self._workspace)) as repo:
try:
head = repo.refs[b"HEAD"]
except KeyError:
return []
sha = head
while sha and len(entries) < max_entries:
commit = repo[sha]
if commit.type_name != b"commit":
break
ts = time.strftime(
"%Y-%m-%d %H:%M",
time.localtime(commit.commit_time),
)
msg = commit.message.decode("utf-8", errors="replace").strip()
entries.append(CommitInfo(
sha=sha.hex()[:8],
message=msg,
timestamp=ts,
))
sha = commit.parents[0] if commit.parents else None
return entries
except Exception:
logger.warning("Git log failed")
return []
def diff_commits(self, sha1: str, sha2: str) -> str:
"""Show diff between two commits."""
if not self.is_initialized():
return ""
try:
from dulwich import porcelain
full1 = self._resolve_sha(sha1)
full2 = self._resolve_sha(sha2)
if not full1 or not full2:
return ""
out = io.BytesIO()
porcelain.diff(
str(self._workspace),
commit=full1,
commit2=full2,
outstream=out,
)
return out.getvalue().decode("utf-8", errors="replace")
except Exception:
logger.warning("Git diff_commits failed")
return ""
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
"""Find a commit by short SHA prefix match."""
for c in self.log(max_entries=max_entries):
if c.sha.startswith(short_sha):
return c
return None
def show_commit_diff(self, short_sha: str, max_entries: int = 20) -> tuple[CommitInfo, str] | None:
"""Find a commit and return it with its diff vs the parent."""
commits = self.log(max_entries=max_entries)
for i, c in enumerate(commits):
if c.sha.startswith(short_sha):
if i + 1 < len(commits):
diff = self.diff_commits(commits[i + 1].sha, c.sha)
else:
diff = ""
return c, diff
return None
# -- restore ---------------------------------------------------------------
def revert(self, commit: str) -> str | None:
"""Revert (undo) the changes introduced by the given commit.
Restores all tracked memory files to the state at the commit's parent,
then creates a new commit recording the revert.
Returns the new commit SHA, or None on failure.
"""
if not self.is_initialized():
return None
try:
from dulwich.repo import Repo
full_sha = self._resolve_sha(commit)
if not full_sha:
logger.warning("Git revert: SHA not found: {}", commit)
return None
with Repo(str(self._workspace)) as repo:
commit_obj = repo[full_sha]
if commit_obj.type_name != b"commit":
return None
if not commit_obj.parents:
logger.warning("Git revert: cannot revert root commit {}", commit)
return None
# Use the parent's tree — this undoes the commit's changes
parent_obj = repo[commit_obj.parents[0]]
tree = repo[parent_obj.tree]
restored: list[str] = []
for filepath in self._tracked_files:
content = self._read_blob_from_tree(repo, tree, filepath)
if content is not None:
dest = self._workspace / filepath
dest.write_text(content, encoding="utf-8")
restored.append(filepath)
if not restored:
return None
# Commit the restored state
msg = f"revert: undo {commit}"
return self.auto_commit(msg)
except Exception:
logger.warning("Git revert failed for {}", commit)
return None
@staticmethod
def _read_blob_from_tree(repo, tree, filepath: str) -> str | None:
"""Read a blob's content from a tree object by walking path parts."""
parts = Path(filepath).parts
current = tree
for part in parts:
try:
entry = current[part.encode()]
except KeyError:
return None
obj = repo[entry[1]]
if obj.type_name == b"blob":
return obj.data.decode("utf-8", errors="replace")
if obj.type_name == b"tree":
current = obj
else:
return None
return None
+59
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from loguru import logger
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -47,3 +49,60 @@ class AgentHook:
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None: def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
return content return content
class CompositeHook(AgentHook):
"""Fan-out hook that delegates to an ordered list of hooks.
Error isolation: async methods catch and log per-hook exceptions
so a faulty custom hook cannot crash the agent loop.
``finalize_content`` is a pipeline (no isolation — bugs should surface).
"""
__slots__ = ("_hooks",)
def __init__(self, hooks: list[AgentHook]) -> None:
self._hooks = list(hooks)
def wants_streaming(self) -> bool:
return any(h.wants_streaming() for h in self._hooks)
async def before_iteration(self, context: AgentHookContext) -> None:
for h in self._hooks:
try:
await h.before_iteration(context)
except Exception:
logger.exception("AgentHook.before_iteration error in {}", type(h).__name__)
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
for h in self._hooks:
try:
await h.on_stream(context, delta)
except Exception:
logger.exception("AgentHook.on_stream error in {}", type(h).__name__)
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
for h in self._hooks:
try:
await h.on_stream_end(context, resuming=resuming)
except Exception:
logger.exception("AgentHook.on_stream_end error in {}", type(h).__name__)
async def before_execute_tools(self, context: AgentHookContext) -> None:
for h in self._hooks:
try:
await h.before_execute_tools(context)
except Exception:
logger.exception("AgentHook.before_execute_tools error in {}", type(h).__name__)
async def after_iteration(self, context: AgentHookContext) -> None:
for h in self._hooks:
try:
await h.after_iteration(context)
except Exception:
logger.exception("AgentHook.after_iteration error in {}", type(h).__name__)
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
for h in self._hooks:
content = h.finalize_content(context, content)
return content
+153 -59
View File
@@ -14,8 +14,8 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
from nanobot.agent.memory import MemoryConsolidator from nanobot.agent.memory import Consolidator, Dream
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
@@ -37,6 +37,120 @@ if TYPE_CHECKING:
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
class _LoopHook(AgentHook):
"""Core lifecycle hook for the main agent loop.
Handles streaming delta relay, progress reporting, tool-call logging,
and think-tag stripping for the built-in agent path.
"""
def __init__(
self,
agent_loop: AgentLoop,
on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None,
*,
channel: str = "cli",
chat_id: str = "direct",
message_id: str | None = None,
) -> None:
self._loop = agent_loop
self._on_progress = on_progress
self._on_stream = on_stream
self._on_stream_end = on_stream_end
self._channel = channel
self._chat_id = chat_id
self._message_id = message_id
self._stream_buf = ""
def wants_streaming(self) -> bool:
return self._on_stream is not None
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
from nanobot.utils.helpers import strip_think
prev_clean = strip_think(self._stream_buf)
self._stream_buf += delta
new_clean = strip_think(self._stream_buf)
incremental = new_clean[len(prev_clean):]
if incremental and self._on_stream:
await self._on_stream(incremental)
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
if self._on_stream_end:
await self._on_stream_end(resuming=resuming)
self._stream_buf = ""
async def before_execute_tools(self, context: AgentHookContext) -> None:
if self._on_progress:
if not self._on_stream:
thought = self._loop._strip_think(
context.response.content if context.response else None
)
if thought:
await self._on_progress(thought)
tool_hint = self._loop._strip_think(self._loop._tool_hint(context.tool_calls))
await self._on_progress(tool_hint, tool_hint=True)
for tc in context.tool_calls:
args_str = json.dumps(tc.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tc.name, args_str[:200])
self._loop._set_tool_context(self._channel, self._chat_id, self._message_id)
async def after_iteration(self, context: AgentHookContext) -> None:
u = context.usage or {}
logger.debug(
"LLM usage: prompt={} completion={} cached={}",
u.get("prompt_tokens", 0),
u.get("completion_tokens", 0),
u.get("cached_tokens", 0),
)
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
return self._loop._strip_think(content)
class _LoopHookChain(AgentHook):
"""Run the core loop hook first, then best-effort extra hooks.
This preserves the historical failure behavior of ``_LoopHook`` while still
letting user-supplied hooks opt into ``CompositeHook`` isolation.
"""
__slots__ = ("_primary", "_extras")
def __init__(self, primary: AgentHook, extra_hooks: list[AgentHook]) -> None:
self._primary = primary
self._extras = CompositeHook(extra_hooks)
def wants_streaming(self) -> bool:
return self._primary.wants_streaming() or self._extras.wants_streaming()
async def before_iteration(self, context: AgentHookContext) -> None:
await self._primary.before_iteration(context)
await self._extras.before_iteration(context)
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
await self._primary.on_stream(context, delta)
await self._extras.on_stream(context, delta)
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
await self._primary.on_stream_end(context, resuming=resuming)
await self._extras.on_stream_end(context, resuming=resuming)
async def before_execute_tools(self, context: AgentHookContext) -> None:
await self._primary.before_execute_tools(context)
await self._extras.before_execute_tools(context)
async def after_iteration(self, context: AgentHookContext) -> None:
await self._primary.after_iteration(context)
await self._extras.after_iteration(context)
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
content = self._primary.finalize_content(context, content)
return self._extras.finalize_content(context, content)
class AgentLoop: class AgentLoop:
""" """
The agent loop is the core processing engine. The agent loop is the core processing engine.
@@ -68,6 +182,7 @@ class AgentLoop:
mcp_servers: dict | None = None, mcp_servers: dict | None = None,
channels_config: ChannelsConfig | None = None, channels_config: ChannelsConfig | None = None,
timezone: str | None = None, timezone: str | None = None,
hooks: list[AgentHook] | None = None,
): ):
from nanobot.config.schema import ExecToolConfig, WebSearchConfig from nanobot.config.schema import ExecToolConfig, WebSearchConfig
@@ -85,6 +200,7 @@ class AgentLoop:
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self._start_time = time.time() self._start_time = time.time()
self._last_usage: dict[str, int] = {} self._last_usage: dict[str, int] = {}
self._extra_hooks: list[AgentHook] = hooks or []
self.context = ContextBuilder(workspace, timezone=timezone) self.context = ContextBuilder(workspace, timezone=timezone)
self.sessions = session_manager or SessionManager(workspace) self.sessions = session_manager or SessionManager(workspace)
@@ -114,8 +230,8 @@ class AgentLoop:
self._concurrency_gate: asyncio.Semaphore | None = ( self._concurrency_gate: asyncio.Semaphore | None = (
asyncio.Semaphore(_max) if _max > 0 else None asyncio.Semaphore(_max) if _max > 0 else None
) )
self.memory_consolidator = MemoryConsolidator( self.consolidator = Consolidator(
workspace=workspace, store=self.context.memory,
provider=provider, provider=provider,
model=self.model, model=self.model,
sessions=self.sessions, sessions=self.sessions,
@@ -124,6 +240,11 @@ class AgentLoop:
get_tool_definitions=self.tools.get_definitions, get_tool_definitions=self.tools.get_definitions,
max_completion_tokens=provider.generation.max_tokens, max_completion_tokens=provider.generation.max_tokens,
) )
self.dream = Dream(
store=self.context.memory,
provider=provider,
model=self.model,
)
self._register_default_tools() self._register_default_tools()
self.commands = CommandRouter() self.commands = CommandRouter()
register_builtin_commands(self.commands) register_builtin_commands(self.commands)
@@ -217,52 +338,27 @@ class AgentLoop:
``resuming=True`` means tool calls follow (spinner should restart); ``resuming=True`` means tool calls follow (spinner should restart);
``resuming=False`` means this is the final response. ``resuming=False`` means this is the final response.
""" """
loop_self = self loop_hook = _LoopHook(
self,
class _LoopHook(AgentHook): on_progress=on_progress,
def __init__(self) -> None: on_stream=on_stream,
self._stream_buf = "" on_stream_end=on_stream_end,
channel=channel,
def wants_streaming(self) -> bool: chat_id=chat_id,
return on_stream is not None message_id=message_id,
)
async def on_stream(self, context: AgentHookContext, delta: str) -> None: hook: AgentHook = (
from nanobot.utils.helpers import strip_think _LoopHookChain(loop_hook, self._extra_hooks)
if self._extra_hooks
prev_clean = strip_think(self._stream_buf) else loop_hook
self._stream_buf += delta )
new_clean = strip_think(self._stream_buf)
incremental = new_clean[len(prev_clean):]
if incremental and on_stream:
await on_stream(incremental)
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
if on_stream_end:
await on_stream_end(resuming=resuming)
self._stream_buf = ""
async def before_execute_tools(self, context: AgentHookContext) -> None:
if on_progress:
if not on_stream:
thought = loop_self._strip_think(context.response.content if context.response else None)
if thought:
await on_progress(thought)
tool_hint = loop_self._strip_think(loop_self._tool_hint(context.tool_calls))
await on_progress(tool_hint, tool_hint=True)
for tc in context.tool_calls:
args_str = json.dumps(tc.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tc.name, args_str[:200])
loop_self._set_tool_context(channel, chat_id, message_id)
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
return loop_self._strip_think(content)
result = await self.runner.run(AgentRunSpec( result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages, initial_messages=initial_messages,
tools=self.tools, tools=self.tools,
model=self.model, model=self.model,
max_iterations=self.max_iterations, max_iterations=self.max_iterations,
hook=_LoopHook(), hook=hook,
error_message="Sorry, I encountered an error calling the AI model.", error_message="Sorry, I encountered an error calling the AI model.",
concurrent_tools=True, concurrent_tools=True,
)) ))
@@ -321,25 +417,23 @@ class AgentLoop:
return f"{stream_base_id}:{stream_segment}" return f"{stream_base_id}:{stream_segment}"
async def on_stream(delta: str) -> None: async def on_stream(delta: str) -> None:
meta = dict(msg.metadata or {})
meta["_stream_delta"] = True
meta["_stream_id"] = _current_stream_id()
await self.bus.publish_outbound(OutboundMessage( await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
content=delta, content=delta, metadata=meta,
metadata={
"_stream_delta": True,
"_stream_id": _current_stream_id(),
},
)) ))
async def on_stream_end(*, resuming: bool = False) -> None: async def on_stream_end(*, resuming: bool = False) -> None:
nonlocal stream_segment nonlocal stream_segment
meta = dict(msg.metadata or {})
meta["_stream_end"] = True
meta["_resuming"] = resuming
meta["_stream_id"] = _current_stream_id()
await self.bus.publish_outbound(OutboundMessage( await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
content="", content="", metadata=meta,
metadata={
"_stream_end": True,
"_resuming": resuming,
"_stream_id": _current_stream_id(),
},
)) ))
stream_segment += 1 stream_segment += 1
@@ -402,7 +496,7 @@ class AgentLoop:
logger.info("Processing system message from {}", msg.sender_id) logger.info("Processing system message from {}", msg.sender_id)
key = f"{channel}:{chat_id}" key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
await self.memory_consolidator.maybe_consolidate_by_tokens(session) await self.consolidator.maybe_consolidate_by_tokens(session)
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=0) history = session.get_history(max_messages=0)
current_role = "assistant" if msg.sender_id == "subagent" else "user" current_role = "assistant" if msg.sender_id == "subagent" else "user"
@@ -417,7 +511,7 @@ class AgentLoop:
) )
self._save_turn(session, all_msgs, 1 + len(history)) self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session) self.sessions.save(session)
self._schedule_background(self.memory_consolidator.maybe_consolidate_by_tokens(session)) self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(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.")
@@ -433,7 +527,7 @@ class AgentLoop:
if result := await self.commands.dispatch(ctx): if result := await self.commands.dispatch(ctx):
return result return result
await self.memory_consolidator.maybe_consolidate_by_tokens(session) await self.consolidator.maybe_consolidate_by_tokens(session)
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"):
@@ -470,7 +564,7 @@ class AgentLoop:
self._save_turn(session, all_msgs, 1 + len(history)) self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session) self.sessions.save(session)
self._schedule_background(self.memory_consolidator.maybe_consolidate_by_tokens(session)) self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn: if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
return None return None
+409 -182
View File
@@ -1,4 +1,4 @@
"""Memory system for persistent agent memory.""" """Memory system: pure file I/O store, lightweight Consolidator, and Dream processor."""
from __future__ import annotations from __future__ import annotations
@@ -11,94 +11,189 @@ from typing import TYPE_CHECKING, Any, Callable
from loguru import logger from loguru import logger
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.git_store import GitStore
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
_SAVE_MEMORY_TOOL = [ # ---------------------------------------------------------------------------
{ # MemoryStore — pure file I/O layer
"type": "function", # ---------------------------------------------------------------------------
"function": {
"name": "save_memory",
"description": "Save the memory consolidation result to persistent storage.",
"parameters": {
"type": "object",
"properties": {
"history_entry": {
"type": "string",
"description": "A paragraph summarizing key events/decisions/topics. "
"Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
},
"memory_update": {
"type": "string",
"description": "Full updated long-term memory as markdown. Include all existing "
"facts plus new ones. Return unchanged if nothing new.",
},
},
"required": ["history_entry", "memory_update"],
},
},
}
]
def _ensure_text(value: Any) -> str:
"""Normalize tool-call payload values to text for file storage."""
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _normalize_save_memory_args(args: Any) -> dict[str, Any] | None:
"""Normalize provider tool-call arguments to the expected dict shape."""
if isinstance(args, str):
args = json.loads(args)
if isinstance(args, list):
return args[0] if args and isinstance(args[0], dict) else None
return args if isinstance(args, dict) else None
_TOOL_CHOICE_ERROR_MARKERS = (
"tool_choice",
"toolchoice",
"does not support",
'should be ["none", "auto"]',
)
def _is_tool_choice_unsupported(content: str | None) -> bool:
"""Detect provider errors caused by forced tool_choice being unsupported."""
text = (content or "").lower()
return any(m in text for m in _TOOL_CHOICE_ERROR_MARKERS)
class MemoryStore: class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log).""" """Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
_MAX_FAILURES_BEFORE_RAW_ARCHIVE = 3 _DEFAULT_MAX_HISTORY = 1000
def __init__(self, workspace: Path): def __init__(self, workspace: Path, max_history_entries: int = _DEFAULT_MAX_HISTORY):
self.workspace = workspace
self.max_history_entries = max_history_entries
self.memory_dir = ensure_dir(workspace / "memory") self.memory_dir = ensure_dir(workspace / "memory")
self.memory_file = self.memory_dir / "MEMORY.md" self.memory_file = self.memory_dir / "MEMORY.md"
self.history_file = self.memory_dir / "HISTORY.md" self.history_file = self.memory_dir / "history.jsonl"
self._consecutive_failures = 0 self.soul_file = workspace / "SOUL.md"
self.user_file = workspace / "USER.md"
self._dream_log_file = self.memory_dir / ".dream-log.md"
self._cursor_file = self.memory_dir / ".cursor"
self._dream_cursor_file = self.memory_dir / ".dream_cursor"
self._git = GitStore(workspace, tracked_files=[
"SOUL.md", "USER.md", "memory/MEMORY.md",
])
def read_long_term(self) -> str: @property
if self.memory_file.exists(): def git(self) -> GitStore:
return self.memory_file.read_text(encoding="utf-8") return self._git
# -- generic helpers -----------------------------------------------------
@staticmethod
def read_file(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
return "" return ""
def write_long_term(self, content: str) -> None: # -- MEMORY.md (long-term facts) -----------------------------------------
def read_memory(self) -> str:
return self.read_file(self.memory_file)
def write_memory(self, content: str) -> None:
self.memory_file.write_text(content, encoding="utf-8") self.memory_file.write_text(content, encoding="utf-8")
def append_history(self, entry: str) -> None: # -- SOUL.md -------------------------------------------------------------
with open(self.history_file, "a", encoding="utf-8") as f:
f.write(entry.rstrip() + "\n\n") def read_soul(self) -> str:
return self.read_file(self.soul_file)
def write_soul(self, content: str) -> None:
self.soul_file.write_text(content, encoding="utf-8")
# -- USER.md -------------------------------------------------------------
def read_user(self) -> str:
return self.read_file(self.user_file)
def write_user(self, content: str) -> None:
self.user_file.write_text(content, encoding="utf-8")
# -- context injection (used by context.py) ------------------------------
def get_memory_context(self) -> str: def get_memory_context(self) -> str:
long_term = self.read_long_term() long_term = self.read_memory()
return f"## Long-term Memory\n{long_term}" if long_term else "" return f"## Long-term Memory\n{long_term}" if long_term else ""
# -- history.jsonl — append-only, JSONL format ---------------------------
def append_history(self, entry: str) -> int:
"""Append *entry* to history.jsonl and return its auto-incrementing cursor."""
cursor = self._next_cursor()
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
record = {"cursor": cursor, "timestamp": ts, "content": strip_think(entry.rstrip()) or entry.rstrip()}
with open(self.history_file, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
self._cursor_file.write_text(str(cursor), encoding="utf-8")
return cursor
def _next_cursor(self) -> int:
"""Read the current cursor counter and return next value."""
if self._cursor_file.exists():
try:
return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
except (ValueError, OSError):
pass
# Fallback: read last line's cursor from the JSONL file.
last = self._read_last_entry()
if last:
return last["cursor"] + 1
return 1
def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]:
"""Return history entries with cursor > *since_cursor*."""
return [e for e in self._read_entries() if e["cursor"] > since_cursor]
def compact_history(self) -> None:
"""Drop oldest entries if the file exceeds *max_history_entries*."""
if self.max_history_entries <= 0:
return
entries = self._read_entries()
if len(entries) <= self.max_history_entries:
return
kept = entries[-self.max_history_entries:]
self._write_entries(kept)
# -- JSONL helpers -------------------------------------------------------
def _read_entries(self) -> list[dict[str, Any]]:
"""Read all entries from history.jsonl."""
entries: list[dict[str, Any]] = []
try:
with open(self.history_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
except FileNotFoundError:
pass
return entries
def _read_last_entry(self) -> dict[str, Any] | None:
"""Read the last entry from the JSONL file efficiently."""
try:
with open(self.history_file, "rb") as f:
f.seek(0, 2)
size = f.tell()
if size == 0:
return None
read_size = min(size, 4096)
f.seek(size - read_size)
data = f.read().decode("utf-8")
lines = [l for l in data.split("\n") if l.strip()]
if not lines:
return None
return json.loads(lines[-1])
except (FileNotFoundError, json.JSONDecodeError):
return None
def _write_entries(self, entries: list[dict[str, Any]]) -> None:
"""Overwrite history.jsonl with the given entries."""
with open(self.history_file, "w", encoding="utf-8") as f:
for entry in entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# -- dream cursor --------------------------------------------------------
def get_last_dream_cursor(self) -> int:
if self._dream_cursor_file.exists():
try:
return int(self._dream_cursor_file.read_text(encoding="utf-8").strip())
except (ValueError, OSError):
pass
return 0
def set_last_dream_cursor(self, cursor: int) -> None:
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
# -- dream log -----------------------------------------------------------
def read_dream_log(self) -> str:
return self.read_file(self._dream_log_file)
def append_dream_log(self, entry: str) -> None:
with open(self._dream_log_file, "a", encoding="utf-8") as f:
f.write(f"{entry.rstrip()}\n\n")
# -- message formatting utility ------------------------------------------
@staticmethod @staticmethod
def _format_messages(messages: list[dict]) -> str: def _format_messages(messages: list[dict]) -> str:
lines = [] lines = []
@@ -111,107 +206,10 @@ class MemoryStore:
) )
return "\n".join(lines) return "\n".join(lines)
async def consolidate( def raw_archive(self, messages: list[dict]) -> None:
self,
messages: list[dict],
provider: LLMProvider,
model: str,
) -> bool:
"""Consolidate the provided message chunk into MEMORY.md + HISTORY.md."""
if not messages:
return True
current_memory = self.read_long_term()
prompt = f"""Process this conversation and call the save_memory tool with your consolidation.
## Current Long-term Memory
{current_memory or "(empty)"}
## Conversation to Process
{self._format_messages(messages)}"""
chat_messages = [
{"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
{"role": "user", "content": prompt},
]
try:
forced = {"type": "function", "function": {"name": "save_memory"}}
response = await provider.chat_with_retry(
messages=chat_messages,
tools=_SAVE_MEMORY_TOOL,
model=model,
tool_choice=forced,
)
if response.finish_reason == "error" and _is_tool_choice_unsupported(
response.content
):
logger.warning("Forced tool_choice unsupported, retrying with auto")
response = await provider.chat_with_retry(
messages=chat_messages,
tools=_SAVE_MEMORY_TOOL,
model=model,
tool_choice="auto",
)
if not response.has_tool_calls:
logger.warning(
"Memory consolidation: LLM did not call save_memory "
"(finish_reason={}, content_len={}, content_preview={})",
response.finish_reason,
len(response.content or ""),
(response.content or "")[:200],
)
return self._fail_or_raw_archive(messages)
args = _normalize_save_memory_args(response.tool_calls[0].arguments)
if args is None:
logger.warning("Memory consolidation: unexpected save_memory arguments")
return self._fail_or_raw_archive(messages)
if "history_entry" not in args or "memory_update" not in args:
logger.warning("Memory consolidation: save_memory payload missing required fields")
return self._fail_or_raw_archive(messages)
entry = args["history_entry"]
update = args["memory_update"]
if entry is None or update is None:
logger.warning("Memory consolidation: save_memory payload contains null required fields")
return self._fail_or_raw_archive(messages)
entry = _ensure_text(entry).strip()
if not entry:
logger.warning("Memory consolidation: history_entry is empty after normalization")
return self._fail_or_raw_archive(messages)
self.append_history(entry)
update = _ensure_text(update)
if update != current_memory:
self.write_long_term(update)
self._consecutive_failures = 0
logger.info("Memory consolidation done for {} messages", len(messages))
return True
except Exception:
logger.exception("Memory consolidation failed")
return self._fail_or_raw_archive(messages)
def _fail_or_raw_archive(self, messages: list[dict]) -> bool:
"""Increment failure count; after threshold, raw-archive messages and return True."""
self._consecutive_failures += 1
if self._consecutive_failures < self._MAX_FAILURES_BEFORE_RAW_ARCHIVE:
return False
self._raw_archive(messages)
self._consecutive_failures = 0
return True
def _raw_archive(self, messages: list[dict]) -> None:
"""Fallback: dump raw messages to HISTORY.md without LLM summarization.""" """Fallback: dump raw messages to HISTORY.md without LLM summarization."""
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
self.append_history( self.append_history(
f"[{ts}] [RAW] {len(messages)} messages\n" f"[RAW] {len(messages)} messages\n"
f"{self._format_messages(messages)}" f"{self._format_messages(messages)}"
) )
logger.warning( logger.warning(
@@ -219,8 +217,14 @@ class MemoryStore:
) )
class MemoryConsolidator:
"""Owns consolidation policy, locking, and session offset updates.""" # ---------------------------------------------------------------------------
# Consolidator — lightweight token-budget triggered consolidation
# ---------------------------------------------------------------------------
class Consolidator:
"""Lightweight consolidation: summarizes evicted messages, appends to HISTORY.md."""
_MAX_CONSOLIDATION_ROUNDS = 5 _MAX_CONSOLIDATION_ROUNDS = 5
@@ -228,7 +232,7 @@ class MemoryConsolidator:
def __init__( def __init__(
self, self,
workspace: Path, store: MemoryStore,
provider: LLMProvider, provider: LLMProvider,
model: str, model: str,
sessions: SessionManager, sessions: SessionManager,
@@ -237,7 +241,7 @@ class MemoryConsolidator:
get_tool_definitions: Callable[[], list[dict[str, Any]]], get_tool_definitions: Callable[[], list[dict[str, Any]]],
max_completion_tokens: int = 4096, max_completion_tokens: int = 4096,
): ):
self.store = MemoryStore(workspace) self.store = store
self.provider = provider self.provider = provider
self.model = model self.model = model
self.sessions = sessions self.sessions = sessions
@@ -245,16 +249,14 @@ class MemoryConsolidator:
self.max_completion_tokens = max_completion_tokens self.max_completion_tokens = max_completion_tokens
self._build_messages = build_messages self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions self._get_tool_definitions = get_tool_definitions
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary() self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary()
)
def get_lock(self, session_key: str) -> asyncio.Lock: def get_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared consolidation lock for one session.""" """Return the shared consolidation lock for one session."""
return self._locks.setdefault(session_key, asyncio.Lock()) return self._locks.setdefault(session_key, asyncio.Lock())
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
return await self.store.consolidate(messages, self.provider, self.model)
def pick_consolidation_boundary( def pick_consolidation_boundary(
self, self,
session: Session, session: Session,
@@ -294,13 +296,49 @@ class MemoryConsolidator:
self._get_tool_definitions(), self._get_tool_definitions(),
) )
async def archive_messages(self, messages: list[dict[str, object]]) -> bool: async def archive(self, messages: list[dict]) -> bool:
"""Archive messages with guaranteed persistence (retries until raw-dump fallback).""" """Summarize messages via LLM and append to HISTORY.md.
Returns True on success (or degraded success), False if nothing to do.
"""
if not messages: if not messages:
return False
try:
formatted = MemoryStore._format_messages(messages)
response = await self.provider.chat_with_retry(
model=self.model,
messages=[
{
"role": "system",
"content": (
"Extract key facts from this conversation. "
"Only output items matching these categories, skip everything else:\n"
"- User facts: personal info, preferences, stated opinions, habits\n"
"- Decisions: choices made, conclusions reached\n"
"- Solutions: working approaches discovered through trial and error, "
"especially non-obvious methods that succeeded after failed attempts\n"
"- Events: plans, deadlines, notable occurrences\n"
"- Preferences: communication style, tool preferences\n\n"
"Priority: user corrections and preferences > solutions > decisions > events > environment facts. "
"The most valuable memory prevents the user from having to repeat themselves.\n\n"
"Skip: code patterns derivable from source, git history, "
"or anything already captured in existing memory.\n\n"
"Output as concise bullet points, one fact per line. "
"No preamble, no commentary.\n"
"If nothing noteworthy happened, output: (nothing)"
),
},
{"role": "user", "content": formatted},
],
tools=None,
tool_choice=None,
)
summary = response.content or "[no summary]"
self.store.append_history(summary)
return True return True
for _ in range(self.store._MAX_FAILURES_BEFORE_RAW_ARCHIVE): except Exception:
if await self.consolidate_messages(messages): logger.warning("Consolidation LLM call failed, raw-dumping to history")
return True self.store.raw_archive(messages)
return True return True
async def maybe_consolidate_by_tokens(self, session: Session) -> None: async def maybe_consolidate_by_tokens(self, session: Session) -> None:
@@ -356,7 +394,7 @@ class MemoryConsolidator:
source, source,
len(chunk), len(chunk),
) )
if not await self.consolidate_messages(chunk): if not await self.archive(chunk):
return return
session.last_consolidated = end_idx session.last_consolidated = end_idx
self.sessions.save(session) self.sessions.save(session)
@@ -364,3 +402,192 @@ class MemoryConsolidator:
estimated, source = self.estimate_session_prompt_tokens(session) estimated, source = self.estimate_session_prompt_tokens(session)
if estimated <= 0: if estimated <= 0:
return return
# ---------------------------------------------------------------------------
# Dream — heavyweight cron-scheduled memory consolidation
# ---------------------------------------------------------------------------
class Dream:
"""Two-phase memory processor: analyze HISTORY.md, then edit files via AgentRunner.
Phase 1 produces an analysis summary (plain LLM call).
Phase 2 delegates to AgentRunner with read_file / edit_file tools so the
LLM can make targeted, incremental edits instead of replacing entire files.
"""
_PHASE1_SYSTEM = (
"Compare conversation history against current memory files. "
"Output one line per finding:\n"
"[FILE] atomic fact or change description\n\n"
"Files: USER (identity, preferences, habits), "
"SOUL (bot behavior, tone), "
"MEMORY (knowledge, project context, tool patterns)\n\n"
"Rules:\n"
"- Only new or conflicting information — skip duplicates and ephemera\n"
"- Prefer atomic facts: \"has a cat named Luna\" not \"discussed pet care\"\n"
"- Corrections: [USER] location is Tokyo, not Osaka\n"
"- Also capture confirmed approaches: if the user validated a non-obvious choice, note it\n\n"
"If nothing needs updating: [SKIP] no new information"
)
_PHASE2_SYSTEM = (
"Update memory files based on the analysis below.\n\n"
"## Quality standards\n"
"- Every line must carry standalone value — no filler\n"
"- Concise bullet points under clear headers\n"
"- Remove outdated or contradicted information\n\n"
"## Editing\n"
"- File contents provided below — edit directly, no read_file needed\n"
"- Batch changes to the same file into one edit_file call\n"
"- Surgical edits only — never rewrite entire files\n"
"- Do NOT overwrite correct entries — only add, update, or remove\n"
"- If nothing to update, stop without calling tools"
)
def __init__(
self,
store: MemoryStore,
provider: LLMProvider,
model: str,
max_batch_size: int = 20,
max_iterations: int = 10,
):
self.store = store
self.provider = provider
self.model = model
self.max_batch_size = max_batch_size
self.max_iterations = max_iterations
self._runner = AgentRunner(provider)
self._tools = self._build_tools()
# -- tool registry -------------------------------------------------------
def _build_tools(self) -> ToolRegistry:
"""Build a minimal tool registry for the Dream agent."""
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
tools = ToolRegistry()
workspace = self.store.workspace
tools.register(ReadFileTool(workspace=workspace, allowed_dir=workspace))
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace))
return tools
# -- main entry ----------------------------------------------------------
async def run(self) -> bool:
"""Process unprocessed history entries. Returns True if work was done."""
last_cursor = self.store.get_last_dream_cursor()
entries = self.store.read_unprocessed_history(since_cursor=last_cursor)
if not entries:
return False
batch = entries[: self.max_batch_size]
logger.info(
"Dream: processing {} entries (cursor {}{}), batch={}",
len(entries), last_cursor, batch[-1]["cursor"], len(batch),
)
# Build history text for LLM
history_text = "\n".join(
f"[{e['timestamp']}] {e['content']}" for e in batch
)
# Current file contents
current_memory = self.store.read_memory() or "(empty)"
current_soul = self.store.read_soul() or "(empty)"
current_user = self.store.read_user() or "(empty)"
file_context = (
f"## Current MEMORY.md\n{current_memory}\n\n"
f"## Current SOUL.md\n{current_soul}\n\n"
f"## Current USER.md\n{current_user}"
)
# Phase 1: Analyze
phase1_prompt = (
f"## Conversation History\n{history_text}\n\n{file_context}"
)
try:
phase1_response = await self.provider.chat_with_retry(
model=self.model,
messages=[
{"role": "system", "content": self._PHASE1_SYSTEM},
{"role": "user", "content": phase1_prompt},
],
tools=None,
tool_choice=None,
)
analysis = phase1_response.content or ""
logger.debug("Dream Phase 1 complete ({} chars)", len(analysis))
except Exception:
logger.exception("Dream Phase 1 failed")
return False
# Phase 2: Delegate to AgentRunner with read_file / edit_file
phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}"
tools = self._tools
messages: list[dict[str, Any]] = [
{"role": "system", "content": self._PHASE2_SYSTEM},
{"role": "user", "content": phase2_prompt},
]
try:
result = await self._runner.run(AgentRunSpec(
initial_messages=messages,
tools=tools,
model=self.model,
max_iterations=self.max_iterations,
fail_on_tool_error=True,
))
logger.debug(
"Dream Phase 2 complete: stop_reason={}, tool_events={}",
result.stop_reason, len(result.tool_events),
)
except Exception:
logger.exception("Dream Phase 2 failed")
result = None
# Build changelog from tool events
changelog: list[str] = []
if result and result.tool_events:
for event in result.tool_events:
if event["status"] == "ok":
changelog.append(f"{event['name']}: {event['detail']}")
# Advance cursor — always, to avoid re-processing Phase 1
new_cursor = batch[-1]["cursor"]
self.store.set_last_dream_cursor(new_cursor)
self.store.compact_history()
if result and result.stop_reason == "completed":
logger.info(
"Dream done: {} change(s), cursor advanced to {}",
len(changelog), new_cursor,
)
else:
reason = result.stop_reason if result else "exception"
logger.warning(
"Dream incomplete ({}): cursor advanced to {}",
reason, new_cursor,
)
# Write dream log
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
if changelog:
log_entry = f"## {ts}\n"
for change in changelog:
log_entry += f"- {change}\n"
self.store.append_dream_log(log_entry)
else:
self.store.append_dream_log(f"## {ts}\nNo changes.\n")
# Git auto-commit (only when there are actual changes)
if changelog and self.store.git.is_initialized():
sha = self.store.git.auto_commit(f"dream: {ts}, {len(changelog)} change(s)")
if sha:
logger.info("Dream commit: {}", sha)
return True
+8 -6
View File
@@ -60,7 +60,7 @@ class AgentRunner:
messages = list(spec.initial_messages) messages = list(spec.initial_messages)
final_content: str | None = None final_content: str | None = None
tools_used: list[str] = [] tools_used: list[str] = []
usage = {"prompt_tokens": 0, "completion_tokens": 0} usage: dict[str, int] = {}
error: str | None = None error: str | None = None
stop_reason = "completed" stop_reason = "completed"
tool_events: list[dict[str, str]] = [] tool_events: list[dict[str, str]] = []
@@ -92,13 +92,15 @@ class AgentRunner:
response = await self.provider.chat_with_retry(**kwargs) response = await self.provider.chat_with_retry(**kwargs)
raw_usage = response.usage or {} raw_usage = response.usage or {}
usage = {
"prompt_tokens": int(raw_usage.get("prompt_tokens", 0) or 0),
"completion_tokens": int(raw_usage.get("completion_tokens", 0) or 0),
}
context.response = response context.response = response
context.usage = usage context.usage = raw_usage
context.tool_calls = list(response.tool_calls) context.tool_calls = list(response.tool_calls)
# Accumulate standard fields into result usage.
usage["prompt_tokens"] = usage.get("prompt_tokens", 0) + int(raw_usage.get("prompt_tokens", 0) or 0)
usage["completion_tokens"] = usage.get("completion_tokens", 0) + int(raw_usage.get("completion_tokens", 0) or 0)
cached = raw_usage.get("cached_tokens")
if cached:
usage["cached_tokens"] = usage.get("cached_tokens", 0) + int(cached)
if response.has_tool_calls: if response.has_tool_calls:
if hook.wants_streaming(): if hook.wants_streaming():
+17 -7
View File
@@ -21,6 +21,21 @@ from nanobot.config.schema import ExecToolConfig
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
class _SubagentHook(AgentHook):
"""Logging-only hook for subagent execution."""
def __init__(self, task_id: str) -> None:
self._task_id = task_id
async def before_execute_tools(self, context: AgentHookContext) -> None:
for tool_call in context.tool_calls:
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.debug(
"Subagent [{}] executing: {} with arguments: {}",
self._task_id, tool_call.name, args_str,
)
class SubagentManager: class SubagentManager:
"""Manages background subagent execution.""" """Manages background subagent execution."""
@@ -100,6 +115,7 @@ class SubagentManager:
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
if self.exec_config.enable:
tools.register(ExecTool( tools.register(ExecTool(
working_dir=str(self.workspace), working_dir=str(self.workspace),
timeout=self.exec_config.timeout, timeout=self.exec_config.timeout,
@@ -115,18 +131,12 @@ class SubagentManager:
{"role": "user", "content": task}, {"role": "user", "content": task},
] ]
class _SubagentHook(AgentHook):
async def before_execute_tools(self, context: AgentHookContext) -> None:
for tool_call in context.tool_calls:
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.debug("Subagent [{}] executing: {} with arguments: {}", task_id, tool_call.name, args_str)
result = await self.runner.run(AgentRunSpec( result = await self.runner.run(AgentRunSpec(
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model=self.model, model=self.model,
max_iterations=15, max_iterations=15,
hook=_SubagentHook(), hook=_SubagentHook(task_id),
max_iterations_message="Task completed but no final response was generated.", max_iterations_message="Task completed but no final response was generated.",
error_message=None, error_message=None,
fail_on_tool_error=True, fail_on_tool_error=True,
+1 -1
View File
@@ -74,7 +74,7 @@ class CronTool(Tool):
"enum": ["add", "list", "remove"], "enum": ["add", "list", "remove"],
"description": "Action to perform", "description": "Action to perform",
}, },
"message": {"type": "string", "description": "Reminder message (for add)"}, "message": {"type": "string", "description": "Instruction for the agent to execute when the job triggers (e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report')"},
"every_seconds": { "every_seconds": {
"type": "integer", "type": "integer",
"description": "Interval in seconds (for recurring tasks)", "description": "Interval in seconds (for recurring tasks)",
+5 -1
View File
@@ -170,7 +170,11 @@ async def connect_mcp_servers(
timeout: httpx.Timeout | None = None, timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None, auth: httpx.Auth | None = None,
) -> httpx.AsyncClient: ) -> httpx.AsyncClient:
merged_headers = {**(cfg.headers or {}), **(headers or {})} merged_headers = {
"Accept": "application/json, text/event-stream",
**(cfg.headers or {}),
**(headers or {}),
}
return httpx.AsyncClient( return httpx.AsyncClient(
headers=merged_headers or None, headers=merged_headers or None,
follow_redirects=True, follow_redirects=True,
+3
View File
@@ -84,6 +84,9 @@ class MessageTool(Tool):
media: list[str] | None = None, media: list[str] | None = None,
**kwargs: Any **kwargs: Any
) -> str: ) -> str:
from nanobot.utils.helpers import strip_think
content = strip_think(content)
channel = channel or self._default_channel channel = channel or self._default_channel
chat_id = chat_id or self._default_chat_id chat_id = chat_id or self._default_chat_id
message_id = message_id or self._default_message_id message_id = message_id or self._default_message_id
+1
View File
@@ -0,0 +1 @@
"""OpenAI-compatible HTTP API for nanobot."""
+193
View File
@@ -0,0 +1,193 @@
"""OpenAI-compatible HTTP API server for a fixed nanobot session.
Provides /v1/chat/completions and /v1/models endpoints.
All requests route to a single persistent API session.
"""
from __future__ import annotations
import asyncio
import time
import uuid
from typing import Any
from aiohttp import web
from loguru import logger
API_SESSION_KEY = "api:default"
API_CHAT_ID = "default"
# ---------------------------------------------------------------------------
# Response helpers
# ---------------------------------------------------------------------------
def _error_json(status: int, message: str, err_type: str = "invalid_request_error") -> web.Response:
return web.json_response(
{"error": {"message": message, "type": err_type, "code": status}},
status=status,
)
def _chat_completion_response(content: str, model: str) -> dict[str, Any]:
return {
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
def _response_text(value: Any) -> str:
"""Normalize process_direct output to plain assistant text."""
if value is None:
return ""
if hasattr(value, "content"):
return str(getattr(value, "content") or "")
return str(value)
# ---------------------------------------------------------------------------
# Route handlers
# ---------------------------------------------------------------------------
async def handle_chat_completions(request: web.Request) -> web.Response:
"""POST /v1/chat/completions"""
# --- Parse body ---
try:
body = await request.json()
except Exception:
return _error_json(400, "Invalid JSON body")
messages = body.get("messages")
if not isinstance(messages, list) or len(messages) != 1:
return _error_json(400, "Only a single user message is supported")
# Stream not yet supported
if body.get("stream", False):
return _error_json(400, "stream=true is not supported yet. Set stream=false or omit it.")
message = messages[0]
if not isinstance(message, dict) or message.get("role") != "user":
return _error_json(400, "Only a single user message is supported")
user_content = message.get("content", "")
if isinstance(user_content, list):
# Multi-modal content array — extract text parts
user_content = " ".join(
part.get("text", "") for part in user_content if part.get("type") == "text"
)
agent_loop = request.app["agent_loop"]
timeout_s: float = request.app.get("request_timeout", 120.0)
model_name: str = request.app.get("model_name", "nanobot")
if (requested_model := body.get("model")) and requested_model != model_name:
return _error_json(400, f"Only configured model '{model_name}' is available")
session_key = f"api:{body['session_id']}" if body.get("session_id") else API_SESSION_KEY
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
logger.info("API request session_key={} content={}", session_key, user_content[:80])
_FALLBACK = "I've completed processing but have no response to give."
try:
async with session_lock:
try:
response = await asyncio.wait_for(
agent_loop.process_direct(
content=user_content,
session_key=session_key,
channel="api",
chat_id=API_CHAT_ID,
),
timeout=timeout_s,
)
response_text = _response_text(response)
if not response_text or not response_text.strip():
logger.warning(
"Empty response for session {}, retrying",
session_key,
)
retry_response = await asyncio.wait_for(
agent_loop.process_direct(
content=user_content,
session_key=session_key,
channel="api",
chat_id=API_CHAT_ID,
),
timeout=timeout_s,
)
response_text = _response_text(retry_response)
if not response_text or not response_text.strip():
logger.warning(
"Empty response after retry for session {}, using fallback",
session_key,
)
response_text = _FALLBACK
except asyncio.TimeoutError:
return _error_json(504, f"Request timed out after {timeout_s}s")
except Exception:
logger.exception("Error processing request for session {}", session_key)
return _error_json(500, "Internal server error", err_type="server_error")
except Exception:
logger.exception("Unexpected API lock error for session {}", session_key)
return _error_json(500, "Internal server error", err_type="server_error")
return web.json_response(_chat_completion_response(response_text, model_name))
async def handle_models(request: web.Request) -> web.Response:
"""GET /v1/models"""
model_name = request.app.get("model_name", "nanobot")
return web.json_response({
"object": "list",
"data": [
{
"id": model_name,
"object": "model",
"created": 0,
"owned_by": "nanobot",
}
],
})
async def handle_health(request: web.Request) -> web.Response:
"""GET /health"""
return web.json_response({"status": "ok"})
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0) -> web.Application:
"""Create the aiohttp application.
Args:
agent_loop: An initialized AgentLoop instance.
model_name: Model name reported in responses.
request_timeout: Per-request timeout in seconds.
"""
app = web.Application()
app["agent_loop"] = agent_loop
app["model_name"] = model_name
app["request_timeout"] = request_timeout
app["session_locks"] = {} # per-user locks, keyed by session_key
app.router.add_post("/v1/chat/completions", handle_chat_completions)
app.router.add_get("/v1/models", handle_models)
app.router.add_get("/health", handle_health)
return app
+404 -283
View File
@@ -1,25 +1,37 @@
"""Discord channel implementation using Discord Gateway websocket.""" """Discord channel implementation using discord.py."""
from __future__ import annotations
import asyncio import asyncio
import json import importlib.util
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import TYPE_CHECKING, Any, Literal
import httpx
from pydantic import Field
import websockets
from loguru import logger from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.utils.helpers import split_message from nanobot.utils.helpers import safe_filename, split_message
DISCORD_AVAILABLE = importlib.util.find_spec("discord") is not None
if TYPE_CHECKING:
import discord
from discord import app_commands
from discord.abc import Messageable
if DISCORD_AVAILABLE:
import discord
from discord import app_commands
from discord.abc import Messageable
DISCORD_API_BASE = "https://discord.com/api/v10"
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
MAX_MESSAGE_LEN = 2000 # Discord message character limit MAX_MESSAGE_LEN = 2000 # Discord message character limit
TYPING_INTERVAL_S = 8
class DiscordConfig(Base): class DiscordConfig(Base):
@@ -28,145 +40,155 @@ class DiscordConfig(Base):
enabled: bool = False enabled: bool = False
token: str = "" token: str = ""
allow_from: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list)
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
intents: int = 37377 intents: int = 37377
group_policy: Literal["mention", "open"] = "mention" group_policy: Literal["mention", "open"] = "mention"
read_receipt_emoji: str = "👀"
working_emoji: str = "🔧"
working_emoji_delay: float = 2.0
class DiscordChannel(BaseChannel): if DISCORD_AVAILABLE:
"""Discord channel using Gateway websocket."""
name = "discord" class DiscordBotClient(discord.Client):
display_name = "Discord" """discord.py client that forwards events to the channel."""
@classmethod def __init__(self, channel: DiscordChannel, *, intents: discord.Intents) -> None:
def default_config(cls) -> dict[str, Any]: super().__init__(intents=intents)
return DiscordConfig().model_dump(by_alias=True) self._channel = channel
self.tree = app_commands.CommandTree(self)
self._register_app_commands()
def __init__(self, config: Any, bus: MessageBus): async def on_ready(self) -> None:
if isinstance(config, dict): self._channel._bot_user_id = str(self.user.id) if self.user else None
config = DiscordConfig.model_validate(config) logger.info("Discord bot connected as user {}", self._channel._bot_user_id)
super().__init__(config, bus)
self.config: DiscordConfig = config
self._ws: websockets.WebSocketClientProtocol | None = None
self._seq: int | None = None
self._heartbeat_task: asyncio.Task | None = None
self._typing_tasks: dict[str, asyncio.Task] = {}
self._http: httpx.AsyncClient | None = None
self._bot_user_id: str | None = None
async def start(self) -> None:
"""Start the Discord gateway connection."""
if not self.config.token:
logger.error("Discord bot token not configured")
return
self._running = True
self._http = httpx.AsyncClient(timeout=30.0)
while self._running:
try: try:
logger.info("Connecting to Discord gateway...") synced = await self.tree.sync()
async with websockets.connect(self.config.gateway_url) as ws: logger.info("Discord app commands synced: {}", len(synced))
self._ws = ws
await self._gateway_loop()
except asyncio.CancelledError:
break
except Exception as e: except Exception as e:
logger.warning("Discord gateway error: {}", e) logger.warning("Discord app command sync failed: {}", e)
if self._running:
logger.info("Reconnecting to Discord gateway in 5 seconds...")
await asyncio.sleep(5)
async def stop(self) -> None: async def on_message(self, message: discord.Message) -> None:
"""Stop the Discord channel.""" await self._channel._handle_discord_message(message)
self._running = False
if self._heartbeat_task:
self._heartbeat_task.cancel()
self._heartbeat_task = None
for task in self._typing_tasks.values():
task.cancel()
self._typing_tasks.clear()
if self._ws:
await self._ws.close()
self._ws = None
if self._http:
await self._http.aclose()
self._http = None
async def send(self, msg: OutboundMessage) -> None: async def _reply_ephemeral(self, interaction: discord.Interaction, text: str) -> bool:
"""Send a message through Discord REST API, including file attachments.""" """Send an ephemeral interaction response and report success."""
if not self._http: try:
logger.warning("Discord HTTP client not initialized") await interaction.response.send_message(text, ephemeral=True)
return True
except Exception as e:
logger.warning("Discord interaction response failed: {}", e)
return False
async def _forward_slash_command(
self,
interaction: discord.Interaction,
command_text: str,
) -> None:
sender_id = str(interaction.user.id)
channel_id = interaction.channel_id
if channel_id is None:
logger.warning("Discord slash command missing channel_id: {}", command_text)
return return
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" if not self._channel.is_allowed(sender_id):
headers = {"Authorization": f"Bot {self.config.token}"} await self._reply_ephemeral(interaction, "You are not allowed to use this bot.")
return
await self._reply_ephemeral(interaction, f"Processing {command_text}...")
await self._channel._handle_message(
sender_id=sender_id,
chat_id=str(channel_id),
content=command_text,
metadata={
"interaction_id": str(interaction.id),
"guild_id": str(interaction.guild_id) if interaction.guild_id else None,
"is_slash_command": True,
},
)
def _register_app_commands(self) -> None:
commands = (
("new", "Start a new conversation", "/new"),
("stop", "Stop the current task", "/stop"),
("restart", "Restart the bot", "/restart"),
("status", "Show bot status", "/status"),
)
for name, description, command_text in commands:
@self.tree.command(name=name, description=description)
async def command_handler(
interaction: discord.Interaction,
_command_text: str = command_text,
) -> None:
await self._forward_slash_command(interaction, _command_text)
@self.tree.command(name="help", description="Show available commands")
async def help_command(interaction: discord.Interaction) -> None:
sender_id = str(interaction.user.id)
if not self._channel.is_allowed(sender_id):
await self._reply_ephemeral(interaction, "You are not allowed to use this bot.")
return
await self._reply_ephemeral(interaction, build_help_text())
@self.tree.error
async def on_app_command_error(
interaction: discord.Interaction,
error: app_commands.AppCommandError,
) -> None:
command_name = interaction.command.qualified_name if interaction.command else "?"
logger.warning(
"Discord app command failed user={} channel={} cmd={} error={}",
interaction.user.id,
interaction.channel_id,
command_name,
error,
)
async def send_outbound(self, msg: OutboundMessage) -> None:
"""Send a nanobot outbound message using Discord transport rules."""
channel_id = int(msg.chat_id)
channel = self.get_channel(channel_id)
if channel is None:
try: try:
channel = await self.fetch_channel(channel_id)
except Exception as e:
logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e)
return
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
sent_media = False sent_media = False
failed_media: list[str] = [] failed_media: list[str] = []
# Send file attachments first for index, media_path in enumerate(msg.media or []):
for media_path in msg.media or []: if await self._send_file(
if await self._send_file(url, headers, media_path, reply_to=msg.reply_to): channel,
media_path,
reference=reference if index == 0 else None,
mention_settings=mention_settings,
):
sent_media = True sent_media = True
else: else:
failed_media.append(Path(media_path).name) failed_media.append(Path(media_path).name)
# Send text content for index, chunk in enumerate(self._build_chunks(msg.content or "", failed_media, sent_media)):
chunks = split_message(msg.content or "", MAX_MESSAGE_LEN) kwargs: dict[str, Any] = {"content": chunk}
if not chunks and failed_media and not sent_media: if index == 0 and reference is not None and not sent_media:
chunks = split_message( kwargs["reference"] = reference
"\n".join(f"[attachment: {name} - send failed]" for name in failed_media), kwargs["allowed_mentions"] = mention_settings
MAX_MESSAGE_LEN, await channel.send(**kwargs)
)
if not chunks:
return
for i, chunk in enumerate(chunks):
payload: dict[str, Any] = {"content": chunk}
# Let the first successful attachment carry the reply if present.
if i == 0 and msg.reply_to and not sent_media:
payload["message_reference"] = {"message_id": msg.reply_to}
payload["allowed_mentions"] = {"replied_user": False}
if not await self._send_payload(url, headers, payload):
break # Abort remaining chunks on failure
finally:
await self._stop_typing(msg.chat_id)
async def _send_payload(
self, url: str, headers: dict[str, str], payload: dict[str, Any]
) -> bool:
"""Send a single Discord API payload with retry on rate-limit. Returns True on success."""
for attempt in range(3):
try:
response = await self._http.post(url, headers=headers, json=payload)
if response.status_code == 429:
data = response.json()
retry_after = float(data.get("retry_after", 1.0))
logger.warning("Discord rate limited, retrying in {}s", retry_after)
await asyncio.sleep(retry_after)
continue
response.raise_for_status()
return True
except Exception as e:
if attempt == 2:
logger.error("Error sending Discord message: {}", e)
else:
await asyncio.sleep(1)
return False
async def _send_file( async def _send_file(
self, self,
url: str, channel: Messageable,
headers: dict[str, str],
file_path: str, file_path: str,
reply_to: str | None = None, *,
reference: discord.PartialMessage | None,
mention_settings: discord.AllowedMentions,
) -> bool: ) -> bool:
"""Send a file attachment via Discord REST API using multipart/form-data.""" """Send a file attachment via discord.py."""
path = Path(file_path) path = Path(file_path)
if not path.is_file(): if not path.is_file():
logger.warning("Discord file not found, skipping: {}", file_path) logger.warning("Discord file not found, skipping: {}", file_path)
@@ -176,220 +198,319 @@ class DiscordChannel(BaseChannel):
logger.warning("Discord file too large (>20MB), skipping: {}", path.name) logger.warning("Discord file too large (>20MB), skipping: {}", path.name)
return False return False
payload_json: dict[str, Any] = {}
if reply_to:
payload_json["message_reference"] = {"message_id": reply_to}
payload_json["allowed_mentions"] = {"replied_user": False}
for attempt in range(3):
try: try:
with open(path, "rb") as f: kwargs: dict[str, Any] = {"file": discord.File(path)}
files = {"files[0]": (path.name, f, "application/octet-stream")} if reference is not None:
data: dict[str, Any] = {} kwargs["reference"] = reference
if payload_json: kwargs["allowed_mentions"] = mention_settings
data["payload_json"] = json.dumps(payload_json) await channel.send(**kwargs)
response = await self._http.post(
url, headers=headers, files=files, data=data
)
if response.status_code == 429:
resp_data = response.json()
retry_after = float(resp_data.get("retry_after", 1.0))
logger.warning("Discord rate limited, retrying in {}s", retry_after)
await asyncio.sleep(retry_after)
continue
response.raise_for_status()
logger.info("Discord file sent: {}", path.name) logger.info("Discord file sent: {}", path.name)
return True return True
except Exception as e: except Exception as e:
if attempt == 2:
logger.error("Error sending Discord file {}: {}", path.name, e) logger.error("Error sending Discord file {}: {}", path.name, e)
else:
await asyncio.sleep(1)
return False return False
async def _gateway_loop(self) -> None: @staticmethod
"""Main gateway loop: identify, heartbeat, dispatch events.""" def _build_chunks(content: str, failed_media: list[str], sent_media: bool) -> list[str]:
if not self._ws: """Build outbound text chunks, including attachment-failure fallback text."""
chunks = split_message(content, MAX_MESSAGE_LEN)
if chunks or not failed_media or sent_media:
return chunks
fallback = "\n".join(f"[attachment: {name} - send failed]" for name in failed_media)
return split_message(fallback, MAX_MESSAGE_LEN)
@staticmethod
def _build_reply_context(
channel: Messageable,
reply_to: str | None,
) -> tuple[discord.PartialMessage | None, discord.AllowedMentions]:
"""Build reply context for outbound messages."""
mention_settings = discord.AllowedMentions(replied_user=False)
if not reply_to:
return None, mention_settings
try:
message_id = int(reply_to)
except ValueError:
logger.warning("Invalid Discord reply target: {}", reply_to)
return None, mention_settings
return channel.get_partial_message(message_id), mention_settings
class DiscordChannel(BaseChannel):
"""Discord channel using discord.py."""
name = "discord"
display_name = "Discord"
@classmethod
def default_config(cls) -> dict[str, Any]:
return DiscordConfig().model_dump(by_alias=True)
@staticmethod
def _channel_key(channel_or_id: Any) -> str:
"""Normalize channel-like objects and ids to a stable string key."""
channel_id = getattr(channel_or_id, "id", channel_or_id)
return str(channel_id)
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = DiscordConfig.model_validate(config)
super().__init__(config, bus)
self.config: DiscordConfig = config
self._client: DiscordBotClient | None = None
self._typing_tasks: dict[str, asyncio.Task[None]] = {}
self._bot_user_id: str | None = None
self._pending_reactions: dict[str, Any] = {} # chat_id -> message object
self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {}
async def start(self) -> None:
"""Start the Discord client."""
if not DISCORD_AVAILABLE:
logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]")
return return
async for raw in self._ws: if not self.config.token:
try: logger.error("Discord bot token not configured")
data = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Invalid JSON from Discord gateway: {}", raw[:100])
continue
op = data.get("op")
event_type = data.get("t")
seq = data.get("s")
payload = data.get("d")
if seq is not None:
self._seq = seq
if op == 10:
# HELLO: start heartbeat and identify
interval_ms = payload.get("heartbeat_interval", 45000)
await self._start_heartbeat(interval_ms / 1000)
await self._identify()
elif op == 0 and event_type == "READY":
logger.info("Discord gateway READY")
# Capture bot user ID for mention detection
user_data = payload.get("user") or {}
self._bot_user_id = user_data.get("id")
logger.info("Discord bot connected as user {}", self._bot_user_id)
elif op == 0 and event_type == "MESSAGE_CREATE":
await self._handle_message_create(payload)
elif op == 7:
# RECONNECT: exit loop to reconnect
logger.info("Discord gateway requested reconnect")
break
elif op == 9:
# INVALID_SESSION: reconnect
logger.warning("Discord gateway invalid session")
break
async def _identify(self) -> None:
"""Send IDENTIFY payload."""
if not self._ws:
return return
identify = {
"op": 2,
"d": {
"token": self.config.token,
"intents": self.config.intents,
"properties": {
"os": "nanobot",
"browser": "nanobot",
"device": "nanobot",
},
},
}
await self._ws.send(json.dumps(identify))
async def _start_heartbeat(self, interval_s: float) -> None:
"""Start or restart the heartbeat loop."""
if self._heartbeat_task:
self._heartbeat_task.cancel()
async def heartbeat_loop() -> None:
while self._running and self._ws:
payload = {"op": 1, "d": self._seq}
try: try:
await self._ws.send(json.dumps(payload)) intents = discord.Intents.none()
intents.value = self.config.intents
self._client = DiscordBotClient(self, intents=intents)
except Exception as e: except Exception as e:
logger.warning("Discord heartbeat failed: {}", e) logger.error("Failed to initialize Discord client: {}", e)
break self._client = None
await asyncio.sleep(interval_s) self._running = False
self._heartbeat_task = asyncio.create_task(heartbeat_loop())
async def _handle_message_create(self, payload: dict[str, Any]) -> None:
"""Handle incoming Discord messages."""
author = payload.get("author") or {}
if author.get("bot"):
return return
sender_id = str(author.get("id", "")) self._running = True
channel_id = str(payload.get("channel_id", "")) logger.info("Starting Discord client via discord.py...")
content = payload.get("content") or ""
guild_id = payload.get("guild_id")
if not sender_id or not channel_id:
return
if not self.is_allowed(sender_id):
return
# Check group channel policy (DMs always respond if is_allowed passes)
if guild_id is not None:
if not self._should_respond_in_group(payload, content):
return
content_parts = [content] if content else []
media_paths: list[str] = []
media_dir = get_media_dir("discord")
for attachment in payload.get("attachments") or []:
url = attachment.get("url")
filename = attachment.get("filename") or "attachment"
size = attachment.get("size") or 0
if not url or not self._http:
continue
if size and size > MAX_ATTACHMENT_BYTES:
content_parts.append(f"[attachment: {filename} - too large]")
continue
try: try:
media_dir.mkdir(parents=True, exist_ok=True) await self._client.start(self.config.token)
file_path = media_dir / f"{attachment.get('id', 'file')}_{filename.replace('/', '_')}" except asyncio.CancelledError:
resp = await self._http.get(url) raise
resp.raise_for_status()
file_path.write_bytes(resp.content)
media_paths.append(str(file_path))
content_parts.append(f"[attachment: {file_path}]")
except Exception as e: except Exception as e:
logger.warning("Failed to download Discord attachment: {}", e) logger.error("Discord client startup failed: {}", e)
content_parts.append(f"[attachment: {filename} - download failed]") finally:
self._running = False
await self._reset_runtime_state(close_client=True)
reply_to = (payload.get("referenced_message") or {}).get("id") async def stop(self) -> None:
"""Stop the Discord channel."""
self._running = False
await self._reset_runtime_state(close_client=True)
await self._start_typing(channel_id) async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Discord using discord.py."""
client = self._client
if client is None or not client.is_ready():
logger.warning("Discord client not ready; dropping outbound message")
return
is_progress = bool((msg.metadata or {}).get("_progress"))
try:
await client.send_outbound(msg)
except Exception as e:
logger.error("Error sending Discord message: {}", e)
finally:
if not is_progress:
await self._stop_typing(msg.chat_id)
await self._clear_reactions(msg.chat_id)
async def _handle_discord_message(self, message: discord.Message) -> None:
"""Handle incoming Discord messages from discord.py."""
if message.author.bot:
return
sender_id = str(message.author.id)
channel_id = self._channel_key(message.channel)
content = message.content or ""
if not self._should_accept_inbound(message, sender_id, content):
return
media_paths, attachment_markers = await self._download_attachments(message.attachments)
full_content = self._compose_inbound_content(content, attachment_markers)
metadata = self._build_inbound_metadata(message)
await self._start_typing(message.channel)
# Add read receipt reaction immediately, working emoji after delay
channel_id = self._channel_key(message.channel)
try:
await message.add_reaction(self.config.read_receipt_emoji)
self._pending_reactions[channel_id] = message
except Exception as e:
logger.debug("Failed to add read receipt reaction: {}", e)
# Delayed working indicator (cosmetic — not tied to subagent lifecycle)
async def _delayed_working_emoji() -> None:
await asyncio.sleep(self.config.working_emoji_delay)
try:
await message.add_reaction(self.config.working_emoji)
except Exception:
pass
self._working_emoji_tasks[channel_id] = asyncio.create_task(_delayed_working_emoji())
try:
await self._handle_message( await self._handle_message(
sender_id=sender_id, sender_id=sender_id,
chat_id=channel_id, chat_id=channel_id,
content="\n".join(p for p in content_parts if p) or "[empty message]", content=full_content,
media=media_paths, media=media_paths,
metadata={ metadata=metadata,
"message_id": str(payload.get("id", "")),
"guild_id": guild_id,
"reply_to": reply_to,
},
) )
except Exception:
await self._clear_reactions(channel_id)
await self._stop_typing(channel_id)
raise
def _should_respond_in_group(self, payload: dict[str, Any], content: str) -> bool: async def _on_message(self, message: discord.Message) -> None:
"""Check if bot should respond in a group channel based on policy.""" """Backward-compatible alias for legacy tests/callers."""
await self._handle_discord_message(message)
def _should_accept_inbound(
self,
message: discord.Message,
sender_id: str,
content: str,
) -> bool:
"""Check if inbound Discord message should be processed."""
if not self.is_allowed(sender_id):
return False
if message.guild is not None and not self._should_respond_in_group(message, content):
return False
return True
async def _download_attachments(
self,
attachments: list[discord.Attachment],
) -> tuple[list[str], list[str]]:
"""Download supported attachments and return paths + display markers."""
media_paths: list[str] = []
markers: list[str] = []
media_dir = get_media_dir("discord")
for attachment in attachments:
filename = attachment.filename or "attachment"
if attachment.size and attachment.size > MAX_ATTACHMENT_BYTES:
markers.append(f"[attachment: {filename} - too large]")
continue
try:
media_dir.mkdir(parents=True, exist_ok=True)
safe_name = safe_filename(filename)
file_path = media_dir / f"{attachment.id}_{safe_name}"
await attachment.save(file_path)
media_paths.append(str(file_path))
markers.append(f"[attachment: {file_path.name}]")
except Exception as e:
logger.warning("Failed to download Discord attachment: {}", e)
markers.append(f"[attachment: {filename} - download failed]")
return media_paths, markers
@staticmethod
def _compose_inbound_content(content: str, attachment_markers: list[str]) -> str:
"""Combine message text with attachment markers."""
content_parts = [content] if content else []
content_parts.extend(attachment_markers)
return "\n".join(part for part in content_parts if part) or "[empty message]"
@staticmethod
def _build_inbound_metadata(message: discord.Message) -> dict[str, str | None]:
"""Build metadata for inbound Discord messages."""
reply_to = str(message.reference.message_id) if message.reference and message.reference.message_id else None
return {
"message_id": str(message.id),
"guild_id": str(message.guild.id) if message.guild else None,
"reply_to": reply_to,
}
def _should_respond_in_group(self, message: discord.Message, content: str) -> bool:
"""Check if the bot should respond in a guild channel based on policy."""
if self.config.group_policy == "open": if self.config.group_policy == "open":
return True return True
if self.config.group_policy == "mention": if self.config.group_policy == "mention":
# Check if bot was mentioned in the message bot_user_id = self._bot_user_id
if self._bot_user_id: if bot_user_id is None:
# Check mentions array logger.debug("Discord message in {} ignored (bot identity unavailable)", message.channel.id)
mentions = payload.get("mentions") or [] return False
for mention in mentions:
if str(mention.get("id")) == self._bot_user_id: if any(str(user.id) == bot_user_id for user in message.mentions):
return True return True
# Also check content for mention format <@USER_ID> if f"<@{bot_user_id}>" in content or f"<@!{bot_user_id}>" in content:
if f"<@{self._bot_user_id}>" in content or f"<@!{self._bot_user_id}>" in content:
return True return True
logger.debug("Discord message in {} ignored (bot not mentioned)", payload.get("channel_id"))
logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id)
return False return False
return True return True
async def _start_typing(self, channel_id: str) -> None: async def _start_typing(self, channel: Messageable) -> None:
"""Start periodic typing indicator for a channel.""" """Start periodic typing indicator for a channel."""
channel_id = self._channel_key(channel)
await self._stop_typing(channel_id) await self._stop_typing(channel_id)
async def typing_loop() -> None: async def typing_loop() -> None:
url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing"
headers = {"Authorization": f"Bot {self.config.token}"}
while self._running: while self._running:
try: try:
await self._http.post(url, headers=headers) async with channel.typing():
await asyncio.sleep(TYPING_INTERVAL_S)
except asyncio.CancelledError: except asyncio.CancelledError:
return return
except Exception as e: except Exception as e:
logger.debug("Discord typing indicator failed for {}: {}", channel_id, e) logger.debug("Discord typing indicator failed for {}: {}", channel_id, e)
return return
await asyncio.sleep(8)
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
async def _stop_typing(self, channel_id: str) -> None: async def _stop_typing(self, channel_id: str) -> None:
"""Stop typing indicator for a channel.""" """Stop typing indicator for a channel."""
task = self._typing_tasks.pop(channel_id, None) task = self._typing_tasks.pop(self._channel_key(channel_id), None)
if task: if task is None:
return
task.cancel() task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _clear_reactions(self, chat_id: str) -> None:
"""Remove all pending reactions after bot replies."""
# Cancel delayed working emoji if it hasn't fired yet
task = self._working_emoji_tasks.pop(chat_id, None)
if task and not task.done():
task.cancel()
msg_obj = self._pending_reactions.pop(chat_id, None)
if msg_obj is None:
return
bot_user = self._client.user if self._client else None
for emoji in (self.config.read_receipt_emoji, self.config.working_emoji):
try:
await msg_obj.remove_reaction(emoji, bot_user)
except Exception:
pass
async def _cancel_all_typing(self) -> None:
"""Stop all typing tasks."""
channel_ids = list(self._typing_tasks)
for channel_id in channel_ids:
await self._stop_typing(channel_id)
async def _reset_runtime_state(self, close_client: bool) -> None:
"""Reset client and typing state."""
await self._cancel_all_typing()
if close_client and self._client is not None and not self._client.is_closed():
try:
await self._client.close()
except Exception as e:
logger.warning("Discord client close failed: {}", e)
self._client = None
self._bot_user_id = None
+43 -7
View File
@@ -417,7 +417,7 @@ class FeishuChannel(BaseChannel):
return True return True
return self._is_bot_mentioned(message) return self._is_bot_mentioned(message)
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: def _add_reaction_sync(self, message_id: str, emoji_type: str) -> str | None:
"""Sync helper for adding reaction (runs in thread pool).""" """Sync helper for adding reaction (runs in thread pool)."""
from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji
try: try:
@@ -433,22 +433,54 @@ class FeishuChannel(BaseChannel):
if not response.success(): if not response.success():
logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg)
return None
else: else:
logger.debug("Added {} reaction to message {}", emoji_type, message_id) logger.debug("Added {} reaction to message {}", emoji_type, message_id)
return response.data.reaction_id if response.data else None
except Exception as e: except Exception as e:
logger.warning("Error adding reaction: {}", e) logger.warning("Error adding reaction: {}", e)
return None
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
""" """
Add a reaction emoji to a message (non-blocking). Add a reaction emoji to a message (non-blocking).
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
""" """
if not self._client: if not self._client:
return None
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type)
def _remove_reaction_sync(self, message_id: str, reaction_id: str) -> None:
"""Sync helper for removing reaction (runs in thread pool)."""
from lark_oapi.api.im.v1 import DeleteMessageReactionRequest
try:
request = DeleteMessageReactionRequest.builder() \
.message_id(message_id) \
.reaction_id(reaction_id) \
.build()
response = self._client.im.v1.message_reaction.delete(request)
if response.success():
logger.debug("Removed reaction {} from message {}", reaction_id, message_id)
else:
logger.debug("Failed to remove reaction: code={}, msg={}", response.code, response.msg)
except Exception as e:
logger.debug("Error removing reaction: {}", e)
async def _remove_reaction(self, message_id: str, reaction_id: str) -> None:
"""
Remove a reaction emoji from a message (non-blocking).
Used to clear the "processing" indicator after bot replies.
"""
if not self._client or not reaction_id:
return return
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type) await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
# Regex to match markdown tables (header + separator + data rows) # Regex to match markdown tables (header + separator + data rows)
_TABLE_RE = re.compile( _TABLE_RE = re.compile(
@@ -783,9 +815,9 @@ class FeishuChannel(BaseChannel):
"""Download a file/audio/media from a Feishu message by message_id and file_key.""" """Download a file/audio/media from a Feishu message by message_id and file_key."""
from lark_oapi.api.im.v1 import GetMessageResourceRequest from lark_oapi.api.im.v1 import GetMessageResourceRequest
# Feishu API only accepts 'image' or 'file' as type parameter # Feishu resource download API only accepts 'image' or 'file' as type.
# Convert 'audio' to 'file' for API compatibility # Both 'audio' and 'media' (video) messages use type='file' for download.
if resource_type == "audio": if resource_type in ("audio", "media"):
resource_type = "file" resource_type = "file"
try: try:
@@ -1046,6 +1078,9 @@ class FeishuChannel(BaseChannel):
# --- stream end: final update or fallback --- # --- stream end: final update or fallback ---
if meta.get("_stream_end"): if meta.get("_stream_end"):
if (message_id := meta.get("message_id")) and (reaction_id := meta.get("reaction_id")):
await self._remove_reaction(message_id, reaction_id)
buf = self._stream_bufs.pop(chat_id, None) buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.text: if not buf or not buf.text:
return return
@@ -1227,7 +1262,7 @@ class FeishuChannel(BaseChannel):
return return
# Add reaction # Add reaction
await self._add_reaction(message_id, self.config.react_emoji) reaction_id = await self._add_reaction(message_id, self.config.react_emoji)
# Parse content # Parse content
content_parts = [] content_parts = []
@@ -1305,6 +1340,7 @@ class FeishuChannel(BaseChannel):
media=media_paths, media=media_paths,
metadata={ metadata={
"message_id": message_id, "message_id": message_id,
"reaction_id": reaction_id,
"chat_type": chat_type, "chat_type": chat_type,
"msg_type": msg_type, "msg_type": msg_type,
"parent_id": parent_id, "parent_id": parent_id,
+941
View File
@@ -0,0 +1,941 @@
"""iMessage channel using local macOS database or Photon advanced-imessage-http-proxy."""
from __future__ import annotations
import asyncio
import base64
import mimetypes
import platform
import sqlite3
import subprocess
from collections import OrderedDict
from pathlib import Path
from typing import Any, Literal
import httpx
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.utils.helpers import split_message
_DEFAULT_DB_PATH = str(Path.home() / "Library" / "Messages" / "chat.db")
_DEFAULT_POLL_INTERVAL = 2.0
_AUDIO_EXTENSIONS = frozenset({".m4a", ".mp3", ".wav", ".aac", ".ogg", ".caf", ".opus"})
_MAX_MESSAGE_LEN = 6000
def _split_paragraphs(text: str) -> list[str]:
"""Split text on ``\\n\\n`` boundaries, then apply length limits to each chunk."""
parts: list[str] = []
for para in text.split("\n\n"):
stripped = para.strip()
if stripped:
parts.extend(split_message(stripped, _MAX_MESSAGE_LEN))
return parts or [text]
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
class IMessageConfig(Base):
"""iMessage channel configuration."""
enabled: bool = False
local: bool = True
server_url: str = ""
api_key: str = ""
proxy: str | None = None
poll_interval: float = _DEFAULT_POLL_INTERVAL
database_path: str = _DEFAULT_DB_PATH
allow_from: list[str] = Field(default_factory=list)
group_policy: Literal["open", "ignore"] = "open"
reply_to_message: bool = False
react_tapback: str = "love"
done_tapback: str = ""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_PHOTON_PROXY_URL = "https://imessage-swagger.photon.codes"
_PHOTON_KIT_PATTERN = ".imsgd.photon.codes"
def _is_photon_kit_url(url: str) -> bool:
"""Return True when *url* points to a Photon iMessage Kit server (upstream)."""
from urllib.parse import urlparse
return _PHOTON_KIT_PATTERN in (urlparse(url).hostname or "")
def _make_bearer_token(server_url: str, api_key: str) -> str:
"""Build the Bearer token expected by advanced-imessage-http-proxy.
If the key already decodes to a ``url|key`` pair it is used as-is.
"""
try:
decoded = base64.b64decode(api_key, validate=True).decode()
if "|" in decoded:
return api_key
except Exception as e:
logger.debug("API key not pre-encoded, will encode: {}", type(e).__name__)
raw = f"{server_url}|{api_key}"
return base64.b64encode(raw.encode()).decode()
def _resolve_proxy_url(server_url: str) -> str:
"""Return the actual HTTP proxy base URL to use for API calls.
When the user provides a Photon iMessage Kit server URL (e.g.
``https://xxxxx.imsgd.photon.codes``), requests must go through
the shared ``advanced-imessage-http-proxy`` at a fixed endpoint.
The original Kit URL is only used inside the Bearer token.
"""
if _is_photon_kit_url(server_url):
logger.info(
"Photon Kit URL detected — routing through proxy at {} "
"(hosted by Photon, the same provider as your iMessage Kit server).",
_PHOTON_PROXY_URL,
)
return _PHOTON_PROXY_URL
return server_url
def _extract_address(chat_id: str) -> str:
"""Convert a chatGuid to the proxy's address format.
``iMessage;-;+1234567890`` → ``+1234567890``
``iMessage;+;chat123`` → ``group:chat123``
``+1234567890`` → ``+1234567890`` (passthrough)
"""
if ";-;" in chat_id:
return chat_id.split(";-;", 1)[1]
if ";+;" in chat_id:
return "group:" + chat_id.split(";+;", 1)[1]
return chat_id
# ---------------------------------------------------------------------------
# Channel
# ---------------------------------------------------------------------------
class IMessageChannel(BaseChannel):
"""iMessage channel with local (macOS) and remote (Photon) modes.
Local mode reads from the native iMessage SQLite database and sends
via AppleScript — pure Python, no external dependencies.
Remote mode talks to a Photon ``advanced-imessage-http-proxy`` server.
See https://github.com/photon-hq/advanced-imessage-http-proxy for the
full API reference.
"""
name = "imessage"
display_name = "iMessage"
@classmethod
def default_config(cls) -> dict[str, Any]:
return IMessageConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = IMessageConfig.model_validate(config)
super().__init__(config, bus)
self.config: IMessageConfig = config
self._processed_ids: OrderedDict[str, None] = OrderedDict()
self._http: httpx.AsyncClient | None = None
self._last_rowid: int = 0
# ---- lifecycle ---------------------------------------------------------
async def start(self) -> None:
if self.config.local:
await self._start_local()
else:
await self._start_remote()
async def stop(self) -> None:
self._running = False
if self._http:
await self._http.aclose()
self._http = None
async def send(self, msg: OutboundMessage) -> None:
if self.config.local:
await self._send_local(msg)
else:
await self._send_remote(msg)
# ======================================================================
# LOCAL MODE (macOS — sqlite3 + AppleScript)
# ======================================================================
async def _start_local(self) -> None:
if platform.system() != "Darwin":
logger.error("iMessage local mode requires macOS")
return
db_path = self.config.database_path
if not Path(db_path).exists():
logger.error(
"iMessage database not found at {}. "
"Ensure Full Disk Access is granted to your terminal.",
db_path,
)
return
self._running = True
self._last_rowid = self._get_max_rowid(db_path)
logger.info("iMessage local watcher started (polling {})", db_path)
while self._running:
try:
await self._poll_local_db(db_path)
except Exception as e:
logger.warning("iMessage local poll error: {}", e)
await asyncio.sleep(max(0.5, self.config.poll_interval))
def _get_max_rowid(self, db_path: str) -> int:
try:
with sqlite3.connect(db_path, uri=True) as conn:
cur = conn.execute("SELECT MAX(ROWID) FROM message")
row = cur.fetchone()
return row[0] or 0
except Exception:
return 0
async def _poll_local_db(self, db_path: str) -> None:
loop = asyncio.get_running_loop()
rows = await loop.run_in_executor(None, self._fetch_new_messages, db_path)
for row in rows:
await self._handle_local_message(row)
self._last_rowid = max(self._last_rowid, int(row["ROWID"]))
def _fetch_new_messages(self, db_path: str) -> list[dict[str, Any]]:
for attempt in range(3):
try:
return self._query_new_messages(db_path)
except sqlite3.OperationalError as e:
if "locked" in str(e) and attempt < 2:
import time
time.sleep(0.5 * (attempt + 1))
continue
raise
return []
def _query_new_messages(self, db_path: str) -> list[dict[str, Any]]:
with sqlite3.connect(db_path, uri=True, timeout=10) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"""
SELECT
m.ROWID,
m.guid,
m.text,
m.is_from_me,
m.date AS msg_date,
m.service,
h.id AS sender,
c.chat_identifier,
c.style AS chat_style,
a.ROWID AS att_rowid,
a.filename AS att_filename,
a.mime_type AS att_mime,
a.transfer_name AS att_transfer_name
FROM message m
LEFT JOIN handle h ON m.handle_id = h.ROWID
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN chat c ON cmj.chat_id = c.ROWID
LEFT JOIN message_attachment_join maj ON m.ROWID = maj.message_id
LEFT JOIN attachment a ON maj.attachment_id = a.ROWID
WHERE m.ROWID > ?
ORDER BY m.ROWID ASC
""",
(self._last_rowid,),
)
msg_map: dict[int, dict[str, Any]] = {}
for row in cur:
d = dict(row)
rowid = d["ROWID"]
if rowid not in msg_map:
msg_map[rowid] = {**d, "attachments": []}
if d.get("att_rowid"):
raw_path = d.get("att_filename") or ""
resolved = (
raw_path.replace("~", str(Path.home()), 1)
if raw_path.startswith("~")
else raw_path
)
msg_map[rowid]["attachments"].append(
{
"filename": resolved,
"mime_type": d.get("att_mime") or "",
"transfer_name": d.get("att_transfer_name") or "",
}
)
return list(msg_map.values())
async def _handle_local_message(self, row: dict[str, Any]) -> None:
if row.get("is_from_me"):
return
message_id = row.get("guid", "")
if self._is_seen(message_id):
return
sender = row.get("sender") or ""
chat_id = row.get("chat_identifier") or sender
content = row.get("text") or ""
is_group = (row.get("chat_style") or 0) == 43
if is_group and self.config.group_policy == "ignore":
return
media_paths: list[str] = []
for att in row.get("attachments") or []:
file_path = att.get("filename", "")
if not file_path or not Path(file_path).exists():
continue
mime = att.get("mime_type") or ""
ext = Path(file_path).suffix.lower()
if ext in _AUDIO_EXTENSIONS or mime.startswith("audio/"):
transcription = await self.transcribe_audio(file_path)
if transcription:
voice_tag = f"[Voice Message: {transcription}]"
content = f"{content}\n{voice_tag}" if content else voice_tag
continue
media_paths.append(file_path)
tag = "image" if mime.startswith("image/") else "file"
media_tag = f"[{tag}: {file_path}]"
content = f"{content}\n{media_tag}" if content else media_tag
await self._handle_message(
sender_id=sender,
chat_id=chat_id,
content=content,
media=media_paths,
metadata={
"message_id": message_id,
"service": row.get("service", "iMessage"),
"is_group": is_group,
"source": "local",
},
)
self._mark_seen(message_id)
async def _send_local(self, msg: OutboundMessage) -> None:
if (msg.metadata or {}).get("_progress"):
return
recipient = msg.chat_id
if msg.content:
for chunk in _split_paragraphs(msg.content):
await self._applescript_send_text(recipient, chunk)
for media_path in msg.media or []:
await self._applescript_send_file(recipient, media_path)
@staticmethod
def _escape_applescript(s: str) -> str:
return (
s.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
async def _applescript_send_text(self, recipient: str, text: str) -> None:
escaped_recipient = self._escape_applescript(recipient)
escaped_text = self._escape_applescript(text)
script = (
f'tell application "Messages"\n'
f" set targetService to 1st account whose service type = iMessage\n"
f' set targetBuddy to participant "{escaped_recipient}" of targetService\n'
f' send "{escaped_text}" to targetBuddy\n'
f"end tell"
)
await self._run_osascript(script)
async def _applescript_send_file(self, recipient: str, file_path: str) -> None:
escaped_recipient = self._escape_applescript(recipient)
escaped_path = self._escape_applescript(file_path)
script = (
f'tell application "Messages"\n'
f" set targetService to 1st account whose service type = iMessage\n"
f' set targetBuddy to participant "{escaped_recipient}" of targetService\n'
f' send POSIX file "{escaped_path}" to targetBuddy\n'
f"end tell"
)
await self._run_osascript(script)
async def _run_osascript(self, script: str) -> None:
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
None,
lambda: subprocess.run(
["osascript", "-e", script],
check=True,
capture_output=True,
timeout=15,
),
)
except subprocess.CalledProcessError as e:
logger.error(
"AppleScript send failed: {}", e.stderr.decode()[:200] if e.stderr else str(e)
)
raise
except subprocess.TimeoutExpired:
logger.error("AppleScript send timed out")
raise
# ======================================================================
# REMOTE MODE (advanced-imessage-http-proxy)
# https://github.com/photon-hq/advanced-imessage-http-proxy
# ======================================================================
def _build_http_client(self) -> httpx.AsyncClient:
token = _make_bearer_token(self.config.server_url, self.config.api_key)
base_url = _resolve_proxy_url(self.config.server_url)
return httpx.AsyncClient(
base_url=base_url.rstrip("/"),
headers={"Authorization": f"Bearer {token}"},
proxy=self.config.proxy or None,
timeout=30.0,
)
async def _start_remote(self) -> None:
if not self.config.server_url:
logger.error("iMessage remote mode requires serverUrl")
return
if not self.config.api_key:
logger.error("iMessage remote mode requires apiKey")
return
self._running = True
self._http = self._build_http_client()
if not await self._api_health():
logger.error("iMessage server health check failed — will retry in poll loop")
await self._seed_existing_message_ids()
poll_interval = max(0.5, self.config.poll_interval)
logger.info(
"iMessage remote polling started ({}s interval, proxy={})",
poll_interval,
self.config.proxy or "none",
)
while self._running:
try:
await self._poll_remote()
except Exception as e:
logger.warning("iMessage remote poll error: {}", e)
await asyncio.sleep(poll_interval)
async def _seed_existing_message_ids(self) -> None:
"""Mark all existing messages as seen so we only process new ones after startup."""
try:
resp = await self._api_get_messages(limit=100)
if resp and isinstance(resp, list):
for msg in resp:
msg_id = msg.get("id") or msg.get("guid", "")
if msg_id:
self._mark_seen(msg_id)
logger.info("Seeded {} existing message IDs", len(self._processed_ids))
except Exception as e:
logger.debug("Could not seed existing message IDs: {}", e)
# ---- inbound -----------------------------------------------------------
async def _poll_remote(self) -> None:
if not self._http:
return
messages = await self._api_get_messages(limit=50)
if not messages:
return
for msg in reversed(messages):
await self._handle_remote_message(msg)
async def _handle_remote_message(self, data: dict[str, Any]) -> None:
sender_raw = data.get("from") or ""
if sender_raw == "me" or data.get("isFromMe"):
return
message_id = data.get("id") or data.get("guid", "")
if self._is_seen(message_id):
return
self._mark_seen(message_id)
sender = sender_raw
if not sender:
handle = data.get("handle")
if isinstance(handle, dict):
sender = handle.get("address", "")
address = data.get("chat") or sender
if not address:
chats = data.get("chats") or []
chat_guid = chats[0].get("guid", "") if chats else ""
address = _extract_address(chat_guid) if chat_guid else sender
content = data.get("text") or ""
is_group = address.startswith("group:") or (";+;" in address)
if is_group and self.config.group_policy == "ignore":
return
await self._api_react(address, message_id, self.config.react_tapback)
await self._api_mark_read(address)
media_paths: list[str] = []
for att in data.get("attachments") or []:
att_guid = att.get("guid", "")
name = att.get("transferName") or att.get("filename") or ""
if not att_guid or not self._http:
continue
local_path = await self._api_download_attachment(att_guid, name)
if not local_path:
continue
mime, _ = mimetypes.guess_type(local_path)
ext = Path(local_path).suffix.lower()
if ext in _AUDIO_EXTENSIONS or (mime and mime.startswith("audio/")):
transcription = await self.transcribe_audio(local_path)
if transcription:
voice_tag = f"[Voice Message: {transcription}]"
content = f"{content}\n{voice_tag}" if content else voice_tag
continue
media_paths.append(local_path)
tag = "image" if mime and mime.startswith("image/") else "file"
media_tag = f"[{tag}: {local_path}]"
content = f"{content}\n{media_tag}" if content else media_tag
await self._handle_message(
sender_id=sender,
chat_id=address,
content=content,
media=media_paths,
metadata={
"message_id": message_id,
"is_group": is_group,
"source": "remote",
"timestamp": data.get("sentAt") or data.get("dateCreated"),
},
)
# ---- outbound ----------------------------------------------------------
async def _send_remote(self, msg: OutboundMessage) -> None:
if not self._http:
raise RuntimeError("iMessage remote HTTP client not initialised")
meta = msg.metadata or {}
if meta.get("_progress"):
return
to = msg.chat_id
await self._api_start_typing(to)
try:
if msg.content:
chunks = _split_paragraphs(msg.content)
for i, chunk in enumerate(chunks):
body: dict[str, Any] = {"to": to, "text": chunk, "service": "iMessage"}
if i == 0 and self.config.reply_to_message and msg.reply_to:
body["replyTo"] = msg.reply_to
if await self._api_send(body) is None:
raise RuntimeError(f"iMessage text delivery failed for {to}")
for media_path in msg.media or []:
if await self._api_send_file(to, media_path) is None:
raise RuntimeError(f"iMessage media delivery failed: {media_path}")
finally:
await self._api_stop_typing(to)
message_id = meta.get("message_id")
if message_id and self.config.react_tapback:
await self._api_remove_react(to, message_id, self.config.react_tapback)
if self.config.done_tapback:
await self._api_react(to, message_id, self.config.done_tapback)
# ======================================================================
# PROXY API METHODS
# https://github.com/photon-hq/advanced-imessage-http-proxy
# ======================================================================
# ---- messaging ---------------------------------------------------------
async def _api_send(self, body: dict[str, Any]) -> dict[str, Any] | None:
"""``POST /send`` — send text message with optional effect / reply."""
return await self._post("/send", body)
async def _api_send_file(
self, to: str, file_path: str, audio: bool | None = None
) -> dict[str, Any] | None:
"""``POST /send/file`` — send attachment (image, file, audio message)."""
if not self._http:
return None
if not Path(file_path).exists():
logger.warning("iMessage attachment not found: {}", file_path)
return None
mime, _ = mimetypes.guess_type(file_path)
ext = Path(file_path).suffix.lower()
is_audio = (
audio
if audio is not None
else (ext in _AUDIO_EXTENSIONS or (mime or "").startswith("audio/"))
)
data: dict[str, str] = {"to": to}
if is_audio:
data["audio"] = "true"
with open(file_path, "rb") as f:
resp = await self._http.post(
"/send/file",
data=data,
files={"file": (Path(file_path).name, f, mime or "application/octet-stream")},
)
return self._unwrap(resp)
async def _api_send_sticker(
self,
to: str,
file_path: str,
reply_to: str | None = None,
**kwargs: Any,
) -> dict[str, Any] | None:
"""``POST /send/sticker`` — send standalone or reply sticker."""
if not self._http:
return None
data: dict[str, str] = {"to": to}
if reply_to:
data["replyTo"] = reply_to
for k in ("stickerX", "stickerY", "stickerScale", "stickerRotation", "stickerWidth"):
if k in kwargs:
data[k] = str(kwargs[k])
with open(file_path, "rb") as f:
resp = await self._http.post(
"/send/sticker",
data=data,
files={"file": (Path(file_path).name, f, "image/png")},
)
return self._unwrap(resp)
async def _api_unsend(self, message_id: str) -> dict[str, Any] | None:
"""``DELETE /messages/:id`` — retract a sent message."""
return await self._delete(f"/messages/{message_id}")
# ---- reactions ---------------------------------------------------------
async def _api_react(self, chat: str, message_id: str, tapback: str) -> None:
"""``POST /messages/:id/react`` — add tapback (best-effort)."""
if not self._http or not tapback:
return
try:
await self._http.post(
f"/messages/{message_id}/react",
json={"chat": chat, "type": tapback},
)
except Exception as e:
logger.debug("iMessage tapback failed: {}", e)
async def _api_remove_react(self, chat: str, message_id: str, tapback: str) -> None:
"""``DELETE /messages/:id/react`` — remove tapback (best-effort)."""
if not self._http or not tapback:
return
try:
await self._http.request(
"DELETE",
f"/messages/{message_id}/react",
json={"chat": chat, "type": tapback},
)
except Exception as e:
logger.debug("iMessage remove tapback failed: {}", e)
# ---- messages ----------------------------------------------------------
async def _api_get_messages(
self,
limit: int = 50,
chat: str | None = None,
) -> list[dict[str, Any]]:
"""``GET /messages`` — query messages."""
params: dict[str, Any] = {"limit": limit}
if chat:
params["chat"] = chat
data = await self._get("/messages", params=params)
return data if isinstance(data, list) else []
async def _api_search_messages(
self, query: str, chat: str | None = None
) -> list[dict[str, Any]]:
"""``GET /messages/search`` — search messages by text."""
params: dict[str, Any] = {"q": query}
if chat:
params["chat"] = chat
data = await self._get("/messages/search", params=params)
return data if isinstance(data, list) else []
async def _api_get_message(self, message_id: str) -> dict[str, Any] | None:
"""``GET /messages/:id`` — get single message details."""
data = await self._get(f"/messages/{message_id}")
return data if isinstance(data, dict) else None
# ---- chats -------------------------------------------------------------
async def _api_get_chats(self) -> list[dict[str, Any]]:
"""``GET /chats`` — list all conversations."""
data = await self._get("/chats")
return data if isinstance(data, list) else []
async def _api_get_chat(self, address: str) -> dict[str, Any] | None:
"""``GET /chats/:id`` — get chat details."""
data = await self._get(f"/chats/{address}")
return data if isinstance(data, dict) else None
async def _api_get_chat_messages(
self,
address: str,
limit: int = 50,
) -> list[dict[str, Any]]:
"""``GET /chats/:id/messages`` — get message history for a chat."""
data = await self._get(f"/chats/{address}/messages", params={"limit": limit})
return data if isinstance(data, list) else []
async def _api_get_chat_participants(self, address: str) -> list[dict[str, Any]]:
"""``GET /chats/:id/participants`` — get group participants."""
data = await self._get(f"/chats/{address}/participants")
return data if isinstance(data, list) else []
async def _api_mark_read(self, address: str) -> None:
"""``POST /chats/:id/read`` — clear unread badge."""
if not self._http:
return
try:
await self._http.post(f"/chats/{address}/read")
except Exception as e:
logger.debug("iMessage mark-read failed: {}", e)
async def _api_start_typing(self, address: str) -> None:
"""``POST /chats/:id/typing`` — show typing indicator."""
if not self._http:
return
try:
await self._http.post(f"/chats/{address}/typing")
except Exception as e:
logger.debug("iMessage typing start failed: {}", e)
async def _api_stop_typing(self, address: str) -> None:
"""``DELETE /chats/:id/typing`` — stop typing indicator."""
if not self._http:
return
try:
await self._http.request("DELETE", f"/chats/{address}/typing")
except Exception as e:
logger.debug("iMessage typing stop failed: {}", e)
# ---- groups ------------------------------------------------------------
async def _api_create_group(
self, members: list[str], name: str | None = None
) -> dict[str, Any] | None:
"""``POST /groups`` — create a group chat."""
body: dict[str, Any] = {"members": members}
if name:
body["name"] = name
return await self._post("/groups", body)
async def _api_update_group(self, group_id: str, name: str) -> dict[str, Any] | None:
"""``PATCH /groups/:id`` — rename a group."""
if not self._http:
return None
resp = await self._http.patch(f"/groups/{group_id}", json={"name": name})
return self._unwrap(resp)
# ---- polls -------------------------------------------------------------
async def _api_create_poll(
self,
to: str,
question: str,
options: list[str],
) -> dict[str, Any] | None:
"""``POST /polls`` — create an interactive poll."""
return await self._post("/polls", {"to": to, "question": question, "options": options})
async def _api_get_poll(self, poll_id: str) -> dict[str, Any] | None:
"""``GET /polls/:id`` — get poll details."""
data = await self._get(f"/polls/{poll_id}")
return data if isinstance(data, dict) else None
async def _api_vote_poll(
self, poll_id: str, chat: str, option_id: str
) -> dict[str, Any] | None:
"""``POST /polls/:id/vote`` — vote on a poll option."""
return await self._post(f"/polls/{poll_id}/vote", {"chat": chat, "optionId": option_id})
async def _api_unvote_poll(
self, poll_id: str, chat: str, option_id: str
) -> dict[str, Any] | None:
"""``POST /polls/:id/unvote`` — remove vote from poll."""
return await self._post(f"/polls/{poll_id}/unvote", {"chat": chat, "optionId": option_id})
async def _api_add_poll_option(
self, poll_id: str, chat: str, text: str
) -> dict[str, Any] | None:
"""``POST /polls/:id/options`` — add option to existing poll."""
return await self._post(f"/polls/{poll_id}/options", {"chat": chat, "text": text})
# ---- attachments -------------------------------------------------------
async def _api_download_attachment(self, att_guid: str, filename: str) -> str | None:
"""``GET /attachments/:id`` — download to local media dir."""
if not self._http:
return None
try:
resp = await self._http.get(f"/attachments/{att_guid}")
if not resp.is_success:
return None
media_dir = get_media_dir("imessage")
sanitized_guid = att_guid.replace("/", "_").replace("\\", "_").replace("\x00", "")
safe_name = Path(filename).name.replace("\x00", "") if filename else ""
if not safe_name:
safe_name = f"{sanitized_guid}.bin"
dest = (media_dir / safe_name).resolve()
if not dest.is_relative_to(media_dir.resolve()):
dest = (media_dir / f"{sanitized_guid}.bin").resolve()
dest.write_bytes(resp.content)
return str(dest)
except Exception as e:
logger.warning("Failed to download iMessage attachment {}: {}", att_guid, e)
return None
async def _api_attachment_info(self, att_guid: str) -> dict[str, Any] | None:
"""``GET /attachments/:id/info`` — get attachment metadata."""
data = await self._get(f"/attachments/{att_guid}/info")
return data if isinstance(data, dict) else None
# ---- contacts & handles ------------------------------------------------
async def _api_check_imessage(self, address: str) -> bool:
"""``GET /check/:address`` — check if address uses iMessage."""
data = await self._get(f"/check/{address}")
if isinstance(data, dict):
return bool(data.get("available") or data.get("imessage"))
return False
async def _api_get_contacts(self) -> list[dict[str, Any]]:
"""``GET /contacts`` — list device contacts."""
data = await self._get("/contacts")
return data if isinstance(data, list) else []
async def _api_get_handles(self) -> list[dict[str, Any]]:
"""``GET /handles`` — list known handles."""
data = await self._get("/handles")
return data if isinstance(data, list) else []
# ---- server ------------------------------------------------------------
async def _api_server_info(self) -> dict[str, Any] | None:
"""``GET /server`` — get server info."""
data = await self._get("/server")
return data if isinstance(data, dict) else None
async def _api_health(self) -> bool:
"""``GET /health`` — basic health check."""
if not self._http:
return False
try:
resp = await self._http.get("/health")
if resp.is_success:
logger.info("iMessage server health check passed")
return True
logger.warning("iMessage health check HTTP {}", resp.status_code)
except Exception as e:
logger.warning("iMessage health check failed: {}", e)
return False
# ---- HTTP helpers ------------------------------------------------------
async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
if not self._http:
return None
try:
resp = await self._http.get(path, params=params)
return self._unwrap(resp)
except Exception as e:
logger.warning("iMessage GET {} failed: {}", path, e)
return None
async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any] | None:
if not self._http:
return None
try:
resp = await self._http.post(path, json=body)
if not resp.is_success:
logger.warning(
"iMessage POST {} HTTP {}: {}", path, resp.status_code, resp.text[:200]
)
return self._unwrap(resp)
except Exception as e:
logger.warning("iMessage POST {} failed: {}", path, e)
raise
async def _delete(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any] | None:
if not self._http:
return None
try:
resp = await self._http.request("DELETE", path, json=body)
return self._unwrap(resp)
except Exception as e:
logger.warning("iMessage DELETE {} failed: {}", path, e)
return None
@staticmethod
def _unwrap(resp: httpx.Response) -> Any:
"""Unwrap the proxy's ``{"ok": true, "data": ...}`` envelope."""
if not resp.is_success:
return None
try:
body = resp.json()
except Exception:
logger.debug("iMessage server returned non-JSON response")
return None
if isinstance(body, dict) and "data" in body:
return body["data"]
return body
# ---- dedup helper ------------------------------------------------------
def _is_seen(self, message_id: str) -> bool:
if not message_id:
return False
return message_id in self._processed_ids
def _mark_seen(self, message_id: str) -> None:
if not message_id:
return
self._processed_ids[message_id] = None
while len(self._processed_ids) > 1000:
self._processed_ids.popitem(last=False)
+98 -7
View File
@@ -3,6 +3,8 @@
import asyncio import asyncio
import logging import logging
import mimetypes import mimetypes
import time
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal, TypeAlias from typing import Any, Literal, TypeAlias
@@ -28,7 +30,7 @@ try:
RoomSendError, RoomSendError,
RoomTypingError, RoomTypingError,
SyncError, SyncError,
UploadError, UploadError, RoomSendResponse,
) )
from nio.crypto.attachments import decrypt_attachment from nio.crypto.attachments import decrypt_attachment
from nio.exceptions import EncryptionError from nio.exceptions import EncryptionError
@@ -97,6 +99,22 @@ MATRIX_HTML_CLEANER = nh3.Cleaner(
link_rel="noopener noreferrer", link_rel="noopener noreferrer",
) )
@dataclass
class _StreamBuf:
"""
Represents a buffer for managing LLM response stream data.
:ivar text: Stores the text content of the buffer.
:type text: str
:ivar event_id: Identifier for the associated event. None indicates no
specific event association.
:type event_id: str | None
:ivar last_edit: Timestamp of the most recent edit to the buffer.
:type last_edit: float
"""
text: str = ""
event_id: str | None = None
last_edit: float = 0.0
def _render_markdown_html(text: str) -> str | None: def _render_markdown_html(text: str) -> str | None:
"""Render markdown to sanitized HTML; returns None for plain text.""" """Render markdown to sanitized HTML; returns None for plain text."""
@@ -114,12 +132,36 @@ def _render_markdown_html(text: str) -> str | None:
return formatted return formatted
def _build_matrix_text_content(text: str) -> dict[str, object]: def _build_matrix_text_content(text: str, event_id: str | None = None) -> dict[str, object]:
"""Build Matrix m.text payload with optional HTML formatted_body.""" """
Constructs and returns a dictionary representing the matrix text content with optional
HTML formatting and reference to an existing event for replacement. This function is
primarily used to create content payloads compatible with the Matrix messaging protocol.
:param text: The plain text content to include in the message.
:type text: str
:param event_id: Optional ID of the event to replace. If provided, the function will
include information indicating that the message is a replacement of the specified
event.
:type event_id: str | None
:return: A dictionary containing the matrix text content, potentially enriched with
HTML formatting and replacement metadata if applicable.
:rtype: dict[str, object]
"""
content: dict[str, object] = {"msgtype": "m.text", "body": text, "m.mentions": {}} content: dict[str, object] = {"msgtype": "m.text", "body": text, "m.mentions": {}}
if html := _render_markdown_html(text): if html := _render_markdown_html(text):
content["format"] = MATRIX_HTML_FORMAT content["format"] = MATRIX_HTML_FORMAT
content["formatted_body"] = html content["formatted_body"] = html
if event_id:
content["m.new_content"] = {
"body": text,
"msgtype": "m.text"
}
content["m.relates_to"] = {
"rel_type": "m.replace",
"event_id": event_id
}
return content return content
@@ -159,7 +201,8 @@ class MatrixConfig(Base):
allow_from: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list)
group_policy: Literal["open", "mention", "allowlist"] = "open" group_policy: Literal["open", "mention", "allowlist"] = "open"
group_allow_from: list[str] = Field(default_factory=list) group_allow_from: list[str] = Field(default_factory=list)
allow_room_mentions: bool = False allow_room_mentions: bool = False,
streaming: bool = False
class MatrixChannel(BaseChannel): class MatrixChannel(BaseChannel):
@@ -167,6 +210,8 @@ class MatrixChannel(BaseChannel):
name = "matrix" name = "matrix"
display_name = "Matrix" display_name = "Matrix"
_STREAM_EDIT_INTERVAL = 2 # min seconds between edit_message_text calls
monotonic_time = time.monotonic
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
@@ -192,6 +237,8 @@ class MatrixChannel(BaseChannel):
) )
self._server_upload_limit_bytes: int | None = None self._server_upload_limit_bytes: int | None = None
self._server_upload_limit_checked = False self._server_upload_limit_checked = False
self._stream_bufs: dict[str, _StreamBuf] = {}
async def start(self) -> None: async def start(self) -> None:
"""Start Matrix client and begin sync loop.""" """Start Matrix client and begin sync loop."""
@@ -297,14 +344,17 @@ class MatrixChannel(BaseChannel):
room = getattr(self.client, "rooms", {}).get(room_id) room = getattr(self.client, "rooms", {}).get(room_id)
return bool(getattr(room, "encrypted", False)) return bool(getattr(room, "encrypted", False))
async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: async def _send_room_content(self, room_id: str,
content: dict[str, Any]) -> None | RoomSendResponse | RoomSendError:
"""Send m.room.message with E2EE options.""" """Send m.room.message with E2EE options."""
if not self.client: if not self.client:
return return None
kwargs: dict[str, Any] = {"room_id": room_id, "message_type": "m.room.message", "content": content} kwargs: dict[str, Any] = {"room_id": room_id, "message_type": "m.room.message", "content": content}
if self.config.e2ee_enabled: if self.config.e2ee_enabled:
kwargs["ignore_unverified_devices"] = True kwargs["ignore_unverified_devices"] = True
await self.client.room_send(**kwargs) response = await self.client.room_send(**kwargs)
return response
async def _resolve_server_upload_limit_bytes(self) -> int | None: async def _resolve_server_upload_limit_bytes(self) -> int | None:
"""Query homeserver upload limit once per channel lifecycle.""" """Query homeserver upload limit once per channel lifecycle."""
@@ -414,6 +464,47 @@ class MatrixChannel(BaseChannel):
if not is_progress: if not is_progress:
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
relates_to = self._build_thread_relates_to(metadata)
if meta.get("_stream_end"):
buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.event_id or not buf.text:
return
await self._stop_typing_keepalive(chat_id, clear_typing=True)
content = _build_matrix_text_content(buf.text, buf.event_id)
if relates_to:
content["m.relates_to"] = relates_to
await self._send_room_content(chat_id, content)
return
buf = self._stream_bufs.get(chat_id)
if buf is None:
buf = _StreamBuf()
self._stream_bufs[chat_id] = buf
buf.text += delta
if not buf.text.strip():
return
now = self.monotonic_time()
if not buf.last_edit or (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL:
try:
content = _build_matrix_text_content(buf.text, buf.event_id)
response = await self._send_room_content(chat_id, content)
buf.last_edit = now
if not buf.event_id:
# we are editing the same message all the time, so only the first time the event id needs to be set
buf.event_id = response.event_id
except Exception:
await self._stop_typing_keepalive(metadata["room_id"], clear_typing=True)
pass
def _register_event_callbacks(self) -> None: def _register_event_callbacks(self) -> None:
self.client.add_event_callback(self._on_message, RoomMessageText) self.client.add_event_callback(self._on_message, RoomMessageText)
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER) self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
+12
View File
@@ -134,6 +134,7 @@ class QQConfig(Base):
secret: str = "" secret: str = ""
allow_from: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list)
msg_format: Literal["plain", "markdown"] = "plain" msg_format: Literal["plain", "markdown"] = "plain"
ack_message: str = "⏳ Processing..."
# Optional: directory to save inbound attachments. If empty, use nanobot get_media_dir("qq"). # Optional: directory to save inbound attachments. If empty, use nanobot get_media_dir("qq").
media_dir: str = "" media_dir: str = ""
@@ -484,6 +485,17 @@ class QQChannel(BaseChannel):
if not content and not media_paths: if not content and not media_paths:
return return
if self.config.ack_message:
try:
await self._send_text_only(
chat_id=chat_id,
is_group=is_group,
msg_id=data.id,
content=self.config.ack_message,
)
except Exception:
logger.debug("QQ ack message failed for chat_id={}", chat_id)
await self._handle_message( await self._handle_message(
sender_id=user_id, sender_id=user_id,
chat_id=chat_id, chat_id=chat_id,
+87 -20
View File
@@ -28,6 +28,16 @@ TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message
def _escape_telegram_html(text: str) -> str:
"""Escape text for Telegram HTML parse mode."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _tool_hint_to_telegram_blockquote(text: str) -> str:
"""Render tool hints as an expandable blockquote (collapsed by default)."""
return f"<blockquote expandable>{_escape_telegram_html(text)}</blockquote>" if text else ""
def _strip_md(s: str) -> str: def _strip_md(s: str) -> str:
"""Strip markdown inline formatting from text.""" """Strip markdown inline formatting from text."""
s = re.sub(r'\*\*(.+?)\*\*', r'\1', s) s = re.sub(r'\*\*(.+?)\*\*', r'\1', s)
@@ -120,7 +130,7 @@ def _markdown_to_telegram_html(text: str) -> str:
text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
# 5. Escape HTML special characters # 5. Escape HTML special characters
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") text = _escape_telegram_html(text)
# 6. Links [text](url) - must be before bold/italic to handle nested cases # 6. Links [text](url) - must be before bold/italic to handle nested cases
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text) text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
@@ -141,13 +151,13 @@ def _markdown_to_telegram_html(text: str) -> str:
# 11. Restore inline code with HTML tags # 11. Restore inline code with HTML tags
for i, code in enumerate(inline_codes): for i, code in enumerate(inline_codes):
# Escape HTML in code content # Escape HTML in code content
escaped = code.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") escaped = _escape_telegram_html(code)
text = text.replace(f"\x00IC{i}\x00", f"<code>{escaped}</code>") text = text.replace(f"\x00IC{i}\x00", f"<code>{escaped}</code>")
# 12. Restore code blocks with HTML tags # 12. Restore code blocks with HTML tags
for i, code in enumerate(code_blocks): for i, code in enumerate(code_blocks):
# Escape HTML in code content # Escape HTML in code content
escaped = code.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") escaped = _escape_telegram_html(code)
text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{escaped}</code></pre>") text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{escaped}</code></pre>")
return text return text
@@ -275,13 +285,10 @@ class TelegramChannel(BaseChannel):
self._app = builder.build() self._app = builder.build()
self._app.add_error_handler(self._on_error) self._app.add_error_handler(self._on_error)
# Add command handlers # Add command handlers (using Regex to support @username suffixes before bot initialization)
self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(MessageHandler(filters.Regex(r"^/start(?:@\w+)?$"), self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(MessageHandler(filters.Regex(r"^/(new|stop|restart|status)(?:@\w+)?$"), self._forward_command))
self._app.add_handler(CommandHandler("stop", self._forward_command)) self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help))
self._app.add_handler(CommandHandler("restart", self._forward_command))
self._app.add_handler(CommandHandler("status", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help))
# Add message handler for text, photos, voice, documents # Add message handler for text, photos, voice, documents
self._app.add_handler( self._app.add_handler(
@@ -313,7 +320,7 @@ class TelegramChannel(BaseChannel):
# Start polling (this runs until stopped) # Start polling (this runs until stopped)
await self._app.updater.start_polling( await self._app.updater.start_polling(
allowed_updates=["message"], allowed_updates=["message"],
drop_pending_updates=True # Ignore old messages on startup drop_pending_updates=False # Process pending messages on startup
) )
# Keep running until stopped # Keep running until stopped
@@ -362,9 +369,14 @@ class TelegramChannel(BaseChannel):
logger.warning("Telegram bot not running") logger.warning("Telegram bot not running")
return return
# Only stop typing indicator for final responses # Only stop typing indicator and remove reaction for final responses
if not msg.metadata.get("_progress", False): if not msg.metadata.get("_progress", False):
self._stop_typing(msg.chat_id) self._stop_typing(msg.chat_id)
if reply_to_message_id := msg.metadata.get("message_id"):
try:
await self._remove_reaction(msg.chat_id, int(reply_to_message_id))
except ValueError:
pass
try: try:
chat_id = int(msg.chat_id) chat_id = int(msg.chat_id)
@@ -431,11 +443,17 @@ class TelegramChannel(BaseChannel):
# Send text content # Send text content
if msg.content and msg.content != "[empty message]": if msg.content and msg.content != "[empty message]":
render_as_blockquote = bool(msg.metadata.get("_tool_hint"))
for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN): for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN):
await self._send_text(chat_id, chunk, reply_params, thread_kwargs) await self._send_text(
chat_id, chunk, reply_params, thread_kwargs,
render_as_blockquote=render_as_blockquote,
)
async def _call_with_retry(self, fn, *args, **kwargs): async def _call_with_retry(self, fn, *args, **kwargs):
"""Call an async Telegram API function with retry on pool/network timeout.""" """Call an async Telegram API function with retry on pool/network timeout and RetryAfter."""
from telegram.error import RetryAfter
for attempt in range(1, _SEND_MAX_RETRIES + 1): for attempt in range(1, _SEND_MAX_RETRIES + 1):
try: try:
return await fn(*args, **kwargs) return await fn(*args, **kwargs)
@@ -448,6 +466,15 @@ class TelegramChannel(BaseChannel):
attempt, _SEND_MAX_RETRIES, delay, attempt, _SEND_MAX_RETRIES, delay,
) )
await asyncio.sleep(delay) await asyncio.sleep(delay)
except RetryAfter as e:
if attempt == _SEND_MAX_RETRIES:
raise
delay = float(e.retry_after)
logger.warning(
"Telegram Flood Control (attempt {}/{}), retrying in {:.1f}s",
attempt, _SEND_MAX_RETRIES, delay,
)
await asyncio.sleep(delay)
async def _send_text( async def _send_text(
self, self,
@@ -455,10 +482,11 @@ class TelegramChannel(BaseChannel):
text: str, text: str,
reply_params=None, reply_params=None,
thread_kwargs: dict | None = None, thread_kwargs: dict | None = None,
render_as_blockquote: bool = False,
) -> None: ) -> None:
"""Send a plain text message with HTML fallback.""" """Send a plain text message with HTML fallback."""
try: try:
html = _markdown_to_telegram_html(text) html = _tool_hint_to_telegram_blockquote(text) if render_as_blockquote else _markdown_to_telegram_html(text)
await self._call_with_retry( await self._call_with_retry(
self._app.bot.send_message, self._app.bot.send_message,
chat_id=chat_id, text=html, parse_mode="HTML", chat_id=chat_id, text=html, parse_mode="HTML",
@@ -498,6 +526,11 @@ class TelegramChannel(BaseChannel):
if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id: if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id:
return return
self._stop_typing(chat_id) self._stop_typing(chat_id)
if reply_to_message_id := meta.get("message_id"):
try:
await self._remove_reaction(chat_id, int(reply_to_message_id))
except ValueError:
pass
try: try:
html = _markdown_to_telegram_html(buf.text) html = _markdown_to_telegram_html(buf.text)
await self._call_with_retry( await self._call_with_retry(
@@ -619,8 +652,7 @@ class TelegramChannel(BaseChannel):
"reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None, "reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None,
} }
@staticmethod async def _extract_reply_context(self, message) -> str | None:
def _extract_reply_context(message) -> str | None:
"""Extract text from the message being replied to, if any.""" """Extract text from the message being replied to, if any."""
reply = getattr(message, "reply_to_message", None) reply = getattr(message, "reply_to_message", None)
if not reply: if not reply:
@@ -628,7 +660,21 @@ class TelegramChannel(BaseChannel):
text = getattr(reply, "text", None) or getattr(reply, "caption", None) or "" text = getattr(reply, "text", None) or getattr(reply, "caption", None) or ""
if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN: if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN:
text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..." text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..."
return f"[Reply to: {text}]" if text else None
if not text:
return None
bot_id, _ = await self._ensure_bot_identity()
reply_user = getattr(reply, "from_user", None)
if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id:
return f"[Reply to bot: {text}]"
elif reply_user and getattr(reply_user, "username", None):
return f"[Reply to @{reply_user.username}: {text}]"
elif reply_user and getattr(reply_user, "first_name", None):
return f"[Reply to {reply_user.first_name}: {text}]"
else:
return f"[Reply to: {text}]"
async def _download_message_media( async def _download_message_media(
self, msg, *, add_failure_content: bool = False self, msg, *, add_failure_content: bool = False
@@ -765,10 +811,18 @@ class TelegramChannel(BaseChannel):
message = update.message message = update.message
user = update.effective_user user = update.effective_user
self._remember_thread_context(message) self._remember_thread_context(message)
# Strip @bot_username suffix if present
content = message.text or ""
if content.startswith("/") and "@" in content:
cmd_part, *rest = content.split(" ", 1)
cmd_part = cmd_part.split("@")[0]
content = f"{cmd_part} {rest[0]}" if rest else cmd_part
await self._handle_message( await self._handle_message(
sender_id=self._sender_id(user), sender_id=self._sender_id(user),
chat_id=str(message.chat_id), chat_id=str(message.chat_id),
content=message.text or "", content=content,
metadata=self._build_message_metadata(message, user), metadata=self._build_message_metadata(message, user),
session_key=self._derive_topic_session_key(message), session_key=self._derive_topic_session_key(message),
) )
@@ -812,7 +866,7 @@ class TelegramChannel(BaseChannel):
# Reply context: text and/or media from the replied-to message # Reply context: text and/or media from the replied-to message
reply = getattr(message, "reply_to_message", None) reply = getattr(message, "reply_to_message", None)
if reply is not None: if reply is not None:
reply_ctx = self._extract_reply_context(message) reply_ctx = await self._extract_reply_context(message)
reply_media, reply_media_parts = await self._download_message_media(reply) reply_media, reply_media_parts = await self._download_message_media(reply)
if reply_media: if reply_media:
media_paths = reply_media + media_paths media_paths = reply_media + media_paths
@@ -903,6 +957,19 @@ class TelegramChannel(BaseChannel):
except Exception as e: except Exception as e:
logger.debug("Telegram reaction failed: {}", e) logger.debug("Telegram reaction failed: {}", e)
async def _remove_reaction(self, chat_id: str, message_id: int) -> None:
"""Remove emoji reaction from a message (best-effort, non-blocking)."""
if not self._app:
return
try:
await self._app.bot.set_message_reaction(
chat_id=int(chat_id),
message_id=message_id,
reaction=[],
)
except Exception as e:
logger.debug("Telegram reaction removal failed: {}", e)
async def _typing_loop(self, chat_id: str) -> None: async def _typing_loop(self, chat_id: str) -> None:
"""Repeatedly send 'typing' action until cancelled.""" """Repeatedly send 'typing' action until cancelled."""
try: try:
+113
View File
@@ -22,6 +22,7 @@ if sys.platform == "win32":
pass pass
import typer import typer
from loguru import logger
from prompt_toolkit import PromptSession, print_formatted_text from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import run_in_terminal from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.formatted_text import ANSI, HTML from prompt_toolkit.formatted_text import ANSI, HTML
@@ -491,6 +492,91 @@ def _migrate_cron_store(config: "Config") -> None:
shutil.move(str(legacy_path), str(new_path)) shutil.move(str(legacy_path), str(new_path))
# ============================================================================
# OpenAI-Compatible API Server
# ============================================================================
@app.command()
def serve(
port: int | None = typer.Option(None, "--port", "-p", help="API server port"),
host: str | None = typer.Option(None, "--host", "-H", help="Bind address"),
timeout: float | None = typer.Option(None, "--timeout", "-t", help="Per-request timeout (seconds)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show nanobot runtime logs"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Start the OpenAI-compatible API server (/v1/chat/completions)."""
try:
from aiohttp import web # noqa: F401
except ImportError:
console.print("[red]aiohttp is required. Install with: pip install 'nanobot-ai[api]'[/red]")
raise typer.Exit(1)
from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import SessionManager
if verbose:
logger.enable("nanobot")
else:
logger.disable("nanobot")
runtime_config = _load_runtime_config(config, workspace)
api_cfg = runtime_config.api
host = host if host is not None else api_cfg.host
port = port if port is not None else api_cfg.port
timeout = timeout if timeout is not None else api_cfg.timeout
sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus()
provider = _make_provider(runtime_config)
session_manager = SessionManager(runtime_config.workspace_path)
agent_loop = AgentLoop(
bus=bus,
provider=provider,
workspace=runtime_config.workspace_path,
model=runtime_config.agents.defaults.model,
max_iterations=runtime_config.agents.defaults.max_tool_iterations,
context_window_tokens=runtime_config.agents.defaults.context_window_tokens,
web_search_config=runtime_config.tools.web.search,
web_proxy=runtime_config.tools.web.proxy or None,
exec_config=runtime_config.tools.exec,
restrict_to_workspace=runtime_config.tools.restrict_to_workspace,
session_manager=session_manager,
mcp_servers=runtime_config.tools.mcp_servers,
channels_config=runtime_config.channels,
timezone=runtime_config.agents.defaults.timezone,
)
model_name = runtime_config.agents.defaults.model
console.print(f"{__logo__} Starting OpenAI-compatible API server")
console.print(f" [cyan]Endpoint[/cyan] : http://{host}:{port}/v1/chat/completions")
console.print(f" [cyan]Model[/cyan] : {model_name}")
console.print(" [cyan]Session[/cyan] : api:default")
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
if host in {"0.0.0.0", "::"}:
console.print(
"[yellow]Warning:[/yellow] API is bound to all interfaces. "
"Only do this behind a trusted network boundary, firewall, or reverse proxy."
)
console.print()
api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout)
async def on_startup(_app):
await agent_loop._connect_mcp()
async def on_cleanup(_app):
await agent_loop.close_mcp()
api_app.on_startup.append(on_startup)
api_app.on_cleanup.append(on_cleanup)
web.run_app(api_app, host=host, port=port, print=lambda msg: logger.info(msg))
# ============================================================================ # ============================================================================
# Gateway / Server # Gateway / Server
# ============================================================================ # ============================================================================
@@ -555,6 +641,15 @@ def gateway(
# Set cron callback (needs agent) # Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None: async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent.""" """Execute a cron job through the agent."""
# Dream is an internal job — run directly, not through the agent loop.
if job.name == "dream":
try:
await agent.dream.run()
logger.info("Dream cron job completed")
except Exception:
logger.exception("Dream cron job failed")
return None
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.utils.evaluator import evaluate_response from nanobot.utils.evaluator import evaluate_response
@@ -674,6 +769,21 @@ def gateway(
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s") console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
# Register Dream cron job (always-on, idempotent on restart)
dream_cfg = config.agents.defaults.dream
if dream_cfg.model:
agent.dream.model = dream_cfg.model
agent.dream.max_batch_size = dream_cfg.max_batch_size
agent.dream.max_iterations = dream_cfg.max_iterations
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
cron.register_system_job(CronJob(
id="dream",
name="dream",
schedule=CronSchedule(kind="cron", expr=dream_cfg.cron, tz=config.agents.defaults.timezone),
payload=CronPayload(kind="system_event"),
))
console.print(f"[green]✓[/green] Dream: cron {dream_cfg.cron}")
async def run(): async def run():
try: try:
await cron.start() await cron.start()
@@ -874,6 +984,9 @@ def agent(
while True: while True:
try: try:
_flush_pending_tty_input() _flush_pending_tty_input()
# Stop spinner before user input to avoid prompt_toolkit conflicts
if renderer:
renderer.stop_for_input()
user_input = await _read_interactive_input_async() user_input = await _read_interactive_input_async()
command = user_input.strip() command = user_input.strip()
if not command: if not command:
+5 -1
View File
@@ -18,7 +18,7 @@ from nanobot import __logo__
def _make_console() -> Console: def _make_console() -> Console:
return Console(file=sys.stdout) return Console(file=sys.stdout, force_terminal=True)
class ThinkingSpinner: class ThinkingSpinner:
@@ -120,6 +120,10 @@ class StreamRenderer:
else: else:
_make_console().print() _make_console().print()
def stop_for_input(self) -> None:
"""Stop spinner before user input to avoid prompt_toolkit conflicts."""
self._stop_spinner()
async def close(self) -> None: async def close(self) -> None:
"""Stop spinner/live without rendering a final streamed round.""" """Stop spinner/live without rendering a final streamed round."""
if self._live: if self._live:
+128 -11
View File
@@ -26,7 +26,10 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key) sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key)
total = cancelled + sub_cancelled total = cancelled + sub_cancelled
content = f"Stopped {total} task(s)." if total else "No active task to stop." content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
metadata=dict(msg.metadata or {})
)
async def cmd_restart(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
@@ -38,7 +41,10 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:]) os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
asyncio.create_task(_do_restart()) asyncio.create_task(_do_restart())
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="Restarting...") return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="Restarting...",
metadata=dict(msg.metadata or {})
)
async def cmd_status(ctx: CommandContext) -> OutboundMessage: async def cmd_status(ctx: CommandContext) -> OutboundMessage:
@@ -47,7 +53,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
session = ctx.session or loop.sessions.get_or_create(ctx.key) session = ctx.session or loop.sessions.get_or_create(ctx.key)
ctx_est = 0 ctx_est = 0
try: try:
ctx_est, _ = loop.memory_consolidator.estimate_session_prompt_tokens(session) ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(session)
except Exception: except Exception:
pass pass
if ctx_est <= 0: if ctx_est <= 0:
@@ -62,7 +68,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
session_msg_count=len(session.get_history(max_messages=0)), session_msg_count=len(session.get_history(max_messages=0)),
context_tokens_estimate=ctx_est, context_tokens_estimate=ctx_est,
), ),
metadata={"render_as": "text"}, metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
) )
@@ -75,29 +81,135 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
loop.sessions.save(session) loop.sessions.save(session)
loop.sessions.invalidate(session.key) loop.sessions.invalidate(session.key)
if snapshot: if snapshot:
loop._schedule_background(loop.memory_consolidator.archive_messages(snapshot)) loop._schedule_background(loop.consolidator.archive(snapshot))
return OutboundMessage( return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content="New session started.", content="New session started.",
metadata=dict(ctx.msg.metadata or {})
)
async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
"""Manually trigger a Dream consolidation run."""
loop = ctx.loop
try:
did_work = await loop.dream.run()
content = "Dream completed." if did_work else "Dream: nothing to process."
except Exception as e:
content = f"Dream failed: {e}"
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content,
)
async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage:
"""Show what the last Dream changed.
Default: diff of the latest commit (HEAD~1 vs HEAD).
With /dream-log <sha>: diff of that specific commit.
"""
store = ctx.loop.consolidator.store
git = store.git
if not git.is_initialized():
if store.get_last_dream_cursor() == 0:
msg = "Dream has not run yet."
else:
msg = "Git not initialized for memory files."
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content=msg, metadata={"render_as": "text"},
)
args = ctx.args.strip()
if args:
# Show diff of a specific commit
sha = args.split()[0]
result = git.show_commit_diff(sha)
if not result:
content = f"Commit `{sha}` not found."
else:
commit, diff = result
content = commit.format(diff)
else:
# Default: show the latest commit's diff
result = git.show_commit_diff(git.log(max_entries=1)[0].sha) if git.log(max_entries=1) else None
if result:
commit, diff = result
content = commit.format(diff)
else:
content = "No commits yet."
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content=content, metadata={"render_as": "text"},
)
async def cmd_dream_restore(ctx: CommandContext) -> OutboundMessage:
"""Restore memory files from a previous dream commit.
Usage:
/dream-restore — list recent commits
/dream-restore <sha> — revert a specific commit
"""
store = ctx.loop.consolidator.store
git = store.git
if not git.is_initialized():
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content="Git not initialized for memory files.",
)
args = ctx.args.strip()
if not args:
# Show recent commits for the user to pick
commits = git.log(max_entries=10)
if not commits:
content = "No commits found."
else:
lines = ["## Recent Dream Commits\n", "Use `/dream-restore <sha>` to revert a commit.\n"]
for c in commits:
lines.append(f"- `{c.sha}` {c.message.splitlines()[0]} ({c.timestamp})")
content = "\n".join(lines)
else:
sha = args.split()[0]
new_sha = git.revert(sha)
if new_sha:
content = f"Reverted commit `{sha}` → new commit `{new_sha}`."
else:
content = f"Failed to revert commit `{sha}`. Check if the SHA is correct."
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content=content, metadata={"render_as": "text"},
) )
async def cmd_help(ctx: CommandContext) -> OutboundMessage: async def cmd_help(ctx: CommandContext) -> OutboundMessage:
"""Return available slash commands.""" """Return available slash commands."""
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content=build_help_text(),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
def build_help_text() -> str:
"""Build canonical help text shared across channels."""
lines = [ lines = [
"🐈 nanobot commands:", "🐈 nanobot commands:",
"/new — Start a new conversation", "/new — Start a new conversation",
"/stop — Stop the current task", "/stop — Stop the current task",
"/restart — Restart the bot", "/restart — Restart the bot",
"/status — Show bot status", "/status — Show bot status",
"/dream — Manually trigger Dream consolidation",
"/dream-log — Show what the last Dream changed",
"/dream-restore — Revert memory to a previous state",
"/help — Show available commands", "/help — Show available commands",
] ]
return OutboundMessage( return "\n".join(lines)
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="\n".join(lines),
metadata={"render_as": "text"},
)
def register_builtin_commands(router: CommandRouter) -> None: def register_builtin_commands(router: CommandRouter) -> None:
@@ -107,4 +219,9 @@ def register_builtin_commands(router: CommandRouter) -> None:
router.priority("/status", cmd_status) router.priority("/status", cmd_status)
router.exact("/new", cmd_new) router.exact("/new", cmd_new)
router.exact("/status", cmd_status) router.exact("/status", cmd_status)
router.exact("/dream", cmd_dream)
router.exact("/dream-log", cmd_dream_log)
router.prefix("/dream-log ", cmd_dream_log)
router.exact("/dream-restore", cmd_dream_restore)
router.prefix("/dream-restore ", cmd_dream_restore)
router.exact("/help", cmd_help) router.exact("/help", cmd_help)
+20
View File
@@ -28,6 +28,15 @@ class ChannelsConfig(Base):
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included) send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
class DreamConfig(Base):
"""Dream memory consolidation configuration."""
cron: str = "0 */2 * * *" # Every 2 hours
model: str | None = None # Override model for Dream
max_batch_size: int = Field(default=20, ge=1) # Max history entries per run
max_iterations: int = Field(default=10, ge=1) # Max tool calls per Phase 2
class AgentDefaults(Base): class AgentDefaults(Base):
"""Default agent configuration.""" """Default agent configuration."""
@@ -42,6 +51,7 @@ class AgentDefaults(Base):
max_tool_iterations: int = 40 max_tool_iterations: int = 40
reasoning_effort: str | None = None # low / medium / high - enables LLM thinking mode reasoning_effort: str | None = None # low / medium / high - enables LLM thinking mode
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
dream: DreamConfig = Field(default_factory=DreamConfig)
class AgentsConfig(Base): class AgentsConfig(Base):
@@ -86,6 +96,7 @@ class ProvidersConfig(Base):
byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # OpenAI Codex (OAuth) openai_codex: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # OpenAI Codex (OAuth)
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth)
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
class HeartbeatConfig(Base): class HeartbeatConfig(Base):
@@ -96,6 +107,14 @@ class HeartbeatConfig(Base):
keep_recent_messages: int = 8 keep_recent_messages: int = 8
class ApiConfig(Base):
"""OpenAI-compatible API server configuration."""
host: str = "127.0.0.1" # Safer default: local-only bind.
port: int = 8900
timeout: float = 120.0 # Per-request timeout in seconds.
class GatewayConfig(Base): class GatewayConfig(Base):
"""Gateway/server configuration.""" """Gateway/server configuration."""
@@ -156,6 +175,7 @@ class Config(BaseSettings):
agents: AgentsConfig = Field(default_factory=AgentsConfig) agents: AgentsConfig = Field(default_factory=AgentsConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
providers: ProvidersConfig = Field(default_factory=ProvidersConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
api: ApiConfig = Field(default_factory=ApiConfig)
gateway: GatewayConfig = Field(default_factory=GatewayConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig)
+14
View File
@@ -351,6 +351,20 @@ class CronService:
logger.info("Cron: added job '{}' ({})", name, job.id) logger.info("Cron: added job '{}' ({})", name, job.id)
return job return job
def register_system_job(self, job: CronJob) -> CronJob:
"""Register an internal system job (idempotent on restart)."""
store = self._load_store()
now = _now_ms()
job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now))
job.created_at_ms = now
job.updated_at_ms = now
store.jobs = [j for j in store.jobs if j.id != job.id]
store.jobs.append(job)
self._save_store()
self._arm_timer()
logger.info("Cron: registered system job '{}' ({})", job.name, job.id)
return job
def remove_job(self, job_id: str) -> bool: def remove_job(self, job_id: str) -> bool:
"""Remove a job by ID.""" """Remove a job by ID."""
store = self._load_store() store = self._load_store()
+170
View File
@@ -0,0 +1,170 @@
"""High-level programmatic interface to nanobot."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.agent.hook import AgentHook
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
@dataclass(slots=True)
class RunResult:
"""Result of a single agent run."""
content: str
tools_used: list[str]
messages: list[dict[str, Any]]
class Nanobot:
"""Programmatic facade for running the nanobot agent.
Usage::
bot = Nanobot.from_config()
result = await bot.run("Summarize this repo", hooks=[MyHook()])
print(result.content)
"""
def __init__(self, loop: AgentLoop) -> None:
self._loop = loop
@classmethod
def from_config(
cls,
config_path: str | Path | None = None,
*,
workspace: str | Path | None = None,
) -> Nanobot:
"""Create a Nanobot instance from a config file.
Args:
config_path: Path to ``config.json``. Defaults to
``~/.nanobot/config.json``.
workspace: Override the workspace directory from config.
"""
from nanobot.config.loader import load_config
from nanobot.config.schema import Config
resolved: Path | None = None
if config_path is not None:
resolved = Path(config_path).expanduser().resolve()
if not resolved.exists():
raise FileNotFoundError(f"Config not found: {resolved}")
config: Config = load_config(resolved)
if workspace is not None:
config.agents.defaults.workspace = str(
Path(workspace).expanduser().resolve()
)
provider = _make_provider(config)
bus = MessageBus()
defaults = config.agents.defaults
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=defaults.model,
max_iterations=defaults.max_tool_iterations,
context_window_tokens=defaults.context_window_tokens,
web_search_config=config.tools.web.search,
web_proxy=config.tools.web.proxy or None,
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
timezone=defaults.timezone,
)
return cls(loop)
async def run(
self,
message: str,
*,
session_key: str = "sdk:default",
hooks: list[AgentHook] | None = None,
) -> RunResult:
"""Run the agent once and return the result.
Args:
message: The user message to process.
session_key: Session identifier for conversation isolation.
Different keys get independent history.
hooks: Optional lifecycle hooks for this run.
"""
prev = self._loop._extra_hooks
if hooks is not None:
self._loop._extra_hooks = list(hooks)
try:
response = await self._loop.process_direct(
message, session_key=session_key,
)
finally:
self._loop._extra_hooks = prev
content = (response.content if response else None) or ""
return RunResult(content=content, tools_used=[], messages=[])
def _make_provider(config: Any) -> Any:
"""Create the LLM provider from config (extracted from CLI)."""
from nanobot.providers.base import GenerationSettings
from nanobot.providers.registry import find_by_name
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
spec = find_by_name(provider_name) if provider_name else None
backend = spec.backend if spec else "openai_compat"
if backend == "azure_openai":
if not p or not p.api_key or not p.api_base:
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
elif backend == "openai_compat" and not model.startswith("bedrock/"):
needs_key = not (p and p.api_key)
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
if needs_key and not exempt:
raise ValueError(f"No API key configured for provider '{provider_name}'.")
if backend == "openai_codex":
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
provider = OpenAICodexProvider(default_model=model)
elif backend == "azure_openai":
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
provider = AzureOpenAIProvider(
api_key=p.api_key, api_base=p.api_base, default_model=model
)
elif backend == "anthropic":
from nanobot.providers.anthropic_provider import AnthropicProvider
provider = AnthropicProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
)
else:
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
provider = OpenAICompatProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
spec=spec,
)
defaults = config.agents.defaults
provider.generation = GenerationSettings(
temperature=defaults.temperature,
max_tokens=defaults.max_tokens,
reasoning_effort=defaults.reasoning_effort,
)
return provider
+4
View File
@@ -379,6 +379,10 @@ class AnthropicProvider(LLMProvider):
val = getattr(response.usage, attr, 0) val = getattr(response.usage, attr, 0)
if val: if val:
usage[attr] = val usage[attr] = val
# Normalize to cached_tokens for downstream consistency.
cache_read = usage.get("cache_read_input_tokens", 0)
if cache_read:
usage["cached_tokens"] = cache_read
return LLMResponse( return LLMResponse(
content="".join(content_parts) or None, content="".join(content_parts) or None,
+45 -4
View File
@@ -308,6 +308,13 @@ class OpenAICompatProvider(LLMProvider):
@classmethod @classmethod
def _extract_usage(cls, response: Any) -> dict[str, int]: def _extract_usage(cls, response: Any) -> dict[str, int]:
"""Extract token usage from an OpenAI-compatible response.
Handles both dict-based (raw JSON) and object-based (SDK Pydantic)
responses. Provider-specific ``cached_tokens`` fields are normalised
under a single key; see the priority chain inside for details.
"""
# --- resolve usage object ---
usage_obj = None usage_obj = None
response_map = cls._maybe_mapping(response) response_map = cls._maybe_mapping(response)
if response_map is not None: if response_map is not None:
@@ -317,20 +324,54 @@ class OpenAICompatProvider(LLMProvider):
usage_map = cls._maybe_mapping(usage_obj) usage_map = cls._maybe_mapping(usage_obj)
if usage_map is not None: if usage_map is not None:
return { result = {
"prompt_tokens": int(usage_map.get("prompt_tokens") or 0), "prompt_tokens": int(usage_map.get("prompt_tokens") or 0),
"completion_tokens": int(usage_map.get("completion_tokens") or 0), "completion_tokens": int(usage_map.get("completion_tokens") or 0),
"total_tokens": int(usage_map.get("total_tokens") or 0), "total_tokens": int(usage_map.get("total_tokens") or 0),
} }
elif usage_obj:
if usage_obj: result = {
return {
"prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0, "prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0,
"completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0, "completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0,
"total_tokens": getattr(usage_obj, "total_tokens", 0) or 0, "total_tokens": getattr(usage_obj, "total_tokens", 0) or 0,
} }
else:
return {} return {}
# --- cached_tokens (normalised across providers) ---
# Try nested paths first (dict), fall back to attribute (SDK object).
# Priority order ensures the most specific field wins.
for path in (
("prompt_tokens_details", "cached_tokens"), # OpenAI/Zhipu/MiniMax/Qwen/Mistral/xAI
("cached_tokens",), # StepFun/Moonshot (top-level)
("prompt_cache_hit_tokens",), # DeepSeek/SiliconFlow
):
cached = cls._get_nested_int(usage_map, path)
if not cached and usage_obj:
cached = cls._get_nested_int(usage_obj, path)
if cached:
result["cached_tokens"] = cached
break
return result
@staticmethod
def _get_nested_int(obj: Any, path: tuple[str, ...]) -> int:
"""Drill into *obj* by *path* segments and return an ``int`` value.
Supports both dict-key access and attribute access so it works
uniformly with raw JSON dicts **and** SDK Pydantic models.
"""
current = obj
for segment in path:
if current is None:
return 0
if isinstance(current, dict):
current = current.get(segment)
else:
current = getattr(current, segment, None)
return int(current or 0) if current is not None else 0
def _parse(self, response: Any) -> LLMResponse: def _parse(self, response: Any) -> LLMResponse:
if isinstance(response, str): if isinstance(response, str):
return LLMResponse(content=response, finish_reason="stop") return LLMResponse(content=response, finish_reason="stop")
+10
View File
@@ -200,6 +200,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
env_key="OPENAI_API_KEY", env_key="OPENAI_API_KEY",
display_name="OpenAI", display_name="OpenAI",
backend="openai_compat", backend="openai_compat",
supports_max_completion_tokens=True,
), ),
# OpenAI Codex: OAuth-based, dedicated provider # OpenAI Codex: OAuth-based, dedicated provider
ProviderSpec( ProviderSpec(
@@ -338,6 +339,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat", backend="openai_compat",
default_api_base="https://api.groq.com/openai/v1", default_api_base="https://api.groq.com/openai/v1",
), ),
# Qianfan (百度千帆): OpenAI-compatible API
ProviderSpec(
name="qianfan",
keywords=("qianfan", "ernie"),
env_key="QIANFAN_API_KEY",
display_name="Qianfan",
backend="openai_compat",
default_api_base="https://qianfan.baidubce.com/v2"
),
) )
+15 -22
View File
@@ -1,6 +1,6 @@
--- ---
name: memory name: memory
description: Two-layer memory system with grep-based recall. description: Two-layer memory system with Dream-managed knowledge files.
always: true always: true
--- ---
@@ -8,30 +8,23 @@ always: true
## Structure ## Structure
- `memory/MEMORY.md` — Long-term facts (preferences, project context, relationships). Always loaded into your context. - `SOUL.md` — Bot personality and communication style. **Managed by Dream.** Do NOT edit.
- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep-style tools or in-memory filters. Each entry starts with [YYYY-MM-DD HH:MM]. - `USER.md` — User profile and preferences. **Managed by Dream.** Do NOT edit.
- `memory/MEMORY.md` — Long-term facts (project context, important events). **Managed by Dream.** Do NOT edit.
- `memory/history.jsonl` — append-only JSONL, not loaded into context. search with `jq`-style tools.
- `memory/.dream-log.md` — Changelog of what Dream changed. View with `/dream-log`.
## Search Past Events ## Search Past Events
Choose the search method based on file size: `memory/history.jsonl` is JSONL format — each line is a JSON object with `cursor`, `timestamp`, `content`.
- Small `memory/HISTORY.md`: use `read_file`, then search in-memory Examples (replace `keyword`):
- Large or long-lived `memory/HISTORY.md`: use the `exec` tool for targeted search - **Python (cross-platform):** `python -c "import json; [print(json.loads(l).get('content','')) for l in open('memory/history.jsonl','r',encoding='utf-8') if l.strip() and 'keyword' in l.lower()][-20:]"`
- **jq:** `cat memory/history.jsonl | jq -r 'select(.content | test("keyword"; "i")) | .content' | tail -20`
- **grep:** `grep -i "keyword" memory/history.jsonl`
Examples: ## Important
- **Linux/macOS:** `grep -i "keyword" memory/HISTORY.md`
- **Windows:** `findstr /i "keyword" memory\HISTORY.md`
- **Cross-platform Python:** `python -c "from pathlib import Path; text = Path('memory/HISTORY.md').read_text(encoding='utf-8'); print('\n'.join([l for l in text.splitlines() if 'keyword' in l.lower()][-20:]))"`
Prefer targeted command-line search for large history files. - **Do NOT edit SOUL.md, USER.md, or MEMORY.md.** They are automatically managed by Dream.
- If you notice outdated information, it will be corrected when Dream runs next.
## When to Update MEMORY.md - Users can view Dream's activity with the `/dream-log` command.
Write important facts immediately using `edit_file` or `write_file`:
- User preferences ("I prefer dark mode")
- Project context ("The API uses OAuth2")
- Relationships ("Alice is the project lead")
## Auto-consolidation
Old conversations are automatically summarized and appended to HISTORY.md when the session grows large. Long-term facts are extracted to MEMORY.md. You don't need to manage this.
+1 -1
View File
@@ -295,7 +295,7 @@ After initialization, customize the SKILL.md and add resources as needed. If you
### Step 4: Edit the Skill ### Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of the agent to use. Include information that would be beneficial and non-obvious to the agent. Consider what procedural knowledge, domain-specific details, or reusable assets would help another the agent instance execute these tasks more effectively. When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of the agent to use. Include information that would be beneficial and non-obvious to the agent. Consider what procedural knowledge, domain-specific details, or reusable assets would help another agent instance execute these tasks more effectively.
#### Learn Proven Design Patterns #### Learn Proven Design Patterns
+19 -4
View File
@@ -124,8 +124,8 @@ def build_assistant_message(
msg: dict[str, Any] = {"role": "assistant", "content": content} msg: dict[str, Any] = {"role": "assistant", "content": content}
if tool_calls: if tool_calls:
msg["tool_calls"] = tool_calls msg["tool_calls"] = tool_calls
if reasoning_content is not None: if reasoning_content is not None or thinking_blocks:
msg["reasoning_content"] = reasoning_content msg["reasoning_content"] = reasoning_content if reasoning_content is not None else ""
if thinking_blocks: if thinking_blocks:
msg["thinking_blocks"] = thinking_blocks msg["thinking_blocks"] = thinking_blocks
return msg return msg
@@ -255,14 +255,18 @@ def build_status_content(
) )
last_in = last_usage.get("prompt_tokens", 0) last_in = last_usage.get("prompt_tokens", 0)
last_out = last_usage.get("completion_tokens", 0) last_out = last_usage.get("completion_tokens", 0)
cached = last_usage.get("cached_tokens", 0)
ctx_total = max(context_window_tokens, 0) ctx_total = max(context_window_tokens, 0)
ctx_pct = int((context_tokens_estimate / ctx_total) * 100) if ctx_total > 0 else 0 ctx_pct = int((context_tokens_estimate / ctx_total) * 100) if ctx_total > 0 else 0
ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate) ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate)
ctx_total_str = f"{ctx_total // 1024}k" if ctx_total > 0 else "n/a" ctx_total_str = f"{ctx_total // 1024}k" if ctx_total > 0 else "n/a"
token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out"
if cached and last_in:
token_line += f" ({cached * 100 // last_in}% cached)"
return "\n".join([ return "\n".join([
f"\U0001f408 nanobot v{version}", f"\U0001f408 nanobot v{version}",
f"\U0001f9e0 Model: {model}", f"\U0001f9e0 Model: {model}",
f"\U0001f4ca Tokens: {last_in} in / {last_out} out", token_line,
f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)", f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)",
f"\U0001f4ac Session: {session_msg_count} messages", f"\U0001f4ac Session: {session_msg_count} messages",
f"\u23f1 Uptime: {uptime}", f"\u23f1 Uptime: {uptime}",
@@ -292,11 +296,22 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
if item.name.endswith(".md") and not item.name.startswith("."): if item.name.endswith(".md") and not item.name.startswith("."):
_write(item, workspace / item.name) _write(item, workspace / item.name)
_write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md") _write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md")
_write(None, workspace / "memory" / "HISTORY.md") _write(None, workspace / "memory" / "history.jsonl")
(workspace / "skills").mkdir(exist_ok=True) (workspace / "skills").mkdir(exist_ok=True)
if added and not silent: if added and not silent:
from rich.console import Console from rich.console import Console
for name in added: for name in added:
Console().print(f" [dim]Created {name}[/dim]") Console().print(f" [dim]Created {name}[/dim]")
# Initialize git for memory version control
try:
from nanobot.agent.git_store import GitStore
gs = GitStore(workspace, tracked_files=[
"SOUL.md", "USER.md", "memory/MEMORY.md",
])
gs.init()
except Exception:
pass
return added return added
+8
View File
@@ -48,9 +48,13 @@ dependencies = [
"chardet>=3.0.2,<6.0.0", "chardet>=3.0.2,<6.0.0",
"openai>=2.8.0", "openai>=2.8.0",
"tiktoken>=0.12.0,<1.0.0", "tiktoken>=0.12.0,<1.0.0",
"dulwich>=0.22.0,<1.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
api = [
"aiohttp>=3.9.0,<4.0.0",
]
wecom = [ wecom = [
"wecom-aibot-sdk-python>=0.1.5", "wecom-aibot-sdk-python>=0.1.5",
] ]
@@ -64,12 +68,16 @@ matrix = [
"mistune>=3.0.0,<4.0.0", "mistune>=3.0.0,<4.0.0",
"nh3>=0.2.17,<1.0.0", "nh3>=0.2.17,<1.0.0",
] ]
discord = [
"discord.py>=2.5.2,<3.0.0",
]
langsmith = [ langsmith = [
"langsmith>=0.1.0", "langsmith>=0.1.0",
] ]
dev = [ dev = [
"pytest>=9.0.0,<10.0.0", "pytest>=9.0.0,<10.0.0",
"pytest-asyncio>=1.3.0,<2.0.0", "pytest-asyncio>=1.3.0,<2.0.0",
"aiohttp>=3.9.0,<4.0.0",
"pytest-cov>=6.0.0,<7.0.0", "pytest-cov>=6.0.0,<7.0.0",
"ruff>=0.1.0", "ruff>=0.1.0",
] ]
+10 -10
View File
@@ -506,7 +506,7 @@ class TestNewCommandArchival:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_new_clears_session_immediately_even_if_archive_fails(self, tmp_path: Path) -> None: async def test_new_clears_session_immediately_even_if_archive_fails(self, tmp_path: Path) -> None:
"""/new clears session immediately; archive_messages retries until raw dump.""" """/new clears session immediately; archive is fire-and-forget."""
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
loop = self._make_loop(tmp_path) loop = self._make_loop(tmp_path)
@@ -518,12 +518,12 @@ class TestNewCommandArchival:
call_count = 0 call_count = 0
async def _failing_consolidate(_messages) -> bool: async def _failing_summarize(_messages) -> bool:
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
return False return False
loop.memory_consolidator.consolidate_messages = _failing_consolidate # type: ignore[method-assign] loop.consolidator.archive = _failing_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg) response = await loop._process_message(new_msg)
@@ -535,7 +535,7 @@ class TestNewCommandArchival:
assert len(session_after.messages) == 0 assert len(session_after.messages) == 0
await loop.close_mcp() await loop.close_mcp()
assert call_count == 3 # retried up to raw-archive threshold assert call_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_new_archives_only_unconsolidated_messages(self, tmp_path: Path) -> None: async def test_new_archives_only_unconsolidated_messages(self, tmp_path: Path) -> None:
@@ -551,12 +551,12 @@ class TestNewCommandArchival:
archived_count = -1 archived_count = -1
async def _fake_consolidate(messages) -> bool: async def _fake_summarize(messages) -> bool:
nonlocal archived_count nonlocal archived_count
archived_count = len(messages) archived_count = len(messages)
return True return True
loop.memory_consolidator.consolidate_messages = _fake_consolidate # type: ignore[method-assign] loop.consolidator.archive = _fake_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg) response = await loop._process_message(new_msg)
@@ -578,10 +578,10 @@ class TestNewCommandArchival:
session.add_message("assistant", f"resp{i}") session.add_message("assistant", f"resp{i}")
loop.sessions.save(session) loop.sessions.save(session)
async def _ok_consolidate(_messages) -> bool: async def _ok_summarize(_messages) -> bool:
return True return True
loop.memory_consolidator.consolidate_messages = _ok_consolidate # type: ignore[method-assign] loop.consolidator.archive = _ok_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg) response = await loop._process_message(new_msg)
@@ -604,12 +604,12 @@ class TestNewCommandArchival:
archived = asyncio.Event() archived = asyncio.Event()
async def _slow_consolidate(_messages) -> bool: async def _slow_summarize(_messages) -> bool:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
archived.set() archived.set()
return True return True
loop.memory_consolidator.consolidate_messages = _slow_consolidate # type: ignore[method-assign] loop.consolidator.archive = _slow_summarize # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
await loop._process_message(new_msg) await loop._process_message(new_msg)
+78
View File
@@ -0,0 +1,78 @@
"""Tests for the lightweight Consolidator — append-only to HISTORY.md."""
import pytest
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from nanobot.agent.memory import Consolidator, MemoryStore
@pytest.fixture
def store(tmp_path):
return MemoryStore(tmp_path)
@pytest.fixture
def mock_provider():
p = MagicMock()
p.chat_with_retry = AsyncMock()
return p
@pytest.fixture
def consolidator(store, mock_provider):
sessions = MagicMock()
sessions.save = MagicMock()
return Consolidator(
store=store,
provider=mock_provider,
model="test-model",
sessions=sessions,
context_window_tokens=1000,
build_messages=MagicMock(return_value=[]),
get_tool_definitions=MagicMock(return_value=[]),
max_completion_tokens=100,
)
class TestConsolidatorSummarize:
async def test_summarize_appends_to_history(self, consolidator, mock_provider, store):
"""Consolidator should call LLM to summarize, then append to HISTORY.md."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="User fixed a bug in the auth module."
)
messages = [
{"role": "user", "content": "fix the auth bug"},
{"role": "assistant", "content": "Done, fixed the race condition."},
]
result = await consolidator.archive(messages)
assert result is True
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
async def test_summarize_raw_dumps_on_llm_failure(self, consolidator, mock_provider, store):
"""On LLM failure, raw-dump messages to HISTORY.md."""
mock_provider.chat_with_retry.side_effect = Exception("API error")
messages = [{"role": "user", "content": "hello"}]
result = await consolidator.archive(messages)
assert result is True # always succeeds
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" in entries[0]["content"]
async def test_summarize_skips_empty_messages(self, consolidator):
result = await consolidator.archive([])
assert result is False
class TestConsolidatorTokenBudget:
async def test_prompt_below_threshold_does_not_consolidate(self, consolidator):
"""No consolidation when tokens are within budget."""
session = MagicMock()
session.last_consolidated = 0
session.messages = [{"role": "user", "content": "hi"}]
session.key = "test:key"
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(100, "tiktoken"))
consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session)
consolidator.archive.assert_not_called()
+97
View File
@@ -0,0 +1,97 @@
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from nanobot.agent.memory import Dream, MemoryStore
from nanobot.agent.runner import AgentRunResult
@pytest.fixture
def store(tmp_path):
s = MemoryStore(tmp_path)
s.write_soul("# Soul\n- Helpful")
s.write_user("# User\n- Developer")
s.write_memory("# Memory\n- Project X active")
return s
@pytest.fixture
def mock_provider():
p = MagicMock()
p.chat_with_retry = AsyncMock()
return p
@pytest.fixture
def mock_runner():
return MagicMock()
@pytest.fixture
def dream(store, mock_provider, mock_runner):
d = Dream(store=store, provider=mock_provider, model="test-model", max_batch_size=5)
d._runner = mock_runner
return d
def _make_run_result(
stop_reason="completed",
final_content=None,
tool_events=None,
usage=None,
):
return AgentRunResult(
final_content=final_content or stop_reason,
stop_reason=stop_reason,
messages=[],
tools_used=[],
usage={},
tool_events=tool_events or [],
)
class TestDreamRun:
async def test_noop_when_no_unprocessed_history(self, dream, mock_provider, mock_runner, store):
"""Dream should not call LLM when there's nothing to process."""
result = await dream.run()
assert result is False
mock_provider.chat_with_retry.assert_not_called()
mock_runner.run.assert_not_called()
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
"""Dream should call AgentRunner when there are unprocessed history entries."""
store.append_history("User prefers dark mode")
mock_provider.chat_with_retry.return_value = MagicMock(content="New fact")
mock_runner.run = AsyncMock(return_value=_make_run_result(
tool_events=[{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}],
))
result = await dream.run()
assert result is True
mock_runner.run.assert_called_once()
spec = mock_runner.run.call_args[0][0]
assert spec.max_iterations == 10
assert spec.fail_on_tool_error is True
async def test_advances_dream_cursor(self, dream, mock_provider, mock_runner, store):
"""Dream should advance the cursor after processing."""
store.append_history("event 1")
store.append_history("event 2")
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
assert store.get_last_dream_cursor() == 2
async def test_compacts_processed_history(self, dream, mock_provider, mock_runner, store):
"""Dream should compact history after processing."""
store.append_history("event 1")
store.append_history("event 2")
store.append_history("event 3")
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
entries = store.read_unprocessed_history(since_cursor=0)
assert all(e["cursor"] > 0 for e in entries)
+229
View File
@@ -0,0 +1,229 @@
"""Tests for GitStore — git-backed version control for memory files."""
import pytest
from pathlib import Path
from nanobot.agent.git_store import GitStore, CommitInfo
TRACKED = ["SOUL.md", "USER.md", "memory/MEMORY.md"]
@pytest.fixture
def git(tmp_path):
"""Uninitialized GitStore."""
return GitStore(tmp_path, tracked_files=TRACKED)
@pytest.fixture
def git_ready(git):
"""Initialized GitStore with one initial commit."""
git.init()
return git
class TestInit:
def test_not_initialized_by_default(self, git, tmp_path):
assert not git.is_initialized()
assert not (tmp_path / ".git").is_dir()
def test_init_creates_git_dir(self, git, tmp_path):
assert git.init()
assert (tmp_path / ".git").is_dir()
def test_init_idempotent(self, git_ready):
assert not git_ready.init()
def test_init_creates_gitignore(self, git_ready):
gi = git_ready._workspace / ".gitignore"
assert gi.exists()
content = gi.read_text(encoding="utf-8")
for f in TRACKED:
assert f"!{f}" in content
def test_init_touches_tracked_files(self, git_ready):
for f in TRACKED:
assert (git_ready._workspace / f).exists()
def test_init_makes_initial_commit(self, git_ready):
commits = git_ready.log()
assert len(commits) == 1
assert "init" in commits[0].message
class TestBuildGitignore:
def test_subdirectory_dirs(self, git):
content = git._build_gitignore()
assert "!memory/\n" in content
for f in TRACKED:
assert f"!{f}\n" in content
assert content.startswith("/*\n")
def test_root_level_files_no_dir_entries(self, tmp_path):
gs = GitStore(tmp_path, tracked_files=["a.md", "b.md"])
content = gs._build_gitignore()
assert "!a.md\n" in content
assert "!b.md\n" in content
dir_lines = [l for l in content.split("\n") if l.startswith("!") and l.endswith("/")]
assert dir_lines == []
class TestAutoCommit:
def test_returns_none_when_not_initialized(self, git):
assert git.auto_commit("test") is None
def test_commits_file_change(self, git_ready):
(git_ready._workspace / "SOUL.md").write_text("updated", encoding="utf-8")
sha = git_ready.auto_commit("update soul")
assert sha is not None
assert len(sha) == 8
def test_returns_none_when_no_changes(self, git_ready):
assert git_ready.auto_commit("no change") is None
def test_commit_appears_in_log(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
sha = git_ready.auto_commit("update soul")
commits = git_ready.log()
assert len(commits) == 2
assert commits[0].sha == sha
def test_does_not_create_empty_commits(self, git_ready):
git_ready.auto_commit("nothing 1")
git_ready.auto_commit("nothing 2")
assert len(git_ready.log()) == 1 # only init commit
class TestLog:
def test_empty_when_not_initialized(self, git):
assert git.log() == []
def test_newest_first(self, git_ready):
ws = git_ready._workspace
for i in range(3):
(ws / "SOUL.md").write_text(f"v{i}", encoding="utf-8")
git_ready.auto_commit(f"commit {i}")
commits = git_ready.log()
assert len(commits) == 4 # init + 3
assert "commit 2" in commits[0].message
assert "init" in commits[-1].message
def test_max_entries(self, git_ready):
ws = git_ready._workspace
for i in range(10):
(ws / "SOUL.md").write_text(f"v{i}", encoding="utf-8")
git_ready.auto_commit(f"c{i}")
assert len(git_ready.log(max_entries=3)) == 3
def test_commit_info_fields(self, git_ready):
c = git_ready.log()[0]
assert isinstance(c, CommitInfo)
assert len(c.sha) == 8
assert c.timestamp
assert c.message
class TestDiffCommits:
def test_empty_when_not_initialized(self, git):
assert git.diff_commits("a", "b") == ""
def test_diff_between_two_commits(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("original", encoding="utf-8")
git_ready.auto_commit("v1")
(ws / "SOUL.md").write_text("modified", encoding="utf-8")
git_ready.auto_commit("v2")
commits = git_ready.log()
diff = git_ready.diff_commits(commits[1].sha, commits[0].sha)
assert "modified" in diff
def test_invalid_sha_returns_empty(self, git_ready):
assert git_ready.diff_commits("deadbeef", "cafebabe") == ""
class TestFindCommit:
def test_finds_by_prefix(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
sha = git_ready.auto_commit("v2")
found = git_ready.find_commit(sha[:4])
assert found is not None
assert found.sha == sha
def test_returns_none_for_unknown(self, git_ready):
assert git_ready.find_commit("deadbeef") is None
class TestShowCommitDiff:
def test_returns_commit_with_diff(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("content", encoding="utf-8")
sha = git_ready.auto_commit("add content")
result = git_ready.show_commit_diff(sha)
assert result is not None
commit, diff = result
assert commit.sha == sha
assert "content" in diff
def test_first_commit_has_empty_diff(self, git_ready):
init_sha = git_ready.log()[-1].sha
result = git_ready.show_commit_diff(init_sha)
assert result is not None
_, diff = result
assert diff == ""
def test_returns_none_for_unknown(self, git_ready):
assert git_ready.show_commit_diff("deadbeef") is None
class TestCommitInfoFormat:
def test_format_with_diff(self):
from nanobot.agent.git_store import CommitInfo
c = CommitInfo(sha="abcd1234", message="test commit\nsecond line", timestamp="2026-04-02 12:00")
result = c.format(diff="some diff")
assert "test commit" in result
assert "`abcd1234`" in result
assert "some diff" in result
def test_format_without_diff(self):
from nanobot.agent.git_store import CommitInfo
c = CommitInfo(sha="abcd1234", message="test", timestamp="2026-04-02 12:00")
result = c.format()
assert "(no file changes)" in result
class TestRevert:
def test_returns_none_when_not_initialized(self, git):
assert git.revert("abc") is None
def test_reverts_file_content(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("v2 content", encoding="utf-8")
git_ready.auto_commit("v2")
commits = git_ready.log()
new_sha = git_ready.revert(commits[0].sha) # undo v2 → back to init
assert new_sha is not None
assert (ws / "SOUL.md").read_text(encoding="utf-8") == ""
def test_cannot_revert_root_commit(self, git_ready):
commits = git_ready.log()
assert git_ready.revert(commits[-1].sha) is None
def test_invalid_sha_returns_none(self, git_ready):
assert git_ready.revert("deadbeef") is None
class TestMemoryStoreGitProperty:
def test_git_property_exposes_gitstore(self, tmp_path):
from nanobot.agent.memory import MemoryStore
store = MemoryStore(tmp_path)
assert isinstance(store.git, GitStore)
def test_git_property_is_same_object(self, tmp_path):
from nanobot.agent.memory import MemoryStore
store = MemoryStore(tmp_path)
assert store.git is store._git
+352
View File
@@ -0,0 +1,352 @@
"""Tests for CompositeHook fan-out, error isolation, and integration."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
def _ctx() -> AgentHookContext:
return AgentHookContext(iteration=0, messages=[])
# ---------------------------------------------------------------------------
# Fan-out: every hook is called in order
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_composite_fans_out_before_iteration():
calls: list[str] = []
class H(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
calls.append(f"A:{context.iteration}")
class H2(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
calls.append(f"B:{context.iteration}")
hook = CompositeHook([H(), H2()])
ctx = _ctx()
await hook.before_iteration(ctx)
assert calls == ["A:0", "B:0"]
@pytest.mark.asyncio
async def test_composite_fans_out_all_async_methods():
"""Verify all async methods fan out to every hook."""
events: list[str] = []
class RecordingHook(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
events.append("before_iteration")
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
events.append(f"on_stream:{delta}")
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
events.append(f"on_stream_end:{resuming}")
async def before_execute_tools(self, context: AgentHookContext) -> None:
events.append("before_execute_tools")
async def after_iteration(self, context: AgentHookContext) -> None:
events.append("after_iteration")
hook = CompositeHook([RecordingHook(), RecordingHook()])
ctx = _ctx()
await hook.before_iteration(ctx)
await hook.on_stream(ctx, "hi")
await hook.on_stream_end(ctx, resuming=True)
await hook.before_execute_tools(ctx)
await hook.after_iteration(ctx)
assert events == [
"before_iteration", "before_iteration",
"on_stream:hi", "on_stream:hi",
"on_stream_end:True", "on_stream_end:True",
"before_execute_tools", "before_execute_tools",
"after_iteration", "after_iteration",
]
# ---------------------------------------------------------------------------
# Error isolation: one hook raises, others still run
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_composite_error_isolation_before_iteration():
calls: list[str] = []
class Bad(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
raise RuntimeError("boom")
class Good(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
calls.append("good")
hook = CompositeHook([Bad(), Good()])
await hook.before_iteration(_ctx())
assert calls == ["good"]
@pytest.mark.asyncio
async def test_composite_error_isolation_on_stream():
calls: list[str] = []
class Bad(AgentHook):
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
raise RuntimeError("stream-boom")
class Good(AgentHook):
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
calls.append(delta)
hook = CompositeHook([Bad(), Good()])
await hook.on_stream(_ctx(), "delta")
assert calls == ["delta"]
@pytest.mark.asyncio
async def test_composite_error_isolation_all_async():
"""Error isolation for on_stream_end, before_execute_tools, after_iteration."""
calls: list[str] = []
class Bad(AgentHook):
async def on_stream_end(self, context, *, resuming):
raise RuntimeError("err")
async def before_execute_tools(self, context):
raise RuntimeError("err")
async def after_iteration(self, context):
raise RuntimeError("err")
class Good(AgentHook):
async def on_stream_end(self, context, *, resuming):
calls.append("on_stream_end")
async def before_execute_tools(self, context):
calls.append("before_execute_tools")
async def after_iteration(self, context):
calls.append("after_iteration")
hook = CompositeHook([Bad(), Good()])
ctx = _ctx()
await hook.on_stream_end(ctx, resuming=False)
await hook.before_execute_tools(ctx)
await hook.after_iteration(ctx)
assert calls == ["on_stream_end", "before_execute_tools", "after_iteration"]
# ---------------------------------------------------------------------------
# finalize_content: pipeline semantics (no error isolation)
# ---------------------------------------------------------------------------
def test_composite_finalize_content_pipeline():
class Upper(AgentHook):
def finalize_content(self, context, content):
return content.upper() if content else content
class Suffix(AgentHook):
def finalize_content(self, context, content):
return (content + "!") if content else content
hook = CompositeHook([Upper(), Suffix()])
result = hook.finalize_content(_ctx(), "hello")
assert result == "HELLO!"
def test_composite_finalize_content_none_passthrough():
hook = CompositeHook([AgentHook()])
assert hook.finalize_content(_ctx(), None) is None
def test_composite_finalize_content_ordering():
"""First hook transforms first, result feeds second hook."""
steps: list[str] = []
class H1(AgentHook):
def finalize_content(self, context, content):
steps.append(f"H1:{content}")
return content.upper()
class H2(AgentHook):
def finalize_content(self, context, content):
steps.append(f"H2:{content}")
return content + "!"
hook = CompositeHook([H1(), H2()])
result = hook.finalize_content(_ctx(), "hi")
assert result == "HI!"
assert steps == ["H1:hi", "H2:HI"]
# ---------------------------------------------------------------------------
# wants_streaming: any-semantics
# ---------------------------------------------------------------------------
def test_composite_wants_streaming_any_true():
class No(AgentHook):
def wants_streaming(self):
return False
class Yes(AgentHook):
def wants_streaming(self):
return True
hook = CompositeHook([No(), Yes(), No()])
assert hook.wants_streaming() is True
def test_composite_wants_streaming_all_false():
hook = CompositeHook([AgentHook(), AgentHook()])
assert hook.wants_streaming() is False
def test_composite_wants_streaming_empty():
hook = CompositeHook([])
assert hook.wants_streaming() is False
# ---------------------------------------------------------------------------
# Empty hooks list: behaves like no-op AgentHook
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_composite_empty_hooks_no_ops():
hook = CompositeHook([])
ctx = _ctx()
await hook.before_iteration(ctx)
await hook.on_stream(ctx, "delta")
await hook.on_stream_end(ctx, resuming=False)
await hook.before_execute_tools(ctx)
await hook.after_iteration(ctx)
assert hook.finalize_content(ctx, "test") == "test"
# ---------------------------------------------------------------------------
# Integration: AgentLoop with extra hooks
# ---------------------------------------------------------------------------
def _make_loop(tmp_path, hooks=None):
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation.max_tokens = 4096
with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr, \
patch("nanobot.agent.loop.Consolidator"), \
patch("nanobot.agent.loop.Dream"):
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, hooks=hooks,
)
return loop
@pytest.mark.asyncio
async def test_agent_loop_extra_hook_receives_calls(tmp_path):
"""Extra hook passed to AgentLoop is called alongside core LoopHook."""
from nanobot.providers.base import LLMResponse
events: list[str] = []
class TrackingHook(AgentHook):
async def before_iteration(self, context):
events.append(f"before_iter:{context.iteration}")
async def after_iteration(self, context):
events.append(f"after_iter:{context.iteration}")
loop = _make_loop(tmp_path, hooks=[TrackingHook()])
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="done", tool_calls=[], usage={})
)
loop.tools.get_definitions = MagicMock(return_value=[])
content, tools_used, messages = await loop._run_agent_loop(
[{"role": "user", "content": "hi"}]
)
assert content == "done"
assert "before_iter:0" in events
assert "after_iter:0" in events
@pytest.mark.asyncio
async def test_agent_loop_extra_hook_error_isolation(tmp_path):
"""A faulty extra hook does not crash the agent loop."""
from nanobot.providers.base import LLMResponse
class BadHook(AgentHook):
async def before_iteration(self, context):
raise RuntimeError("I am broken")
loop = _make_loop(tmp_path, hooks=[BadHook()])
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="still works", tool_calls=[], usage={})
)
loop.tools.get_definitions = MagicMock(return_value=[])
content, _, _ = await loop._run_agent_loop(
[{"role": "user", "content": "hi"}]
)
assert content == "still works"
@pytest.mark.asyncio
async def test_agent_loop_extra_hooks_do_not_swallow_loop_hook_errors(tmp_path):
"""Extra hooks must not change the core LoopHook failure behavior."""
from nanobot.providers.base import LLMResponse, ToolCallRequest
loop = _make_loop(tmp_path, hooks=[AgentHook()])
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="working",
tool_calls=[ToolCallRequest(id="c1", name="list_dir", arguments={"path": "."})],
usage={},
))
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.execute = AsyncMock(return_value="ok")
async def bad_progress(*args, **kwargs):
raise RuntimeError("progress failed")
with pytest.raises(RuntimeError, match="progress failed"):
await loop._run_agent_loop([], on_progress=bad_progress)
@pytest.mark.asyncio
async def test_agent_loop_no_hooks_backward_compat(tmp_path):
"""Without hooks param, behavior is identical to before."""
from nanobot.providers.base import LLMResponse, ToolCallRequest
loop = _make_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="working",
tool_calls=[ToolCallRequest(id="c1", name="list_dir", arguments={"path": "."})],
))
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.execute = AsyncMock(return_value="ok")
loop.max_iterations = 2
content, tools_used, _ = await loop._run_agent_loop([])
assert content == (
"I reached the maximum number of tool call iterations (2) "
"without completing the task. You can try breaking the task into smaller steps."
)
assert tools_used == ["list_dir", "list_dir"]
+138
View File
@@ -0,0 +1,138 @@
"""Tests for AgentLoop._dispatch streaming metadata passthrough."""
from unittest.mock import AsyncMock, patch
import pytest
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
def _make_inbound(**meta) -> InboundMessage:
meta.setdefault("_wants_stream", True)
return InboundMessage(
channel="telegram",
sender_id="user1",
chat_id="chat1",
content="hello",
metadata=meta,
)
@pytest.mark.asyncio
async def test_on_stream_forwards_message_metadata() -> None:
"""on_stream should include original message metadata (e.g. message_thread_id)."""
from nanobot.agent.loop import AgentLoop
bus = MessageBus()
msg = _make_inbound(message_thread_id="42")
loop = AgentLoop.__new__(AgentLoop)
loop.bus = bus
loop._session_locks = {}
loop._concurrency_gate = None
async def fake_process_message(msg_in, **kwargs):
on_stream = kwargs.get("on_stream")
on_stream_end = kwargs.get("on_stream_end")
if on_stream:
await on_stream("hello")
if on_stream_end:
await on_stream_end()
return OutboundMessage(
channel=msg_in.channel, chat_id=msg_in.chat_id,
content="done", metadata=msg_in.metadata,
)
with patch.object(loop, "_process_message", side_effect=fake_process_message):
await loop._dispatch(msg)
# Collect all outbound messages (stream delta, stream end, final response)
outbound: list[OutboundMessage] = []
while not bus.outbound.empty():
outbound.append(await bus.outbound.get())
stream_msg = next(m for m in outbound if m.metadata.get("_stream_delta"))
assert stream_msg.metadata["message_thread_id"] == "42"
assert stream_msg.metadata["_stream_delta"] is True
assert "_stream_id" in stream_msg.metadata
@pytest.mark.asyncio
async def test_on_stream_end_forwards_message_metadata() -> None:
"""on_stream_end should include original message metadata."""
from nanobot.agent.loop import AgentLoop
bus = MessageBus()
msg = _make_inbound(message_thread_id="42")
loop = AgentLoop.__new__(AgentLoop)
loop.bus = bus
loop._session_locks = {}
loop._concurrency_gate = None
async def fake_process_message(msg_in, **kwargs):
on_stream = kwargs.get("on_stream")
on_stream_end = kwargs.get("on_stream_end")
if on_stream:
await on_stream("hello")
if on_stream_end:
await on_stream_end()
return OutboundMessage(
channel=msg_in.channel, chat_id=msg_in.chat_id,
content="done", metadata=msg_in.metadata,
)
with patch.object(loop, "_process_message", side_effect=fake_process_message):
await loop._dispatch(msg)
outbound: list[OutboundMessage] = []
while not bus.outbound.empty():
outbound.append(await bus.outbound.get())
end_msg = next(m for m in outbound if m.metadata.get("_stream_end"))
assert end_msg.metadata["message_thread_id"] == "42"
assert end_msg.metadata["_stream_end"] is True
assert end_msg.metadata["_resuming"] is False
assert "_stream_id" in end_msg.metadata
@pytest.mark.asyncio
async def test_streaming_preserves_arbitrary_metadata_keys() -> None:
"""Both streaming callbacks should forward all original metadata keys untouched."""
from nanobot.agent.loop import AgentLoop
bus = MessageBus()
msg = _make_inbound(message_thread_id="99", custom_flag="abc", reply_to_id="msg77")
loop = AgentLoop.__new__(AgentLoop)
loop.bus = bus
loop._session_locks = {}
loop._concurrency_gate = None
async def fake_process_message(msg_in, **kwargs):
on_stream = kwargs.get("on_stream")
on_stream_end = kwargs.get("on_stream_end")
if on_stream:
await on_stream("hi")
if on_stream_end:
await on_stream_end()
return OutboundMessage(
channel=msg_in.channel, chat_id=msg_in.chat_id,
content="done", metadata=msg_in.metadata,
)
with patch.object(loop, "_process_message", side_effect=fake_process_message):
await loop._dispatch(msg)
outbound: list[OutboundMessage] = []
while not bus.outbound.empty():
outbound.append(await bus.outbound.get())
stream_msg = next(m for m in outbound if m.metadata.get("_stream_delta"))
for key in ("message_thread_id", "custom_flag", "reply_to_id"):
assert stream_msg.metadata[key] == msg.metadata[key]
end_msg = next(m for m in outbound if m.metadata.get("_stream_end"))
for key in ("message_thread_id", "custom_flag", "reply_to_id"):
assert end_msg.metadata[key] == msg.metadata[key]
+18 -18
View File
@@ -26,24 +26,24 @@ def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -
context_window_tokens=context_window_tokens, context_window_tokens=context_window_tokens,
) )
loop.tools.get_definitions = MagicMock(return_value=[]) loop.tools.get_definitions = MagicMock(return_value=[])
loop.memory_consolidator._SAFETY_BUFFER = 0 loop.consolidator._SAFETY_BUFFER = 0
return loop return loop
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_below_threshold_does_not_consolidate(tmp_path) -> None: async def test_prompt_below_threshold_does_not_consolidate(tmp_path) -> None:
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign] loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
await loop.process_direct("hello", session_key="cli:test") await loop.process_direct("hello", session_key="cli:test")
loop.memory_consolidator.consolidate_messages.assert_not_awaited() loop.consolidator.archive.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_above_threshold_triggers_consolidation(tmp_path, monkeypatch) -> None: async def test_prompt_above_threshold_triggers_consolidation(tmp_path, monkeypatch) -> None:
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign] loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"}, {"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
@@ -55,13 +55,13 @@ async def test_prompt_above_threshold_triggers_consolidation(tmp_path, monkeypat
await loop.process_direct("hello", session_key="cli:test") await loop.process_direct("hello", session_key="cli:test")
assert loop.memory_consolidator.consolidate_messages.await_count >= 1 assert loop.consolidator.archive.await_count >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path, monkeypatch) -> None: async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path, monkeypatch) -> None:
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign] loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
@@ -76,9 +76,9 @@ async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path
token_map = {"u1": 120, "a1": 120, "u2": 120, "a2": 120, "u3": 120} token_map = {"u1": 120, "a1": 120, "u2": 120, "a2": 120, "u3": 120}
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda message: token_map[message["content"]]) monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda message: token_map[message["content"]])
await loop.memory_consolidator.maybe_consolidate_by_tokens(session) await loop.consolidator.maybe_consolidate_by_tokens(session)
archived_chunk = loop.memory_consolidator.consolidate_messages.await_args.args[0] archived_chunk = loop.consolidator.archive.await_args.args[0]
assert [message["content"] for message in archived_chunk] == ["u1", "a1", "u2", "a2"] assert [message["content"] for message in archived_chunk] == ["u1", "a1", "u2", "a2"]
assert session.last_consolidated == 4 assert session.last_consolidated == 4
@@ -87,7 +87,7 @@ async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path
async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> None: async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> None:
"""Verify maybe_consolidate_by_tokens keeps looping until under threshold.""" """Verify maybe_consolidate_by_tokens keeps looping until under threshold."""
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign] loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
@@ -110,12 +110,12 @@ async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> No
return (300, "test") return (300, "test")
return (80, "test") return (80, "test")
loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign] loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100) monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.memory_consolidator.maybe_consolidate_by_tokens(session) await loop.consolidator.maybe_consolidate_by_tokens(session)
assert loop.memory_consolidator.consolidate_messages.await_count == 2 assert loop.consolidator.archive.await_count == 2
assert session.last_consolidated == 6 assert session.last_consolidated == 6
@@ -123,7 +123,7 @@ async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> No
async def test_consolidation_continues_below_trigger_until_half_target(tmp_path, monkeypatch) -> None: async def test_consolidation_continues_below_trigger_until_half_target(tmp_path, monkeypatch) -> None:
"""Once triggered, consolidation should continue until it drops below half threshold.""" """Once triggered, consolidation should continue until it drops below half threshold."""
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign] loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.messages = [ session.messages = [
@@ -147,12 +147,12 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
return (150, "test") return (150, "test")
return (80, "test") return (80, "test")
loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign] loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100) monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.memory_consolidator.maybe_consolidate_by_tokens(session) await loop.consolidator.maybe_consolidate_by_tokens(session)
assert loop.memory_consolidator.consolidate_messages.await_count == 2 assert loop.consolidator.archive.await_count == 2
assert session.last_consolidated == 6 assert session.last_consolidated == 6
@@ -166,7 +166,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
async def track_consolidate(messages): async def track_consolidate(messages):
order.append("consolidate") order.append("consolidate")
return True return True
loop.memory_consolidator.consolidate_messages = track_consolidate # type: ignore[method-assign] loop.consolidator.archive = track_consolidate # type: ignore[method-assign]
async def track_llm(*args, **kwargs): async def track_llm(*args, **kwargs):
order.append("llm") order.append("llm")
@@ -187,7 +187,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
def mock_estimate(_session): def mock_estimate(_session):
call_count[0] += 1 call_count[0] += 1
return (1000 if call_count[0] <= 1 else 80, "test") return (1000 if call_count[0] <= 1 else 80, "test")
loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign] loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
await loop.process_direct("hello", session_key="cli:test") await loop.process_direct("hello", session_key="cli:test")
@@ -1,478 +0,0 @@
"""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
import pytest
from nanobot.agent.memory import MemoryStore
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
def _make_messages(message_count: int = 30):
"""Create a list of mock messages."""
return [
{"role": "user", "content": f"msg{i}", "timestamp": "2026-01-01 00:00"}
for i in range(message_count)
]
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 ScriptedProvider(LLMProvider):
def __init__(self, responses: list[LLMResponse]):
super().__init__()
self._responses = list(responses)
self.calls = 0
async def chat(self, *args, **kwargs) -> LLMResponse:
self.calls += 1
if self._responses:
return self._responses.pop(0)
return LLMResponse(content="", tool_calls=[])
def get_default_model(self) -> str:
return "test-model"
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.",
)
)
provider.chat_with_retry = provider.chat
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
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"]},
)
)
provider.chat_with_retry = provider.chat
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
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()
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)
provider.chat_with_retry = provider.chat
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
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=[])
)
provider.chat_with_retry = provider.chat
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is False
assert not store.history_file.exists()
@pytest.mark.asyncio
async def test_skips_when_message_chunk_is_empty(self, tmp_path: Path) -> None:
"""Consolidation should be a no-op when the selected chunk is empty."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat_with_retry = provider.chat
messages: list[dict] = []
result = await store.consolidate(messages, provider, "test-model")
assert result is True
provider.chat.assert_not_called()
@pytest.mark.asyncio
async def test_list_arguments_extracts_first_dict(self, tmp_path: Path) -> None:
"""Some providers return arguments as a list - extract first element if it's a dict."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
response = LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="save_memory",
arguments=[{
"history_entry": "[2026-01-01] User discussed testing.",
"memory_update": "# Memory\nUser likes testing.",
}],
)
],
)
provider.chat = AsyncMock(return_value=response)
provider.chat_with_retry = provider.chat
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is True
assert "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_list_arguments_empty_list_returns_false(self, tmp_path: Path) -> None:
"""Empty list arguments should return False."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
response = LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="save_memory",
arguments=[],
)
],
)
provider.chat = AsyncMock(return_value=response)
provider.chat_with_retry = provider.chat
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is False
@pytest.mark.asyncio
async def test_list_arguments_non_dict_content_returns_false(self, tmp_path: Path) -> None:
"""List with non-dict content should return False."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
response = LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="save_memory",
arguments=["string", "content"],
)
],
)
provider.chat = AsyncMock(return_value=response)
provider.chat_with_retry = provider.chat
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is False
@pytest.mark.asyncio
async def test_missing_history_entry_returns_false_without_writing(self, tmp_path: Path) -> None:
"""Do not persist partial results when required fields are missing."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="save_memory",
arguments={"memory_update": "# Memory\nOnly memory update"},
)
],
)
)
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is False
assert not store.history_file.exists()
assert not store.memory_file.exists()
@pytest.mark.asyncio
async def test_missing_memory_update_returns_false_without_writing(self, tmp_path: Path) -> None:
"""Do not append history if memory_update is missing."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="save_memory",
arguments={"history_entry": "[2026-01-01] Partial output."},
)
],
)
)
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is False
assert not store.history_file.exists()
assert not store.memory_file.exists()
@pytest.mark.asyncio
async def test_null_required_field_returns_false_without_writing(self, tmp_path: Path) -> None:
"""Null required fields should be rejected before persistence."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(
return_value=_make_tool_response(
history_entry=None,
memory_update="# Memory\nUser likes testing.",
)
)
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is False
assert not store.history_file.exists()
assert not store.memory_file.exists()
@pytest.mark.asyncio
async def test_empty_history_entry_returns_false_without_writing(self, tmp_path: Path) -> None:
"""Empty history entries should be rejected to avoid blank archival records."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(
return_value=_make_tool_response(
history_entry=" ",
memory_update="# Memory\nUser likes testing.",
)
)
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is False
assert not store.history_file.exists()
assert not store.memory_file.exists()
@pytest.mark.asyncio
async def test_retries_transient_error_then_succeeds(self, tmp_path: Path, monkeypatch) -> None:
store = MemoryStore(tmp_path)
provider = ScriptedProvider([
LLMResponse(content="503 server error", finish_reason="error"),
_make_tool_response(
history_entry="[2026-01-01] User discussed testing.",
memory_update="# Memory\nUser likes testing.",
),
])
messages = _make_messages(message_count=60)
delays: list[int] = []
async def _fake_sleep(delay: int) -> None:
delays.append(delay)
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
result = await store.consolidate(messages, provider, "test-model")
assert result is True
assert provider.calls == 2
assert delays == [1]
@pytest.mark.asyncio
async def test_consolidation_delegates_to_provider_defaults(self, tmp_path: Path) -> None:
"""Consolidation no longer passes generation params — the provider owns them."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(
return_value=_make_tool_response(
history_entry="[2026-01-01] User discussed testing.",
memory_update="# Memory\nUser likes testing.",
)
)
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is True
provider.chat_with_retry.assert_awaited_once()
_, kwargs = provider.chat_with_retry.await_args
assert kwargs["model"] == "test-model"
assert "temperature" not in kwargs
assert "max_tokens" not in kwargs
assert "reasoning_effort" not in kwargs
@pytest.mark.asyncio
async def test_tool_choice_fallback_on_unsupported_error(self, tmp_path: Path) -> None:
"""Forced tool_choice rejected by provider -> retry with auto and succeed."""
store = MemoryStore(tmp_path)
error_resp = LLMResponse(
content="Error calling LLM: BadRequestError: "
"The tool_choice parameter does not support being set to required or object",
finish_reason="error",
tool_calls=[],
)
ok_resp = _make_tool_response(
history_entry="[2026-01-01] Fallback worked.",
memory_update="# Memory\nFallback OK.",
)
call_log: list[dict] = []
async def _tracking_chat(**kwargs):
call_log.append(kwargs)
return error_resp if len(call_log) == 1 else ok_resp
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(side_effect=_tracking_chat)
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is True
assert len(call_log) == 2
assert isinstance(call_log[0]["tool_choice"], dict)
assert call_log[1]["tool_choice"] == "auto"
assert "Fallback worked." in store.history_file.read_text()
@pytest.mark.asyncio
async def test_tool_choice_fallback_auto_no_tool_call(self, tmp_path: Path) -> None:
"""Forced rejected, auto retry also produces no tool call -> return False."""
store = MemoryStore(tmp_path)
error_resp = LLMResponse(
content="Error: tool_choice must be none or auto",
finish_reason="error",
tool_calls=[],
)
no_tool_resp = LLMResponse(
content="Here is a summary.",
finish_reason="stop",
tool_calls=[],
)
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(side_effect=[error_resp, no_tool_resp])
messages = _make_messages(message_count=60)
result = await store.consolidate(messages, provider, "test-model")
assert result is False
assert not store.history_file.exists()
@pytest.mark.asyncio
async def test_raw_archive_after_consecutive_failures(self, tmp_path: Path) -> None:
"""After 3 consecutive failures, raw-archive messages and return True."""
store = MemoryStore(tmp_path)
no_tool = LLMResponse(content="No tool call.", finish_reason="stop", tool_calls=[])
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(return_value=no_tool)
messages = _make_messages(message_count=10)
assert await store.consolidate(messages, provider, "m") is False
assert await store.consolidate(messages, provider, "m") is False
assert await store.consolidate(messages, provider, "m") is True
assert store.history_file.exists()
content = store.history_file.read_text()
assert "[RAW]" in content
assert "10 messages" in content
assert "msg0" in content
assert not store.memory_file.exists()
@pytest.mark.asyncio
async def test_raw_archive_counter_resets_on_success(self, tmp_path: Path) -> None:
"""A successful consolidation resets the failure counter."""
store = MemoryStore(tmp_path)
no_tool = LLMResponse(content="Nope.", finish_reason="stop", tool_calls=[])
ok_resp = _make_tool_response(
history_entry="[2026-01-01] OK.",
memory_update="# Memory\nOK.",
)
messages = _make_messages(message_count=10)
provider = AsyncMock()
provider.chat_with_retry = AsyncMock(return_value=no_tool)
assert await store.consolidate(messages, provider, "m") is False
assert await store.consolidate(messages, provider, "m") is False
assert store._consecutive_failures == 2
provider.chat_with_retry = AsyncMock(return_value=ok_resp)
assert await store.consolidate(messages, provider, "m") is True
assert store._consecutive_failures == 0
provider.chat_with_retry = AsyncMock(return_value=no_tool)
assert await store.consolidate(messages, provider, "m") is False
assert store._consecutive_failures == 1
+133
View File
@@ -0,0 +1,133 @@
"""Tests for the restructured MemoryStore — pure file I/O layer."""
import json
import pytest
from pathlib import Path
from nanobot.agent.memory import MemoryStore
@pytest.fixture
def store(tmp_path):
return MemoryStore(tmp_path)
class TestMemoryStoreBasicIO:
def test_read_memory_returns_empty_when_missing(self, store):
assert store.read_memory() == ""
def test_write_and_read_memory(self, store):
store.write_memory("hello")
assert store.read_memory() == "hello"
def test_read_soul_returns_empty_when_missing(self, store):
assert store.read_soul() == ""
def test_write_and_read_soul(self, store):
store.write_soul("soul content")
assert store.read_soul() == "soul content"
def test_read_user_returns_empty_when_missing(self, store):
assert store.read_user() == ""
def test_write_and_read_user(self, store):
store.write_user("user content")
assert store.read_user() == "user content"
def test_get_memory_context_returns_empty_when_missing(self, store):
assert store.get_memory_context() == ""
def test_get_memory_context_returns_formatted_content(self, store):
store.write_memory("important fact")
ctx = store.get_memory_context()
assert "Long-term Memory" in ctx
assert "important fact" in ctx
class TestHistoryWithCursor:
def test_append_history_returns_cursor(self, store):
cursor = store.append_history("event 1")
assert cursor == 1
cursor2 = store.append_history("event 2")
assert cursor2 == 2
def test_append_history_includes_cursor_in_file(self, store):
store.append_history("event 1")
content = store.read_file(store.history_file)
data = json.loads(content)
assert data["cursor"] == 1
def test_cursor_persists_across_appends(self, store):
store.append_history("event 1")
store.append_history("event 2")
cursor = store.append_history("event 3")
assert cursor == 3
def test_read_unprocessed_history(self, store):
store.append_history("event 1")
store.append_history("event 2")
store.append_history("event 3")
entries = store.read_unprocessed_history(since_cursor=1)
assert len(entries) == 2
assert entries[0]["cursor"] == 2
def test_read_unprocessed_history_returns_all_when_cursor_zero(self, store):
store.append_history("event 1")
store.append_history("event 2")
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 2
def test_compact_history_drops_oldest(self, tmp_path):
store = MemoryStore(tmp_path, max_history_entries=2)
store.append_history("event 1")
store.append_history("event 2")
store.append_history("event 3")
store.append_history("event 4")
store.append_history("event 5")
store.compact_history()
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 2
assert entries[0]["cursor"] in {4, 5}
class TestDreamCursor:
def test_initial_cursor_is_zero(self, store):
assert store.get_last_dream_cursor() == 0
def test_set_and_get_cursor(self, store):
store.set_last_dream_cursor(5)
assert store.get_last_dream_cursor() == 5
def test_cursor_persists(self, store):
store.set_last_dream_cursor(3)
store2 = MemoryStore(store.workspace)
assert store2.get_last_dream_cursor() == 3
class TestDreamLog:
def test_read_dream_log_returns_empty_when_missing(self, store):
assert store.read_dream_log() == ""
def test_append_dream_log(self, store):
store.append_dream_log("## 2026-03-30\nProcessed entries #1-#5")
log = store.read_dream_log()
assert "Processed entries #1-#5" in log
def test_append_dream_log_is_additive(self, store):
store.append_dream_log("first run")
store.append_dream_log("second run")
log = store.read_dream_log()
assert "first run" in log
assert "second run" in log
class TestLegacyHistoryMigration:
def test_read_unprocessed_history_handles_entries_without_cursor(self, store):
"""JSONL entries with cursor=1 are correctly parsed and returned."""
store.history_file.write_text(
'{"cursor": 1, "timestamp": "2026-03-30 14:30", "content": "Old event"}\n',
encoding="utf-8")
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert entries[0]["cursor"] == 1
+79
View File
@@ -333,3 +333,82 @@ async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, mon
args = mgr._announce_result.await_args.args args = mgr._announce_result.await_args.args
assert args[3] == "Task completed but no final response was generated." assert args[3] == "Task completed but no final response was generated."
assert args[5] == "ok" assert args[5] == "ok"
@pytest.mark.asyncio
async def test_runner_accumulates_usage_and_preserves_cached_tokens():
"""Runner should accumulate prompt/completion tokens across iterations
and preserve cached_tokens from provider responses."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
call_count = {"n": 0}
async def chat_with_retry(*, messages, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
return LLMResponse(
content="thinking",
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "x"})],
usage={"prompt_tokens": 100, "completion_tokens": 10, "cached_tokens": 80},
)
return LLMResponse(
content="done",
tool_calls=[],
usage={"prompt_tokens": 200, "completion_tokens": 20, "cached_tokens": 150},
)
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="file content")
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=3,
))
# Usage should be accumulated across iterations
assert result.usage["prompt_tokens"] == 300 # 100 + 200
assert result.usage["completion_tokens"] == 30 # 10 + 20
assert result.usage["cached_tokens"] == 230 # 80 + 150
@pytest.mark.asyncio
async def test_runner_passes_cached_tokens_to_hook_context():
"""Hook context.usage should contain cached_tokens."""
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
captured_usage: list[dict] = []
class UsageHook(AgentHook):
async def after_iteration(self, context: AgentHookContext) -> None:
captured_usage.append(dict(context.usage))
async def chat_with_retry(**kwargs):
return LLMResponse(
content="done",
tool_calls=[],
usage={"prompt_tokens": 200, "completion_tokens": 20, "cached_tokens": 150},
)
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner(provider)
await runner.run(AgentRunSpec(
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
hook=UsageHook(),
))
assert len(captured_usage) == 1
assert captured_usage[0]["cached_tokens"] == 150
+34
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -222,6 +223,39 @@ class TestSubagentCancellation:
assert assistant_messages[0]["reasoning_content"] == "hidden reasoning" assert assistant_messages[0]["reasoning_content"] == "hidden reasoning"
assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}] assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}]
@pytest.mark.asyncio
async def test_subagent_exec_tool_not_registered_when_disabled(self, tmp_path):
from nanobot.agent.subagent import SubagentManager
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(
provider=provider,
workspace=tmp_path,
bus=bus,
exec_config=ExecToolConfig(enable=False),
)
mgr._announce_result = AsyncMock()
async def fake_run(spec):
assert spec.tools.get("exec") is None
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
)
mgr.runner.run = AsyncMock(side_effect=fake_run)
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
mgr.runner.run.assert_awaited_once()
mgr._announce_result.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subagent_announces_error_when_tool_execution_fails(self, monkeypatch, tmp_path): async def test_subagent_announces_error_when_tool_execution_fails(self, monkeypatch, tmp_path):
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
+676
View File
@@ -0,0 +1,676 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
import discord
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.discord import DiscordBotClient, DiscordChannel, DiscordConfig
from nanobot.command.builtin import build_help_text
# Minimal Discord client test double used to control startup/readiness behavior.
class _FakeDiscordClient:
instances: list["_FakeDiscordClient"] = []
start_error: Exception | None = None
def __init__(self, owner, *, intents) -> None:
self.owner = owner
self.intents = intents
self.closed = False
self.ready = True
self.channels: dict[int, object] = {}
self.user = SimpleNamespace(id=999)
self.__class__.instances.append(self)
async def start(self, token: str) -> None:
self.token = token
if self.__class__.start_error is not None:
raise self.__class__.start_error
async def close(self) -> None:
self.closed = True
def is_closed(self) -> bool:
return self.closed
def is_ready(self) -> bool:
return self.ready
def get_channel(self, channel_id: int):
return self.channels.get(channel_id)
async def send_outbound(self, msg: OutboundMessage) -> None:
channel = self.get_channel(int(msg.chat_id))
if channel is None:
return
await channel.send(content=msg.content)
class _FakeAttachment:
# Attachment double that can simulate successful or failing save() calls.
def __init__(self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False) -> None:
self.id = attachment_id
self.filename = filename
self.size = size
self._fail = fail
async def save(self, path: str | Path) -> None:
if self._fail:
raise RuntimeError("save failed")
Path(path).write_bytes(b"attachment")
class _FakePartialMessage:
# Lightweight stand-in for Discord partial message references used in replies.
def __init__(self, message_id: int) -> None:
self.id = message_id
class _FakeChannel:
# Channel double that records outbound payloads and typing activity.
def __init__(self, channel_id: int = 123) -> None:
self.id = channel_id
self.sent_payloads: list[dict] = []
self.trigger_typing_calls = 0
self.typing_enter_hook = None
async def send(self, **kwargs) -> None:
payload = dict(kwargs)
if "file" in payload:
payload["file_name"] = payload["file"].filename
del payload["file"]
self.sent_payloads.append(payload)
def get_partial_message(self, message_id: int) -> _FakePartialMessage:
return _FakePartialMessage(message_id)
def typing(self):
channel = self
class _TypingContext:
async def __aenter__(self):
channel.trigger_typing_calls += 1
if channel.typing_enter_hook is not None:
await channel.typing_enter_hook()
async def __aexit__(self, exc_type, exc, tb):
return False
return _TypingContext()
class _FakeInteractionResponse:
def __init__(self) -> None:
self.messages: list[dict] = []
self._done = False
async def send_message(self, content: str, *, ephemeral: bool = False) -> None:
self.messages.append({"content": content, "ephemeral": ephemeral})
self._done = True
def is_done(self) -> bool:
return self._done
def _make_interaction(
*,
user_id: int = 123,
channel_id: int | None = 456,
guild_id: int | None = None,
interaction_id: int = 999,
):
return SimpleNamespace(
user=SimpleNamespace(id=user_id),
channel_id=channel_id,
guild_id=guild_id,
id=interaction_id,
command=SimpleNamespace(qualified_name="new"),
response=_FakeInteractionResponse(),
)
def _make_message(
*,
author_id: int = 123,
author_bot: bool = False,
channel_id: int = 456,
message_id: int = 789,
content: str = "hello",
guild_id: int | None = None,
mentions: list[object] | None = None,
attachments: list[object] | None = None,
reply_to: int | None = None,
):
# Factory for incoming Discord message objects with optional guild/reply/attachments.
guild = SimpleNamespace(id=guild_id) if guild_id is not None else None
reference = SimpleNamespace(message_id=reply_to) if reply_to is not None else None
return SimpleNamespace(
author=SimpleNamespace(id=author_id, bot=author_bot),
channel=_FakeChannel(channel_id),
content=content,
guild=guild,
mentions=mentions or [],
attachments=attachments or [],
reference=reference,
id=message_id,
)
@pytest.mark.asyncio
async def test_start_returns_when_token_missing() -> None:
# If no token is configured, startup should no-op and leave channel stopped.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
await channel.start()
assert channel.is_running is False
assert channel._client is None
@pytest.mark.asyncio
async def test_start_returns_when_discord_dependency_missing(monkeypatch) -> None:
channel = DiscordChannel(
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DISCORD_AVAILABLE", False)
await channel.start()
assert channel.is_running is False
assert channel._client is None
@pytest.mark.asyncio
async def test_start_handles_client_construction_failure(monkeypatch) -> None:
# Construction errors from the Discord client should be swallowed and keep state clean.
channel = DiscordChannel(
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
MessageBus(),
)
def _boom(owner, *, intents):
raise RuntimeError("bad client")
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
await channel.start()
assert channel.is_running is False
assert channel._client is None
@pytest.mark.asyncio
async def test_start_handles_client_start_failure(monkeypatch) -> None:
# If client.start fails, the partially created client should be closed and detached.
channel = DiscordChannel(
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
MessageBus(),
)
_FakeDiscordClient.instances.clear()
_FakeDiscordClient.start_error = RuntimeError("connect failed")
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
await channel.start()
assert channel.is_running is False
assert channel._client is None
assert _FakeDiscordClient.instances[0].intents.value == channel.config.intents
assert _FakeDiscordClient.instances[0].closed is True
_FakeDiscordClient.start_error = None
@pytest.mark.asyncio
async def test_stop_is_safe_after_partial_start(monkeypatch) -> None:
# stop() should close/discard the client even when startup was only partially completed.
channel = DiscordChannel(
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
MessageBus(),
)
client = _FakeDiscordClient(channel, intents=None)
channel._client = client
channel._running = True
await channel.stop()
assert channel.is_running is False
assert client.closed is True
assert channel._client is None
@pytest.mark.asyncio
async def test_on_message_ignores_bot_messages() -> None:
# Incoming bot-authored messages must be ignored to prevent feedback loops.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
handled: list[dict] = []
channel._handle_message = lambda **kwargs: handled.append(kwargs) # type: ignore[method-assign]
await channel._on_message(_make_message(author_bot=True))
assert handled == []
# If inbound handling raises, typing should be stopped for that channel.
async def fail_handle(**kwargs) -> None:
raise RuntimeError("boom")
channel._handle_message = fail_handle # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="boom"):
await channel._on_message(_make_message(author_id=123, channel_id=456))
assert channel._typing_tasks == {}
@pytest.mark.asyncio
async def test_on_message_accepts_allowlisted_dm() -> None:
# Allowed direct messages should be forwarded with normalized metadata.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus())
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
await channel._on_message(_make_message(author_id=123, channel_id=456, message_id=789))
assert len(handled) == 1
assert handled[0]["chat_id"] == "456"
assert handled[0]["metadata"] == {"message_id": "789", "guild_id": None, "reply_to": None}
@pytest.mark.asyncio
async def test_on_message_ignores_unmentioned_guild_message() -> None:
# With mention-only group policy, guild messages without a bot mention are dropped.
channel = DiscordChannel(
DiscordConfig(enabled=True, allow_from=["*"], group_policy="mention"),
MessageBus(),
)
channel._bot_user_id = "999"
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
await channel._on_message(_make_message(guild_id=1, content="hello everyone"))
assert handled == []
@pytest.mark.asyncio
async def test_on_message_accepts_mentioned_guild_message() -> None:
# Mentioned guild messages should be accepted and preserve reply threading metadata.
channel = DiscordChannel(
DiscordConfig(enabled=True, allow_from=["*"], group_policy="mention"),
MessageBus(),
)
channel._bot_user_id = "999"
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
await channel._on_message(
_make_message(
guild_id=1,
content="<@999> hello",
mentions=[SimpleNamespace(id=999)],
reply_to=321,
)
)
assert len(handled) == 1
assert handled[0]["metadata"]["reply_to"] == "321"
@pytest.mark.asyncio
async def test_on_message_downloads_attachments(tmp_path, monkeypatch) -> None:
# Attachment downloads should be saved and referenced in forwarded content/media.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path)
await channel._on_message(
_make_message(
attachments=[_FakeAttachment(12, "photo.png")],
content="see file",
)
)
assert len(handled) == 1
assert handled[0]["media"] == [str(tmp_path / "12_photo.png")]
assert "[attachment:" in handled[0]["content"]
@pytest.mark.asyncio
async def test_on_message_marks_failed_attachment_download(tmp_path, monkeypatch) -> None:
# Failed attachment downloads should emit a readable placeholder and no media path.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path)
await channel._on_message(
_make_message(
attachments=[_FakeAttachment(12, "photo.png", fail=True)],
content="",
)
)
assert len(handled) == 1
assert handled[0]["media"] == []
assert handled[0]["content"] == "[attachment: photo.png - download failed]"
@pytest.mark.asyncio
async def test_send_warns_when_client_not_ready() -> None:
# Sending without a running/ready client should be a safe no-op.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
assert channel._typing_tasks == {}
@pytest.mark.asyncio
async def test_send_skips_when_channel_not_cached() -> None:
# Outbound sends should be skipped when the destination channel is not resolvable.
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = DiscordBotClient(owner, intents=discord.Intents.none())
fetch_calls: list[int] = []
async def fetch_channel(channel_id: int):
fetch_calls.append(channel_id)
raise RuntimeError("not found")
client.fetch_channel = fetch_channel # type: ignore[method-assign]
await client.send_outbound(OutboundMessage(channel="discord", chat_id="123", content="hello"))
assert client.get_channel(123) is None
assert fetch_calls == [123]
@pytest.mark.asyncio
async def test_send_fetches_channel_when_not_cached() -> None:
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = DiscordBotClient(owner, intents=discord.Intents.none())
target = _FakeChannel(channel_id=123)
async def fetch_channel(channel_id: int):
return target if channel_id == 123 else None
client.fetch_channel = fetch_channel # type: ignore[method-assign]
await client.send_outbound(OutboundMessage(channel="discord", chat_id="123", content="hello"))
assert target.sent_payloads == [{"content": "hello"}]
@pytest.mark.asyncio
async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus())
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
client = DiscordBotClient(channel, intents=discord.Intents.none())
interaction = _make_interaction(user_id=123, channel_id=456, interaction_id=321)
new_cmd = client.tree.get_command("new")
assert new_cmd is not None
await new_cmd.callback(interaction)
assert interaction.response.messages == [
{"content": "Processing /new...", "ephemeral": True}
]
assert len(handled) == 1
assert handled[0]["content"] == "/new"
assert handled[0]["sender_id"] == "123"
assert handled[0]["chat_id"] == "456"
assert handled[0]["metadata"]["interaction_id"] == "321"
assert handled[0]["metadata"]["is_slash_command"] is True
@pytest.mark.asyncio
async def test_slash_new_is_blocked_for_disallowed_user() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["999"]), MessageBus())
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
client = DiscordBotClient(channel, intents=discord.Intents.none())
interaction = _make_interaction(user_id=123, channel_id=456)
new_cmd = client.tree.get_command("new")
assert new_cmd is not None
await new_cmd.callback(interaction)
assert interaction.response.messages == [
{"content": "You are not allowed to use this bot.", "ephemeral": True}
]
assert handled == []
@pytest.mark.parametrize("slash_name", ["stop", "restart", "status"])
@pytest.mark.asyncio
async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
client = DiscordBotClient(channel, intents=discord.Intents.none())
interaction = _make_interaction()
interaction.command.qualified_name = slash_name
cmd = client.tree.get_command(slash_name)
assert cmd is not None
await cmd.callback(interaction)
assert interaction.response.messages == [
{"content": f"Processing /{slash_name}...", "ephemeral": True}
]
assert len(handled) == 1
assert handled[0]["content"] == f"/{slash_name}"
assert handled[0]["metadata"]["is_slash_command"] is True
@pytest.mark.asyncio
async def test_slash_help_returns_ephemeral_help_text() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
handled: list[dict] = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
client = DiscordBotClient(channel, intents=discord.Intents.none())
interaction = _make_interaction()
interaction.command.qualified_name = "help"
help_cmd = client.tree.get_command("help")
assert help_cmd is not None
await help_cmd.callback(interaction)
assert interaction.response.messages == [
{"content": build_help_text(), "ephemeral": True}
]
assert handled == []
@pytest.mark.asyncio
async def test_client_send_outbound_chunks_text_replies_and_uploads_files(tmp_path) -> None:
# Outbound payloads should upload files, attach reply references, and chunk long text.
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = DiscordBotClient(owner, intents=discord.Intents.none())
target = _FakeChannel(channel_id=123)
client.get_channel = lambda channel_id: target if channel_id == 123 else None # type: ignore[method-assign]
file_path = tmp_path / "demo.txt"
file_path.write_text("hi")
await client.send_outbound(
OutboundMessage(
channel="discord",
chat_id="123",
content="a" * 2100,
reply_to="55",
media=[str(file_path)],
)
)
assert len(target.sent_payloads) == 3
assert target.sent_payloads[0]["file_name"] == "demo.txt"
assert target.sent_payloads[0]["reference"].id == 55
assert target.sent_payloads[1]["content"] == "a" * 2000
assert target.sent_payloads[2]["content"] == "a" * 100
@pytest.mark.asyncio
async def test_client_send_outbound_reports_failed_attachments_when_no_text(tmp_path) -> None:
# If all attachment sends fail and no text exists, emit a failure placeholder message.
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = DiscordBotClient(owner, intents=discord.Intents.none())
target = _FakeChannel(channel_id=123)
client.get_channel = lambda channel_id: target if channel_id == 123 else None # type: ignore[method-assign]
missing_file = tmp_path / "missing.txt"
await client.send_outbound(
OutboundMessage(
channel="discord",
chat_id="123",
content="",
media=[str(missing_file)],
)
)
assert target.sent_payloads == [{"content": "[attachment: missing.txt - send failed]"}]
@pytest.mark.asyncio
async def test_send_stops_typing_after_send() -> None:
# Active typing indicators should be cancelled/cleared after a successful send.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = _FakeDiscordClient(channel, intents=None)
channel._client = client
channel._running = True
start = asyncio.Event()
release = asyncio.Event()
async def slow_typing() -> None:
start.set()
await release.wait()
typing_channel = _FakeChannel(channel_id=123)
typing_channel.typing_enter_hook = slow_typing
await channel._start_typing(typing_channel)
await start.wait()
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
release.set()
await asyncio.sleep(0)
assert channel._typing_tasks == {}
# Progress messages should keep typing active until a final (non-progress) send.
start = asyncio.Event()
release = asyncio.Event()
async def slow_typing_progress() -> None:
start.set()
await release.wait()
typing_channel = _FakeChannel(channel_id=123)
typing_channel.typing_enter_hook = slow_typing_progress
await channel._start_typing(typing_channel)
await start.wait()
await channel.send(
OutboundMessage(
channel="discord",
chat_id="123",
content="progress",
metadata={"_progress": True},
)
)
assert "123" in channel._typing_tasks
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="final"))
release.set()
await asyncio.sleep(0)
assert channel._typing_tasks == {}
@pytest.mark.asyncio
async def test_start_typing_uses_typing_context_when_trigger_typing_missing() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
channel._running = True
entered = asyncio.Event()
release = asyncio.Event()
class _TypingCtx:
async def __aenter__(self):
entered.set()
async def __aexit__(self, exc_type, exc, tb):
return False
class _NoTriggerChannel:
def __init__(self, channel_id: int = 123) -> None:
self.id = channel_id
def typing(self):
async def _waiter():
await release.wait()
# Hold the loop so task remains active until explicitly stopped.
class _Ctx(_TypingCtx):
async def __aenter__(self):
await super().__aenter__()
await _waiter()
return _Ctx()
typing_channel = _NoTriggerChannel(channel_id=123)
await channel._start_typing(typing_channel) # type: ignore[arg-type]
await entered.wait()
assert "123" in channel._typing_tasks
await channel._stop_typing("123")
release.set()
await asyncio.sleep(0)
assert channel._typing_tasks == {}
+238
View File
@@ -0,0 +1,238 @@
"""Tests for Feishu reaction add/remove and auto-cleanup on stream end."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
def _make_channel() -> FeishuChannel:
config = FeishuConfig(
enabled=True,
app_id="cli_test",
app_secret="secret",
allow_from=["*"],
)
ch = FeishuChannel(config, MessageBus())
ch._client = MagicMock()
ch._loop = None
return ch
def _mock_reaction_create_response(reaction_id: str = "reaction_001", success: bool = True):
resp = MagicMock()
resp.success.return_value = success
resp.code = 0 if success else 99999
resp.msg = "ok" if success else "error"
if success:
resp.data = SimpleNamespace(reaction_id=reaction_id)
else:
resp.data = None
return resp
# ── _add_reaction_sync ──────────────────────────────────────────────────────
class TestAddReactionSync:
def test_returns_reaction_id_on_success(self):
ch = _make_channel()
ch._client.im.v1.message_reaction.create.return_value = _mock_reaction_create_response("rx_42")
result = ch._add_reaction_sync("om_001", "THUMBSUP")
assert result == "rx_42"
def test_returns_none_when_response_fails(self):
ch = _make_channel()
ch._client.im.v1.message_reaction.create.return_value = _mock_reaction_create_response(success=False)
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
def test_returns_none_when_response_data_is_none(self):
ch = _make_channel()
resp = MagicMock()
resp.success.return_value = True
resp.data = None
ch._client.im.v1.message_reaction.create.return_value = resp
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
def test_returns_none_on_exception(self):
ch = _make_channel()
ch._client.im.v1.message_reaction.create.side_effect = RuntimeError("network error")
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
# ── _add_reaction (async) ───────────────────────────────────────────────────
class TestAddReactionAsync:
@pytest.mark.asyncio
async def test_returns_reaction_id(self):
ch = _make_channel()
ch._add_reaction_sync = MagicMock(return_value="rx_99")
result = await ch._add_reaction("om_001", "EYES")
assert result == "rx_99"
@pytest.mark.asyncio
async def test_returns_none_when_no_client(self):
ch = _make_channel()
ch._client = None
result = await ch._add_reaction("om_001", "THUMBSUP")
assert result is None
# ── _remove_reaction_sync ───────────────────────────────────────────────────
class TestRemoveReactionSync:
def test_calls_delete_on_success(self):
ch = _make_channel()
resp = MagicMock()
resp.success.return_value = True
ch._client.im.v1.message_reaction.delete.return_value = resp
ch._remove_reaction_sync("om_001", "rx_42")
ch._client.im.v1.message_reaction.delete.assert_called_once()
def test_handles_failure_gracefully(self):
ch = _make_channel()
resp = MagicMock()
resp.success.return_value = False
resp.code = 99999
resp.msg = "not found"
ch._client.im.v1.message_reaction.delete.return_value = resp
# Should not raise
ch._remove_reaction_sync("om_001", "rx_42")
def test_handles_exception_gracefully(self):
ch = _make_channel()
ch._client.im.v1.message_reaction.delete.side_effect = RuntimeError("network error")
# Should not raise
ch._remove_reaction_sync("om_001", "rx_42")
# ── _remove_reaction (async) ────────────────────────────────────────────────
class TestRemoveReactionAsync:
@pytest.mark.asyncio
async def test_calls_sync_helper(self):
ch = _make_channel()
ch._remove_reaction_sync = MagicMock()
await ch._remove_reaction("om_001", "rx_42")
ch._remove_reaction_sync.assert_called_once_with("om_001", "rx_42")
@pytest.mark.asyncio
async def test_noop_when_no_client(self):
ch = _make_channel()
ch._client = None
ch._remove_reaction_sync = MagicMock()
await ch._remove_reaction("om_001", "rx_42")
ch._remove_reaction_sync.assert_not_called()
@pytest.mark.asyncio
async def test_noop_when_reaction_id_is_empty(self):
ch = _make_channel()
ch._remove_reaction_sync = MagicMock()
await ch._remove_reaction("om_001", "")
ch._remove_reaction_sync.assert_not_called()
@pytest.mark.asyncio
async def test_noop_when_reaction_id_is_none(self):
ch = _make_channel()
ch._remove_reaction_sync = MagicMock()
await ch._remove_reaction("om_001", None)
ch._remove_reaction_sync.assert_not_called()
# ── send_delta stream end: reaction auto-cleanup ────────────────────────────
class TestStreamEndReactionCleanup:
@pytest.mark.asyncio
async def test_removes_reaction_on_stream_end(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True, "message_id": "om_001", "reaction_id": "rx_42"},
)
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
@pytest.mark.asyncio
async def test_no_removal_when_message_id_missing(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True, "reaction_id": "rx_42"},
)
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_reaction_id_missing(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "",
metadata={"_stream_end": True, "message_id": "om_001"},
)
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_both_ids_missing(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_not_stream_end(self):
ch = _make_channel()
ch._remove_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "more text",
metadata={"message_id": "om_001", "reaction_id": "rx_42"},
)
ch._remove_reaction.assert_not_called()
+224 -1
View File
@@ -3,6 +3,9 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
from nio import RoomSendResponse
from nanobot.channels.matrix import _build_matrix_text_content
# Check optional matrix dependencies before importing # Check optional matrix dependencies before importing
try: try:
@@ -65,6 +68,7 @@ class _FakeAsyncClient:
self.raise_on_send = False self.raise_on_send = False
self.raise_on_typing = False self.raise_on_typing = False
self.raise_on_upload = False self.raise_on_upload = False
self.room_send_response: RoomSendResponse | None = RoomSendResponse(event_id="", room_id="")
def add_event_callback(self, callback, event_type) -> None: def add_event_callback(self, callback, event_type) -> None:
self.callbacks.append((callback, event_type)) self.callbacks.append((callback, event_type))
@@ -87,7 +91,7 @@ class _FakeAsyncClient:
message_type: str, message_type: str,
content: dict[str, object], content: dict[str, object],
ignore_unverified_devices: object = _ROOM_SEND_UNSET, ignore_unverified_devices: object = _ROOM_SEND_UNSET,
) -> None: ) -> RoomSendResponse:
call: dict[str, object] = { call: dict[str, object] = {
"room_id": room_id, "room_id": room_id,
"message_type": message_type, "message_type": message_type,
@@ -98,6 +102,7 @@ class _FakeAsyncClient:
self.room_send_calls.append(call) self.room_send_calls.append(call)
if self.raise_on_send: if self.raise_on_send:
raise RuntimeError("send failed") raise RuntimeError("send failed")
return self.room_send_response
async def room_typing( async def room_typing(
self, self,
@@ -520,6 +525,7 @@ async def test_on_message_room_mention_requires_opt_in() -> None:
source={"content": {"m.mentions": {"room": True}}}, source={"content": {"m.mentions": {"room": True}}},
) )
channel.config.allow_room_mentions = False
await channel._on_message(room, room_mention_event) await channel._on_message(room, room_mention_event)
assert handled == [] assert handled == []
assert client.typing_calls == [] assert client.typing_calls == []
@@ -1322,3 +1328,220 @@ async def test_send_keeps_plaintext_only_for_plain_text() -> None:
"body": text, "body": text,
"m.mentions": {}, "m.mentions": {},
} }
def test_build_matrix_text_content_basic_text() -> None:
"""Test basic text content without HTML formatting."""
result = _build_matrix_text_content("Hello, World!")
expected = {
"msgtype": "m.text",
"body": "Hello, World!",
"m.mentions": {}
}
assert expected == result
def test_build_matrix_text_content_with_markdown() -> None:
"""Test text content with markdown that renders to HTML."""
text = "*Hello* **World**"
result = _build_matrix_text_content(text)
assert "msgtype" in result
assert "body" in result
assert result["body"] == text
assert "format" in result
assert result["format"] == "org.matrix.custom.html"
assert "formatted_body" in result
assert isinstance(result["formatted_body"], str)
assert len(result["formatted_body"]) > 0
def test_build_matrix_text_content_with_event_id() -> None:
"""Test text content with event_id for message replacement."""
event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
result = _build_matrix_text_content("Updated message", event_id)
assert "msgtype" in result
assert "body" in result
assert result["m.new_content"]
assert result["m.new_content"]["body"] == "Updated message"
assert result["m.relates_to"]["rel_type"] == "m.replace"
assert result["m.relates_to"]["event_id"] == event_id
def test_build_matrix_text_content_no_event_id() -> None:
"""Test that when event_id is not provided, no extra properties are added."""
result = _build_matrix_text_content("Regular message")
# Basic required properties should be present
assert "msgtype" in result
assert "body" in result
assert result["body"] == "Regular message"
# Extra properties for replacement should NOT be present
assert "m.relates_to" not in result
assert "m.new_content" not in result
assert "format" not in result
assert "formatted_body" not in result
def test_build_matrix_text_content_plain_text_no_html() -> None:
"""Test plain text that should not include HTML formatting."""
result = _build_matrix_text_content("Simple plain text")
assert "msgtype" in result
assert "body" in result
assert "format" not in result
assert "formatted_body" not in result
@pytest.mark.asyncio
async def test_send_room_content_returns_room_send_response():
"""Test that _send_room_content returns the response from client.room_send."""
client = _FakeAsyncClient("", "", "", None)
channel = MatrixChannel(_make_config(), MessageBus())
channel.client = client
room_id = "!test_room:matrix.org"
content = {"msgtype": "m.text", "body": "Hello World"}
result = await channel._send_room_content(room_id, content)
assert result is client.room_send_response
@pytest.mark.asyncio
async def test_send_delta_creates_stream_buffer_and_sends_initial_message() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
await channel.send_delta("!room:matrix.org", "Hello")
assert "!room:matrix.org" in channel._stream_bufs
buf = channel._stream_bufs["!room:matrix.org"]
assert buf.text == "Hello"
assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
assert len(client.room_send_calls) == 1
assert client.room_send_calls[0]["content"]["body"] == "Hello"
@pytest.mark.asyncio
async def test_send_delta_appends_without_sending_before_edit_interval(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
now = 100.0
monkeypatch.setattr(channel, "monotonic_time", lambda: now)
await channel.send_delta("!room:matrix.org", "Hello")
assert len(client.room_send_calls) == 1
await channel.send_delta("!room:matrix.org", " world")
assert len(client.room_send_calls) == 1
buf = channel._stream_bufs["!room:matrix.org"]
assert buf.text == "Hello world"
assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
@pytest.mark.asyncio
async def test_send_delta_edits_again_after_interval(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo"
times = [100.0, 102.0, 104.0, 106.0, 108.0]
times.reverse()
monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop())
await channel.send_delta("!room:matrix.org", "Hello")
await channel.send_delta("!room:matrix.org", " world")
assert len(client.room_send_calls) == 2
first_content = client.room_send_calls[0]["content"]
second_content = client.room_send_calls[1]["content"]
assert "body" in first_content
assert first_content["body"] == "Hello"
assert "m.relates_to" not in first_content
assert "body" in second_content
assert "m.relates_to" in second_content
assert second_content["body"] == "Hello world"
assert second_content["m.relates_to"] == {
"rel_type": "m.replace",
"event_id": "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo",
}
@pytest.mark.asyncio
async def test_send_delta_stream_end_replaces_existing_message() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._stream_bufs["!room:matrix.org"] = matrix_module._StreamBuf(
text="Final text",
event_id="event-1",
last_edit=100.0,
)
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True})
assert "!room:matrix.org" not in channel._stream_bufs
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
assert len(client.room_send_calls) == 1
assert client.room_send_calls[0]["content"]["body"] == "Final text"
assert client.room_send_calls[0]["content"]["m.relates_to"] == {
"rel_type": "m.replace",
"event_id": "event-1",
}
@pytest.mark.asyncio
async def test_send_delta_stream_end_noop_when_buffer_missing() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
await channel.send_delta("!room:matrix.org", "", {"_stream_end": True})
assert client.room_send_calls == []
assert client.typing_calls == []
@pytest.mark.asyncio
async def test_send_delta_on_error_stops_typing(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
client.raise_on_send = True
channel.client = client
now = 100.0
monkeypatch.setattr(channel, "monotonic_time", lambda: now)
await channel.send_delta("!room:matrix.org", "Hello", {"room_id": "!room:matrix.org"})
assert "!room:matrix.org" in channel._stream_bufs
assert channel._stream_bufs["!room:matrix.org"].text == "Hello"
assert len(client.room_send_calls) == 1
assert len(client.typing_calls) == 1
@pytest.mark.asyncio
async def test_send_delta_ignores_whitespace_only_delta(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
now = 100.0
monkeypatch.setattr(channel, "monotonic_time", lambda: now)
await channel.send_delta("!room:matrix.org", " ")
assert "!room:matrix.org" in channel._stream_bufs
assert channel._stream_bufs["!room:matrix.org"].text == " "
assert client.room_send_calls == []
+172
View File
@@ -0,0 +1,172 @@
"""Tests for QQ channel ack_message feature.
Covers the four verification points from the PR:
1. C2C message: ack appears instantly
2. Group message: ack appears instantly
3. ack_message set to "": no ack sent
4. Custom ack_message text: correct text delivered
Each test also verifies that normal message processing is not blocked.
"""
from types import SimpleNamespace
import pytest
try:
from nanobot.channels import qq
QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False)
except ImportError:
QQ_AVAILABLE = False
if not QQ_AVAILABLE:
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
from nanobot.bus.queue import MessageBus
from nanobot.channels.qq import QQChannel, QQConfig
class _FakeApi:
def __init__(self) -> None:
self.c2c_calls: list[dict] = []
self.group_calls: list[dict] = []
async def post_c2c_message(self, **kwargs) -> None:
self.c2c_calls.append(kwargs)
async def post_group_message(self, **kwargs) -> None:
self.group_calls.append(kwargs)
class _FakeClient:
def __init__(self) -> None:
self.api = _FakeApi()
@pytest.mark.asyncio
async def test_ack_sent_on_c2c_message() -> None:
"""Ack is sent immediately for C2C messages, then normal processing continues."""
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["*"],
ack_message="⏳ Processing...",
),
MessageBus(),
)
channel._client = _FakeClient()
data = SimpleNamespace(
id="msg1",
content="hello",
author=SimpleNamespace(user_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=False)
assert len(channel._client.api.c2c_calls) >= 1
ack_call = channel._client.api.c2c_calls[0]
assert ack_call["content"] == "⏳ Processing..."
assert ack_call["openid"] == "user1"
assert ack_call["msg_id"] == "msg1"
assert ack_call["msg_type"] == 0
msg = await channel.bus.consume_inbound()
assert msg.content == "hello"
assert msg.sender_id == "user1"
@pytest.mark.asyncio
async def test_ack_sent_on_group_message() -> None:
"""Ack is sent immediately for group messages, then normal processing continues."""
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["*"],
ack_message="⏳ Processing...",
),
MessageBus(),
)
channel._client = _FakeClient()
data = SimpleNamespace(
id="msg2",
content="hello group",
group_openid="group123",
author=SimpleNamespace(member_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=True)
assert len(channel._client.api.group_calls) >= 1
ack_call = channel._client.api.group_calls[0]
assert ack_call["content"] == "⏳ Processing..."
assert ack_call["group_openid"] == "group123"
assert ack_call["msg_id"] == "msg2"
assert ack_call["msg_type"] == 0
msg = await channel.bus.consume_inbound()
assert msg.content == "hello group"
assert msg.chat_id == "group123"
@pytest.mark.asyncio
async def test_no_ack_when_ack_message_empty() -> None:
"""Setting ack_message to empty string disables the ack entirely."""
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["*"],
ack_message="",
),
MessageBus(),
)
channel._client = _FakeClient()
data = SimpleNamespace(
id="msg3",
content="hello",
author=SimpleNamespace(user_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=False)
assert len(channel._client.api.c2c_calls) == 0
assert len(channel._client.api.group_calls) == 0
msg = await channel.bus.consume_inbound()
assert msg.content == "hello"
@pytest.mark.asyncio
async def test_custom_ack_message_text() -> None:
"""Custom Chinese ack_message text is delivered correctly."""
custom = "正在处理中,请稍候..."
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["*"],
ack_message=custom,
),
MessageBus(),
)
channel._client = _FakeClient()
data = SimpleNamespace(
id="msg4",
content="test input",
author=SimpleNamespace(user_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=False)
assert len(channel._client.api.c2c_calls) >= 1
ack_call = channel._client.api.c2c_calls[0]
assert ack_call["content"] == custom
msg = await channel.bus.consume_inbound()
assert msg.content == "test input"
+26 -13
View File
@@ -647,43 +647,56 @@ async def test_group_policy_open_accepts_plain_group_message() -> None:
assert channel._app.bot.get_me_calls == 0 assert channel._app.bot.get_me_calls == 0
def test_extract_reply_context_no_reply() -> None: @pytest.mark.asyncio
async def test_extract_reply_context_no_reply() -> None:
"""When there is no reply_to_message, _extract_reply_context returns None.""" """When there is no reply_to_message, _extract_reply_context returns None."""
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
message = SimpleNamespace(reply_to_message=None) message = SimpleNamespace(reply_to_message=None)
assert TelegramChannel._extract_reply_context(message) is None assert await channel._extract_reply_context(message) is None
def test_extract_reply_context_with_text() -> None: @pytest.mark.asyncio
async def test_extract_reply_context_with_text() -> None:
"""When reply has text, return prefixed string.""" """When reply has text, return prefixed string."""
reply = SimpleNamespace(text="Hello world", caption=None) channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
channel._app = _FakeApp(lambda: None)
reply = SimpleNamespace(text="Hello world", caption=None, from_user=SimpleNamespace(id=2, username="testuser", first_name="Test"))
message = SimpleNamespace(reply_to_message=reply) message = SimpleNamespace(reply_to_message=reply)
assert TelegramChannel._extract_reply_context(message) == "[Reply to: Hello world]" assert await channel._extract_reply_context(message) == "[Reply to @testuser: Hello world]"
def test_extract_reply_context_with_caption_only() -> None: @pytest.mark.asyncio
async def test_extract_reply_context_with_caption_only() -> None:
"""When reply has only caption (no text), caption is used.""" """When reply has only caption (no text), caption is used."""
reply = SimpleNamespace(text=None, caption="Photo caption") channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
channel._app = _FakeApp(lambda: None)
reply = SimpleNamespace(text=None, caption="Photo caption", from_user=SimpleNamespace(id=2, username=None, first_name="Test"))
message = SimpleNamespace(reply_to_message=reply) message = SimpleNamespace(reply_to_message=reply)
assert TelegramChannel._extract_reply_context(message) == "[Reply to: Photo caption]" assert await channel._extract_reply_context(message) == "[Reply to Test: Photo caption]"
def test_extract_reply_context_truncation() -> None: @pytest.mark.asyncio
async def test_extract_reply_context_truncation() -> None:
"""Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN.""" """Reply text is truncated at TELEGRAM_REPLY_CONTEXT_MAX_LEN."""
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
channel._app = _FakeApp(lambda: None)
long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100) long_text = "x" * (TELEGRAM_REPLY_CONTEXT_MAX_LEN + 100)
reply = SimpleNamespace(text=long_text, caption=None) reply = SimpleNamespace(text=long_text, caption=None, from_user=SimpleNamespace(id=2, username=None, first_name=None))
message = SimpleNamespace(reply_to_message=reply) message = SimpleNamespace(reply_to_message=reply)
result = TelegramChannel._extract_reply_context(message) result = await channel._extract_reply_context(message)
assert result is not None assert result is not None
assert result.startswith("[Reply to: ") assert result.startswith("[Reply to: ")
assert result.endswith("...]") assert result.endswith("...]")
assert len(result) == len("[Reply to: ]") + TELEGRAM_REPLY_CONTEXT_MAX_LEN + len("...") assert len(result) == len("[Reply to: ]") + TELEGRAM_REPLY_CONTEXT_MAX_LEN + len("...")
def test_extract_reply_context_no_text_returns_none() -> None: @pytest.mark.asyncio
async def test_extract_reply_context_no_text_returns_none() -> None:
"""When reply has no text/caption, _extract_reply_context returns None (media handled separately).""" """When reply has no text/caption, _extract_reply_context returns None (media handled separately)."""
channel = TelegramChannel(TelegramConfig(enabled=True, token="123:abc"), MessageBus())
reply = SimpleNamespace(text=None, caption=None) reply = SimpleNamespace(text=None, caption=None)
message = SimpleNamespace(reply_to_message=reply) message = SimpleNamespace(reply_to_message=reply)
assert TelegramChannel._extract_reply_context(message) is None assert await channel._extract_reply_context(message) is None
@pytest.mark.asyncio @pytest.mark.asyncio
+26
View File
@@ -145,3 +145,29 @@ def test_response_renderable_without_metadata_keeps_markdown_path():
renderable = commands._response_renderable(help_text, render_markdown=True) renderable = commands._response_renderable(help_text, render_markdown=True)
assert renderable.__class__.__name__ == "Markdown" assert renderable.__class__.__name__ == "Markdown"
def test_stream_renderer_stop_for_input_stops_spinner():
"""stop_for_input should stop the active spinner to avoid prompt_toolkit conflicts."""
spinner = MagicMock()
mock_console = MagicMock()
mock_console.status.return_value = spinner
# Create renderer with mocked console
with patch.object(stream_mod, "_make_console", return_value=mock_console):
renderer = stream_mod.StreamRenderer(show_spinner=True)
# Verify spinner started
spinner.start.assert_called_once()
# Stop for input
renderer.stop_for_input()
# Verify spinner stopped
spinner.stop.assert_called_once()
def test_make_console_uses_force_terminal():
"""Console should be created with force_terminal=True for proper ANSI handling."""
console = stream_mod._make_console()
assert console._force_terminal is True
+184 -78
View File
@@ -642,27 +642,105 @@ def test_heartbeat_retains_recent_messages_by_default():
assert config.gateway.heartbeat.keep_recent_messages == 8 assert config.gateway.heartbeat.keep_recent_messages == 8
def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None: def _write_instance_config(tmp_path: Path) -> Path:
config_file = tmp_path / "instance" / "config.json" config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True) config_file.parent.mkdir(parents=True)
config_file.write_text("{}") config_file.write_text("{}")
return config_file
config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
seen: dict[str, Path] = {}
def _stop_gateway_provider(_config) -> object:
raise _StopGatewayError("stop")
def _patch_cli_command_runtime(
monkeypatch,
config: Config,
*,
set_config_path=None,
sync_templates=None,
make_provider=None,
message_bus=None,
session_manager=None,
cron_service=None,
get_cron_dir=None,
) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.config.loader.set_config_path", "nanobot.config.loader.set_config_path",
lambda path: seen.__setitem__("config_path", path), set_config_path or (lambda _path: None),
) )
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.commands.sync_workspace_templates", "nanobot.cli.commands.sync_workspace_templates",
lambda path: seen.__setitem__("workspace", path), sync_templates or (lambda _path: None),
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.commands._make_provider", "nanobot.cli.commands._make_provider",
lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")), make_provider or (lambda _config: object()),
)
if message_bus is not None:
monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus)
if session_manager is not None:
monkeypatch.setattr("nanobot.session.manager.SessionManager", session_manager)
if cron_service is not None:
monkeypatch.setattr("nanobot.cron.service.CronService", cron_service)
if get_cron_dir is not None:
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", get_cron_dir)
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
pytest.importorskip("aiohttp")
class _FakeApiApp:
def __init__(self) -> None:
self.on_startup: list[object] = []
self.on_cleanup: list[object] = []
class _FakeAgentLoop:
def __init__(self, **kwargs) -> None:
seen["workspace"] = kwargs["workspace"]
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
def _fake_create_app(agent_loop, model_name: str, request_timeout: float):
seen["agent_loop"] = agent_loop
seen["model_name"] = model_name
seen["request_timeout"] = request_timeout
return _FakeApiApp()
def _fake_run_app(api_app, host: str, port: int, print):
seen["api_app"] = api_app
seen["host"] = host
seen["port"] = port
_patch_cli_command_runtime(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
)
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
seen: dict[str, Path] = {}
_patch_cli_command_runtime(
monkeypatch,
config,
set_config_path=lambda path: seen.__setitem__("config_path", path),
sync_templates=lambda path: seen.__setitem__("workspace", path),
make_provider=_stop_gateway_provider,
) )
result = runner.invoke(app, ["gateway", "--config", str(config_file)]) result = runner.invoke(app, ["gateway", "--config", str(config_file)])
@@ -673,24 +751,17 @@ def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Pa
def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None: def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance" / "config.json" config_file = _write_instance_config(tmp_path)
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
config = Config() config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace") config.agents.defaults.workspace = str(tmp_path / "config-workspace")
override = tmp_path / "override-workspace" override = tmp_path / "override-workspace"
seen: dict[str, Path] = {} seen: dict[str, Path] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) _patch_cli_command_runtime(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch,
monkeypatch.setattr( config,
"nanobot.cli.commands.sync_workspace_templates", sync_templates=lambda path: seen.__setitem__("workspace", path),
lambda path: seen.__setitem__("workspace", path), make_provider=_stop_gateway_provider,
)
monkeypatch.setattr(
"nanobot.cli.commands._make_provider",
lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")),
) )
result = runner.invoke( result = runner.invoke(
@@ -704,27 +775,23 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path)
def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance" / "config.json" config_file = _write_instance_config(tmp_path)
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
config = Config() config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace") config.agents.defaults.workspace = str(tmp_path / "config-workspace")
seen: dict[str, Path] = {} seen: dict[str, Path] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
class _StopCron: class _StopCron:
def __init__(self, store_path: Path) -> None: def __init__(self, store_path: Path) -> None:
seen["cron_store"] = store_path seen["cron_store"] = store_path
raise _StopGatewayError("stop") raise _StopGatewayError("stop")
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) _patch_cli_command_runtime(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
cron_service=_StopCron,
)
result = runner.invoke(app, ["gateway", "--config", str(config_file)]) result = runner.invoke(app, ["gateway", "--config", str(config_file)])
@@ -735,10 +802,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path:
def test_gateway_workspace_override_does_not_migrate_legacy_cron( def test_gateway_workspace_override_does_not_migrate_legacy_cron(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
config_file = tmp_path / "instance" / "config.json" config_file = _write_instance_config(tmp_path)
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
legacy_dir = tmp_path / "global" / "cron" legacy_dir = tmp_path / "global" / "cron"
legacy_dir.mkdir(parents=True) legacy_dir.mkdir(parents=True)
legacy_file = legacy_dir / "jobs.json" legacy_file = legacy_dir / "jobs.json"
@@ -748,20 +812,19 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron(
config = Config() config = Config()
seen: dict[str, Path] = {} seen: dict[str, Path] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
class _StopCron: class _StopCron:
def __init__(self, store_path: Path) -> None: def __init__(self, store_path: Path) -> None:
seen["cron_store"] = store_path seen["cron_store"] = store_path
raise _StopGatewayError("stop") raise _StopGatewayError("stop")
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) _patch_cli_command_runtime(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
cron_service=_StopCron,
get_cron_dir=lambda: legacy_dir,
)
result = runner.invoke( result = runner.invoke(
app, app,
@@ -777,10 +840,7 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron(
def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron( def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
config_file = tmp_path / "instance" / "config.json" config_file = _write_instance_config(tmp_path)
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
legacy_dir = tmp_path / "global" / "cron" legacy_dir = tmp_path / "global" / "cron"
legacy_dir.mkdir(parents=True) legacy_dir.mkdir(parents=True)
legacy_file = legacy_dir / "jobs.json" legacy_file = legacy_dir / "jobs.json"
@@ -791,20 +851,19 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
config.agents.defaults.workspace = str(custom_workspace) config.agents.defaults.workspace = str(custom_workspace)
seen: dict[str, Path] = {} seen: dict[str, Path] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
class _StopCron: class _StopCron:
def __init__(self, store_path: Path) -> None: def __init__(self, store_path: Path) -> None:
seen["cron_store"] = store_path seen["cron_store"] = store_path
raise _StopGatewayError("stop") raise _StopGatewayError("stop")
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) _patch_cli_command_runtime(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
cron_service=_StopCron,
get_cron_dir=lambda: legacy_dir,
)
result = runner.invoke(app, ["gateway", "--config", str(config_file)]) result = runner.invoke(app, ["gateway", "--config", str(config_file)])
@@ -856,19 +915,14 @@ def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) ->
def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None: def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance" / "config.json" config_file = _write_instance_config(tmp_path)
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
config = Config() config = Config()
config.gateway.port = 18791 config.gateway.port = 18791
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) _patch_cli_command_runtime(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch,
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) config,
monkeypatch.setattr( make_provider=_stop_gateway_provider,
"nanobot.cli.commands._make_provider",
lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")),
) )
result = runner.invoke(app, ["gateway", "--config", str(config_file)]) result = runner.invoke(app, ["gateway", "--config", str(config_file)])
@@ -878,19 +932,14 @@ def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_
def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) -> None: def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance" / "config.json" config_file = _write_instance_config(tmp_path)
config_file.parent.mkdir(parents=True)
config_file.write_text("{}")
config = Config() config = Config()
config.gateway.port = 18791 config.gateway.port = 18791
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) _patch_cli_command_runtime(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch,
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) config,
monkeypatch.setattr( make_provider=_stop_gateway_provider,
"nanobot.cli.commands._make_provider",
lambda _config: (_ for _ in ()).throw(_StopGatewayError("stop")),
) )
result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"]) result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"])
@@ -899,6 +948,63 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path)
assert "port 18792" in result.stdout assert "port 18792" in result.stdout
def test_serve_uses_api_config_defaults_and_workspace_override(
monkeypatch, tmp_path: Path
) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
config.api.host = "127.0.0.2"
config.api.port = 18900
config.api.timeout = 45.0
override_workspace = tmp_path / "override-workspace"
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(
app,
["serve", "--config", str(config_file), "--workspace", str(override_workspace)],
)
assert result.exit_code == 0
assert seen["workspace"] == override_workspace
assert seen["host"] == "127.0.0.2"
assert seen["port"] == 18900
assert seen["request_timeout"] == 45.0
def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
config.api.host = "127.0.0.2"
config.api.port = 18900
config.api.timeout = 45.0
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(
app,
[
"serve",
"--config",
str(config_file),
"--host",
"127.0.0.1",
"--port",
"18901",
"--timeout",
"46",
],
)
assert result.exit_code == 0
assert seen["host"] == "127.0.0.1"
assert seen["port"] == 18901
assert seen["request_timeout"] == 46.0
def test_channels_login_requires_channel_name() -> None: def test_channels_login_requires_channel_name() -> None:
result = runner.invoke(app, ["channels", "login"]) result = runner.invoke(app, ["channels", "login"])
+6 -4
View File
@@ -127,7 +127,7 @@ class TestRestartCommand:
loop.sessions.get_or_create.return_value = session loop.sessions.get_or_create.return_value = session
loop._start_time = time.time() - 125 loop._start_time = time.time() - 125
loop._last_usage = {"prompt_tokens": 0, "completion_tokens": 0} loop._last_usage = {"prompt_tokens": 0, "completion_tokens": 0}
loop.memory_consolidator.estimate_session_prompt_tokens = MagicMock( loop.consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(20500, "tiktoken") return_value=(20500, "tiktoken")
) )
@@ -152,10 +152,12 @@ class TestRestartCommand:
]) ])
await loop._run_agent_loop([]) await loop._run_agent_loop([])
assert loop._last_usage == {"prompt_tokens": 9, "completion_tokens": 4} assert loop._last_usage["prompt_tokens"] == 9
assert loop._last_usage["completion_tokens"] == 4
await loop._run_agent_loop([]) await loop._run_agent_loop([])
assert loop._last_usage == {"prompt_tokens": 0, "completion_tokens": 0} assert loop._last_usage["prompt_tokens"] == 0
assert loop._last_usage["completion_tokens"] == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_falls_back_to_last_usage_when_context_estimate_missing(self): async def test_status_falls_back_to_last_usage_when_context_estimate_missing(self):
@@ -164,7 +166,7 @@ class TestRestartCommand:
session.get_history.return_value = [{"role": "user"}] session.get_history.return_value = [{"role": "user"}]
loop.sessions.get_or_create.return_value = session loop.sessions.get_or_create.return_value = session
loop._last_usage = {"prompt_tokens": 1200, "completion_tokens": 34} loop._last_usage = {"prompt_tokens": 1200, "completion_tokens": 34}
loop.memory_consolidator.estimate_session_prompt_tokens = MagicMock( loop.consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(0, "none") return_value=(0, "none")
) )
+231
View File
@@ -0,0 +1,231 @@
"""Tests for cached token extraction from OpenAI-compatible providers."""
from __future__ import annotations
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
class FakeUsage:
"""Mimics an OpenAI SDK usage object (has attributes, not dict keys)."""
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class FakePromptDetails:
"""Mimics prompt_tokens_details sub-object."""
def __init__(self, cached_tokens=0):
self.cached_tokens = cached_tokens
class _FakeSpec:
supports_prompt_caching = False
model_id_prefix = None
strip_model_prefix = False
max_completion_tokens = False
reasoning_effort = None
def _provider():
from unittest.mock import MagicMock
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
p.client = MagicMock()
p.spec = _FakeSpec()
return p
# Minimal valid choice so _parse reaches _extract_usage.
_DICT_CHOICE = {"message": {"content": "Hello"}}
class _FakeMessage:
content = "Hello"
tool_calls = None
class _FakeChoice:
message = _FakeMessage()
finish_reason = "stop"
# --- dict-based response (raw JSON / mapping) ---
def test_extract_usage_openai_cached_tokens_dict():
"""prompt_tokens_details.cached_tokens from a dict response."""
p = _provider()
response = {
"choices": [_DICT_CHOICE],
"usage": {
"prompt_tokens": 2000,
"completion_tokens": 300,
"total_tokens": 2300,
"prompt_tokens_details": {"cached_tokens": 1200},
}
}
result = p._parse(response)
assert result.usage["cached_tokens"] == 1200
assert result.usage["prompt_tokens"] == 2000
def test_extract_usage_deepseek_cached_tokens_dict():
"""prompt_cache_hit_tokens from a DeepSeek dict response."""
p = _provider()
response = {
"choices": [_DICT_CHOICE],
"usage": {
"prompt_tokens": 1500,
"completion_tokens": 200,
"total_tokens": 1700,
"prompt_cache_hit_tokens": 1200,
"prompt_cache_miss_tokens": 300,
}
}
result = p._parse(response)
assert result.usage["cached_tokens"] == 1200
def test_extract_usage_no_cached_tokens_dict():
"""Response without any cache fields -> no cached_tokens key."""
p = _provider()
response = {
"choices": [_DICT_CHOICE],
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 200,
"total_tokens": 1200,
}
}
result = p._parse(response)
assert "cached_tokens" not in result.usage
def test_extract_usage_openai_cached_zero_dict():
"""cached_tokens=0 should NOT be included (same as existing fields)."""
p = _provider()
response = {
"choices": [_DICT_CHOICE],
"usage": {
"prompt_tokens": 2000,
"completion_tokens": 300,
"total_tokens": 2300,
"prompt_tokens_details": {"cached_tokens": 0},
}
}
result = p._parse(response)
assert "cached_tokens" not in result.usage
# --- object-based response (OpenAI SDK Pydantic model) ---
def test_extract_usage_openai_cached_tokens_obj():
"""prompt_tokens_details.cached_tokens from an SDK object response."""
p = _provider()
usage_obj = FakeUsage(
prompt_tokens=2000,
completion_tokens=300,
total_tokens=2300,
prompt_tokens_details=FakePromptDetails(cached_tokens=1200),
)
response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj)
result = p._parse(response)
assert result.usage["cached_tokens"] == 1200
def test_extract_usage_deepseek_cached_tokens_obj():
"""prompt_cache_hit_tokens from a DeepSeek SDK object response."""
p = _provider()
usage_obj = FakeUsage(
prompt_tokens=1500,
completion_tokens=200,
total_tokens=1700,
prompt_cache_hit_tokens=1200,
)
response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj)
result = p._parse(response)
assert result.usage["cached_tokens"] == 1200
def test_extract_usage_stepfun_top_level_cached_tokens_dict():
"""StepFun/Moonshot: usage.cached_tokens at top level (not nested)."""
p = _provider()
response = {
"choices": [_DICT_CHOICE],
"usage": {
"prompt_tokens": 591,
"completion_tokens": 120,
"total_tokens": 711,
"cached_tokens": 512,
}
}
result = p._parse(response)
assert result.usage["cached_tokens"] == 512
def test_extract_usage_stepfun_top_level_cached_tokens_obj():
"""StepFun/Moonshot: usage.cached_tokens as SDK object attribute."""
p = _provider()
usage_obj = FakeUsage(
prompt_tokens=591,
completion_tokens=120,
total_tokens=711,
cached_tokens=512,
)
response = FakeUsage(choices=[_FakeChoice()], usage=usage_obj)
result = p._parse(response)
assert result.usage["cached_tokens"] == 512
def test_extract_usage_priority_nested_over_top_level_dict():
"""When both nested and top-level cached_tokens exist, nested wins."""
p = _provider()
response = {
"choices": [_DICT_CHOICE],
"usage": {
"prompt_tokens": 2000,
"completion_tokens": 300,
"total_tokens": 2300,
"prompt_tokens_details": {"cached_tokens": 100},
"cached_tokens": 500,
}
}
result = p._parse(response)
assert result.usage["cached_tokens"] == 100
def test_anthropic_maps_cache_fields_to_cached_tokens():
"""Anthropic's cache_read_input_tokens should map to cached_tokens."""
from nanobot.providers.anthropic_provider import AnthropicProvider
usage_obj = FakeUsage(
input_tokens=800,
output_tokens=200,
cache_creation_input_tokens=0,
cache_read_input_tokens=1200,
)
content_block = FakeUsage(type="text", text="hello")
response = FakeUsage(
id="msg_1",
type="message",
stop_reason="end_turn",
content=[content_block],
usage=usage_obj,
)
result = AnthropicProvider._parse_response(response)
assert result.usage["cached_tokens"] == 1200
assert result.usage["prompt_tokens"] == 800
def test_anthropic_no_cache_fields():
"""Anthropic response without cache fields should not have cached_tokens."""
from nanobot.providers.anthropic_provider import AnthropicProvider
usage_obj = FakeUsage(input_tokens=800, output_tokens=200)
content_block = FakeUsage(type="text", text="hello")
response = FakeUsage(
id="msg_1",
type="message",
stop_reason="end_turn",
content=[content_block],
usage=usage_obj,
)
result = AnthropicProvider._parse_response(response)
assert "cached_tokens" not in result.usage
@@ -0,0 +1,34 @@
"""Regression tests for max_completion_tokens selection in OpenAI-compatible providers."""
from unittest.mock import patch
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import find_by_name
def test_openai_provider_uses_max_completion_tokens_when_supported():
"""OpenAI registry spec should drive max_completion_tokens payload selection."""
spec = find_by_name("openai")
assert spec is not None
assert spec.supports_max_completion_tokens is True
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(
api_key="test-key",
api_base=None,
default_model="gpt-4.1",
spec=spec,
)
payload = provider._build_kwargs(
messages=[{"role": "user", "content": "Hello"}],
tools=None,
model="gpt-4.1",
max_tokens=1234,
temperature=0.2,
reasoning_effort=None,
tool_choice=None,
)
assert payload["max_completion_tokens"] == 1234
assert "max_tokens" not in payload
+59
View File
@@ -0,0 +1,59 @@
"""Tests for build_status_content cache hit rate display."""
from nanobot.utils.helpers import build_status_content
def test_status_shows_cache_hit_rate():
content = build_status_content(
version="0.1.0",
model="glm-4-plus",
start_time=1000000.0,
last_usage={"prompt_tokens": 2000, "completion_tokens": 300, "cached_tokens": 1200},
context_window_tokens=128000,
session_msg_count=10,
context_tokens_estimate=5000,
)
assert "60% cached" in content
assert "2000 in / 300 out" in content
def test_status_no_cache_info():
"""Without cached_tokens, display should not show cache percentage."""
content = build_status_content(
version="0.1.0",
model="glm-4-plus",
start_time=1000000.0,
last_usage={"prompt_tokens": 2000, "completion_tokens": 300},
context_window_tokens=128000,
session_msg_count=10,
context_tokens_estimate=5000,
)
assert "cached" not in content.lower()
assert "2000 in / 300 out" in content
def test_status_zero_cached_tokens():
"""cached_tokens=0 should not show cache percentage."""
content = build_status_content(
version="0.1.0",
model="glm-4-plus",
start_time=1000000.0,
last_usage={"prompt_tokens": 2000, "completion_tokens": 300, "cached_tokens": 0},
context_window_tokens=128000,
session_msg_count=10,
context_tokens_estimate=5000,
)
assert "cached" not in content.lower()
def test_status_100_percent_cached():
content = build_status_content(
version="0.1.0",
model="glm-4-plus",
start_time=1000000.0,
last_usage={"prompt_tokens": 1000, "completion_tokens": 100, "cached_tokens": 1000},
context_window_tokens=128000,
session_msg_count=5,
context_tokens_estimate=3000,
)
assert "100% cached" in content
+147
View File
@@ -0,0 +1,147 @@
"""Tests for the Nanobot programmatic facade."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.nanobot import Nanobot, RunResult
def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
data = {
"providers": {"openrouter": {"apiKey": "sk-test-key"}},
"agents": {"defaults": {"model": "openai/gpt-4.1"}},
}
if overrides:
data.update(overrides)
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps(data))
return config_path
def test_from_config_missing_file():
with pytest.raises(FileNotFoundError):
Nanobot.from_config("/nonexistent/config.json")
def test_from_config_creates_instance(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
assert bot._loop is not None
assert bot._loop.workspace == tmp_path
def test_from_config_default_path():
from nanobot.config.schema import Config
with patch("nanobot.config.loader.load_config") as mock_load, \
patch("nanobot.nanobot._make_provider") as mock_prov:
mock_load.return_value = Config()
mock_prov.return_value = MagicMock()
mock_prov.return_value.get_default_model.return_value = "test"
mock_prov.return_value.generation.max_tokens = 4096
Nanobot.from_config()
mock_load.assert_called_once_with(None)
@pytest.mark.asyncio
async def test_run_returns_result(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
from nanobot.bus.events import OutboundMessage
mock_response = OutboundMessage(
channel="cli", chat_id="direct", content="Hello back!"
)
bot._loop.process_direct = AsyncMock(return_value=mock_response)
result = await bot.run("hi")
assert isinstance(result, RunResult)
assert result.content == "Hello back!"
bot._loop.process_direct.assert_awaited_once_with("hi", session_key="sdk:default")
@pytest.mark.asyncio
async def test_run_with_hooks(tmp_path):
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
class TestHook(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
pass
mock_response = OutboundMessage(
channel="cli", chat_id="direct", content="done"
)
bot._loop.process_direct = AsyncMock(return_value=mock_response)
result = await bot.run("hi", hooks=[TestHook()])
assert result.content == "done"
assert bot._loop._extra_hooks == []
@pytest.mark.asyncio
async def test_run_hooks_restored_on_error(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
from nanobot.agent.hook import AgentHook
bot._loop.process_direct = AsyncMock(side_effect=RuntimeError("boom"))
original_hooks = bot._loop._extra_hooks
with pytest.raises(RuntimeError):
await bot.run("hi", hooks=[AgentHook()])
assert bot._loop._extra_hooks is original_hooks
@pytest.mark.asyncio
async def test_run_none_response(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
bot._loop.process_direct = AsyncMock(return_value=None)
result = await bot.run("hi")
assert result.content == ""
def test_workspace_override(tmp_path):
config_path = _write_config(tmp_path)
custom_ws = tmp_path / "custom_workspace"
custom_ws.mkdir()
bot = Nanobot.from_config(config_path, workspace=custom_ws)
assert bot._loop.workspace == custom_ws
@pytest.mark.asyncio
async def test_run_custom_session_key(tmp_path):
from nanobot.bus.events import OutboundMessage
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
mock_response = OutboundMessage(
channel="cli", chat_id="direct", content="ok"
)
bot._loop.process_direct = AsyncMock(return_value=mock_response)
await bot.run("hi", session_key="user-alice")
bot._loop.process_direct.assert_awaited_once_with("hi", session_key="user-alice")
def test_import_from_top_level():
from nanobot import Nanobot as N, RunResult as R
assert N is Nanobot
assert R is RunResult
+372
View File
@@ -0,0 +1,372 @@
"""Focused tests for the fixed-session OpenAI-compatible API."""
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from nanobot.api.server import (
API_CHAT_ID,
API_SESSION_KEY,
_chat_completion_response,
_error_json,
create_app,
handle_chat_completions,
)
try:
from aiohttp.test_utils import TestClient, TestServer
HAS_AIOHTTP = True
except ImportError:
HAS_AIOHTTP = False
pytest_plugins = ("pytest_asyncio",)
def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
agent = MagicMock()
agent.process_direct = AsyncMock(return_value=response_text)
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
return agent
@pytest.fixture
def mock_agent():
return _make_mock_agent()
@pytest.fixture
def app(mock_agent):
return create_app(mock_agent, model_name="test-model", request_timeout=10.0)
@pytest_asyncio.fixture
async def aiohttp_client():
clients: list[TestClient] = []
async def _make_client(app):
client = TestClient(TestServer(app))
await client.start_server()
clients.append(client)
return client
try:
yield _make_client
finally:
for client in clients:
await client.close()
def test_error_json() -> None:
resp = _error_json(400, "bad request")
assert resp.status == 400
body = json.loads(resp.body)
assert body["error"]["message"] == "bad request"
assert body["error"]["code"] == 400
def test_chat_completion_response() -> None:
result = _chat_completion_response("hello world", "test-model")
assert result["object"] == "chat.completion"
assert result["model"] == "test-model"
assert result["choices"][0]["message"]["content"] == "hello world"
assert result["choices"][0]["finish_reason"] == "stop"
assert result["id"].startswith("chatcmpl-")
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_missing_messages_returns_400(aiohttp_client, app) -> None:
client = await aiohttp_client(app)
resp = await client.post("/v1/chat/completions", json={"model": "test"})
assert resp.status == 400
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_no_user_message_returns_400(aiohttp_client, app) -> None:
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "system", "content": "you are a bot"}]},
)
assert resp.status == 400
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_stream_true_returns_400(aiohttp_client, app) -> None:
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hello"}], "stream": True},
)
assert resp.status == 400
body = await resp.json()
assert "stream" in body["error"]["message"].lower()
@pytest.mark.asyncio
async def test_model_mismatch_returns_400() -> None:
request = MagicMock()
request.json = AsyncMock(
return_value={
"model": "other-model",
"messages": [{"role": "user", "content": "hello"}],
}
)
request.app = {
"agent_loop": _make_mock_agent(),
"model_name": "test-model",
"request_timeout": 10.0,
"session_lock": asyncio.Lock(),
}
resp = await handle_chat_completions(request)
assert resp.status == 400
body = json.loads(resp.body)
assert "test-model" in body["error"]["message"]
@pytest.mark.asyncio
async def test_single_user_message_required() -> None:
request = MagicMock()
request.json = AsyncMock(
return_value={
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous reply"},
],
}
)
request.app = {
"agent_loop": _make_mock_agent(),
"model_name": "test-model",
"request_timeout": 10.0,
"session_lock": asyncio.Lock(),
}
resp = await handle_chat_completions(request)
assert resp.status == 400
body = json.loads(resp.body)
assert "single user message" in body["error"]["message"].lower()
@pytest.mark.asyncio
async def test_single_user_message_must_have_user_role() -> None:
request = MagicMock()
request.json = AsyncMock(
return_value={
"messages": [{"role": "system", "content": "you are a bot"}],
}
)
request.app = {
"agent_loop": _make_mock_agent(),
"model_name": "test-model",
"request_timeout": 10.0,
"session_lock": asyncio.Lock(),
}
resp = await handle_chat_completions(request)
assert resp.status == 400
body = json.loads(resp.body)
assert "single user message" in body["error"]["message"].lower()
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_agent) -> None:
app = create_app(mock_agent, model_name="test-model")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hello"}]},
)
assert resp.status == 200
body = await resp.json()
assert body["choices"][0]["message"]["content"] == "mock response"
assert body["model"] == "test-model"
mock_agent.process_direct.assert_called_once_with(
content="hello",
session_key=API_SESSION_KEY,
channel="api",
chat_id=API_CHAT_ID,
)
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
call_log: list[str] = []
async def fake_process(content, session_key="", channel="", chat_id=""):
call_log.append(session_key)
return f"reply to {content}"
agent = MagicMock()
agent.process_direct = fake_process
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
r1 = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "first"}]},
)
r2 = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "second"}]},
)
assert r1.status == 200
assert r2.status == 200
assert call_log == [API_SESSION_KEY, API_SESSION_KEY]
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
order: list[str] = []
async def slow_process(content, session_key="", channel="", chat_id=""):
order.append(f"start:{content}")
await asyncio.sleep(0.1)
order.append(f"end:{content}")
return content
agent = MagicMock()
agent.process_direct = slow_process
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
async def send(msg: str):
return await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": msg}]},
)
r1, r2 = await asyncio.gather(send("first"), send("second"))
assert r1.status == 200
assert r2.status == 200
# Verify serialization: one process must fully finish before the other starts
assert "end:second" in order or "end:first" in order
if order[0] == "start:first":
assert order.index("end:first") < order.index("start:second")
else:
assert order.index("end:second") < order.index("start:first")
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_models_endpoint(aiohttp_client, app) -> None:
client = await aiohttp_client(app)
resp = await client.get("/v1/models")
assert resp.status == 200
body = await resp.json()
assert body["object"] == "list"
assert body["data"][0]["id"] == "test-model"
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_health_endpoint(aiohttp_client, app) -> None:
client = await aiohttp_client(app)
resp = await client.get("/health")
assert resp.status == 200
body = await resp.json()
assert body["status"] == "ok"
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_multimodal_content_extracts_text(aiohttp_client, mock_agent) -> None:
app = create_app(mock_agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
],
}
]
},
)
assert resp.status == 200
mock_agent.process_direct.assert_called_once_with(
content="describe this",
session_key=API_SESSION_KEY,
channel="api",
chat_id=API_CHAT_ID,
)
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_empty_response_retry_then_success(aiohttp_client) -> None:
call_count = 0
async def sometimes_empty(content, session_key="", channel="", chat_id=""):
nonlocal call_count
call_count += 1
if call_count == 1:
return ""
return "recovered response"
agent = MagicMock()
agent.process_direct = sometimes_empty
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hello"}]},
)
assert resp.status == 200
body = await resp.json()
assert body["choices"][0]["message"]["content"] == "recovered response"
assert call_count == 2
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_empty_response_falls_back(aiohttp_client) -> None:
call_count = 0
async def always_empty(content, session_key="", channel="", chat_id=""):
nonlocal call_count
call_count += 1
return ""
agent = MagicMock()
agent.process_direct = always_empty
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
app = create_app(agent, model_name="m")
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
json={"messages": [{"role": "user", "content": "hello"}]},
)
assert resp.status == 200
body = await resp.json()
assert body["choices"][0]["message"]["content"] == "I've completed processing but have no response to give."
assert call_count == 2