Compare commits

..
Author SHA1 Message Date
Dianqi Jiandchengyongru c3b55ba289 feat(channels/feishu): add domain config for Lark global support
Add 'domain' field to FeishuConfig (Literal['feishu', 'lark'], default 'feishu').
Pass domain to lark.Client.builder() and lark.ws.Client to support Lark global
(open.larksuite.com) in addition to Feishu China (open.feishu.cn).
Existing configs default to 'feishu' for backward compatibility.

Also add documentation for domain field in README.md and add tests for
domain config.
2026-04-11 23:50:41 +08:00
chengyongruandGitHub bc4cc49a59 feat(agent): mid-turn message injection for responsive follow-ups (#2985)
* feat(agent): add mid-turn message injection for responsive follow-ups

Allow user messages sent during an active agent turn to be injected
into the running LLM context instead of being queued behind a
per-session lock. Inspired by Claude Code's mid-turn queue drain
mechanism (query.ts:1547-1643).

Key design decisions:
- Messages are injected as natural user messages between iterations,
  no tool cancellation or special system prompt needed
- Two drain checkpoints: after tool execution and after final LLM
  response ("last-mile" to prevent dropping late arrivals)
- Bounded by MAX_INJECTION_CYCLES (5) to prevent consuming the
  iteration budget on rapid follow-ups
- had_injections flag bypasses _sent_in_turn suppression so follow-up
  responses are always delivered

Closes #1609

* fix(agent): harden mid-turn injection with streaming fix, bounded queue, and message safety

- Fix streaming protocol violation: Checkpoint 2 now checks for injections
  BEFORE calling on_stream_end, passing resuming=True when injections found
  so streaming channels (Feishu) don't prematurely finalize the card
- Bound pending queue to maxsize=20 with QueueFull handling
- Add warning log when injection batch exceeds _MAX_INJECTIONS_PER_TURN
- Re-publish leftover queue messages to bus in _dispatch finally block to
  prevent silent message loss on early exit (max_iterations, tool_error, cancel)
- Fix PEP 8 blank line before dataclass and logger.info indentation
- Add 12 new tests covering drain, checkpoints, cycle cap, queue routing,
  cleanup, and leftover re-publish
2026-04-11 02:11:02 +08:00
chengyongru df6f9dd171 fix(wecom): use reply_stream for progress messages to avoid errcode=40008
The plain reply() uses cmd="reply" which does not support "text" msgtype
and causes WeCom API to return errcode=40008 (invalid message type).
Unify both progress and final text messages to use reply_stream()
(cmd="aibot_respond_msg"), differentiating via finish flag.

Fixes #2999
2026-04-10 22:20:28 +08:00
chengyongruandGitHub 6af81bc4a3 feat(agent): auto compact — proactive session compression to reduce token cost and latency (#2982)
When a user is idle for longer than a configured TTL, nanobot **proactively** compresses the session context into a summary. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary and fresh input.
2026-04-10 17:43:42 +08:00
chengyongruandchengyongru 4a33c1392b test(channels): add media support tests for QQ and WeCom channels
Cover helpers (sanitize_filename, guess media type), outbound send
(exception handling, media-then-text order, fallback), inbound message
processing (attachments, dedup, empty content), _post_base64file
payload filtering, and WeCom upload/download flows.
2026-04-10 17:05:59 +08:00
chengyongruandchengyongru 4b0fdffe39 fix(wecom): harden upload/download, extract media type helper
- Use asyncio.to_thread for file I/O to avoid blocking event loop
- Add 200MB upload size limit with early rejection
- Fix file handle leak by using context manager
- Use memoryview for upload chunking to reduce peak memory
- Add inbound download size check to prevent OOM
- Use asyncio.to_thread for write_bytes in download path
- Extract inline media_type detection to _guess_wecom_media_type()
2026-04-10 17:05:59 +08:00
chengyongruandchengyongru 4fe23a01c9 fix(wecom): harden upload and inbound media handling
- Use asyncio.to_thread for file I/O to avoid blocking event loop
- Add 200MB upload size limit with early rejection
- Fix file handle leak by using context manager
- Free raw bytes early after chunking to reduce memory pressure
- Add file attachments to media_paths (was text-only, inconsistent with image)
- Use robust _sanitize_filename() instead of os.path.basename() for path safety
- Remove re-raise in send() for consistency with QQ channel
- Fix truncated media_id logging for short IDs
2026-04-10 17:05:59 +08:00
gem12andchengyongru 973b888d39 feat(channels): Add full media support for QQ and WeCom channels
QQ channel improvements (on top of nightly):
- Add top-level try/except in _on_message and send() for resilience
- Use defensive getattr() for attachment attributes (botpy version compat)
- Skip file_name for image uploads to avoid QQ rendering as file attachment
- Extract only file_info from upload response to avoid extra fields
- Handle protocol-relative URLs (//...) in attachment downloads

WeCom channel improvements:
- Add _upload_media_ws() for WebSocket 3-step media upload protocol
- Send media files (image/video/voice/file) via WeCom rich media API
- Support progress messages (plain reply) vs final response (streaming)
- Support proactive send when no frame available (cron push)
- Pass media_paths to message bus for downstream processing
2026-04-10 17:05:59 +08:00
flobo3andchengyongru 7b1ce24600 fix: strip <thought> blocks from Gemma 4 and similar models 2026-04-10 00:58:00 +08:00
chengyongruandchengyongru 3bece171c2 docs(websocket): add WebSocket channel documentation
Comprehensive guide covering wire protocol, configuration reference,
token issuance, security notes, and common deployment patterns.
2026-04-09 15:56:34 +08:00
chengyongruandchengyongru 8f7ce9fef7 fix(websocket): harden security and robustness
- Use hmac.compare_digest for timing-safe static token comparison
- Add issued token capacity limit (_MAX_ISSUED_TOKENS=10000) with 429 response
- Use atomic pop in _take_issued_token_if_valid to eliminate TOCTOU window
- Enforce TLSv1.2 minimum version for SSL connections
- Extract _safe_send helper for consistent ConnectionClosed handling
- Move connection registration after ready send to prevent out-of-order delivery
- Add HTTP-level allow_from check and client_id truncation in process_request
- Make stop() idempotent with graceful shutdown error handling
- Normalize path via validator instead of leaving raw value
- Default websocket_requires_token to True for secure-by-default behavior
- Add integration tests and ws_test_client helper
- Refactor tests to use shared _ch factory and bus fixture
2026-04-09 15:56:34 +08:00
chengyongruandchengyongru d327c19db0 fix(websocket): handle ConnectionClosed gracefully in send and send_delta 2026-04-09 15:56:34 +08:00
Jack Luandchengyongru e00dca2f84 feat(channels): add WebSocket server channel and tests
Port Python implementation from a1ec7b192a
(websocket channel module and channel tests; excludes webui debug app).
2026-04-09 15:56:34 +08:00
Jiajun Xieandchengyongru 51200a954c fix(feishu): improve voice message download with detailed logging
- Add explicit error logging for missing file_key and message_id
- Add logging for download failures
- Change audio extension from .opus to .ogg for better Whisper compatibility
- Feishu voice messages are opus in OGG container; .ogg is more widely recognized
2026-04-09 10:14:26 +08:00
chengyongruandchengyongru c121547114 refactor(feishu): simplify tool hint to append-only, delegate to send_delta for throttling
- Make tool_hint_prefix configurable in FeishuConfig (default: 🔧)
- Delegate tool hint card updates from send() to send_delta() so hints
  automatically benefit from _STREAM_EDIT_INTERVAL throttling
- Fix staticmethod calls to use self.__class__ instead of self
- Document all supported metadata keys in send_delta docstring
- Add test for empty/whitespace-only tool hint with active stream buffer
2026-04-08 21:02:21 +08:00
xzq.xuandchengyongru dcc9c057bb fix(tool-hints): deduplicate by formatted string + per-line inline display
Two display fixes based on real-world Feishu testing:

1. tool_hints.py: format_tool_hints now deduplicates by comparing the
   fully formatted hint string instead of tool name alone. This fixes
   `ls /Desktop` and `ls /Downloads` being incorrectly merged as
   `ls /Desktop × 2`. Truly identical calls still fold correctly.
   (_group_consecutive and all abbreviation logic preserved unchanged.)

2. feishu.py: inline tool hints now display one tool per line with
   🔧 prefix, and use double-newline trailing to prevent Setext heading
   rendering when followed by markdown `---`.

Made-with: Cursor
2026-04-08 21:02:21 +08:00
xzq.xuandchengyongru 586d4e2411 fix(feishu): preserve tool hints in final card content
Tool hints should be kept as permanent content in the streaming card
so users can see which tools were called (matching the standalone card
behavior). Previously, hints were stripped when new deltas arrived or
when the stream ended, causing tool call information to disappear.

Now:
- New delta: hint becomes permanent content, delta appends after it
- New tool hint: replaces the previous hint (unchanged)
- Resuming/stream_end: hint is preserved in the final text

Updated 3 tests to verify hint preservation semantics.

Made-with: Cursor
2026-04-08 21:02:21 +08:00
xzq.xuandchengyongru a4bb1923ac fix(feishu): prevent tool hint stacking and clean hints on stream_end
Three fixes for inline tool hints:

1. Consecutive tool hints now replace the previous one instead of
   stacking — the old suffix is stripped before appending the new one.

2. When _resuming flushes the buffer, any trailing tool hint suffix
   is removed so it doesn't persist into the next streaming segment.

3. When final _stream_end closes the card, tool hint suffix is
   cleaned from the text before the final card update.

Adds 3 regression tests covering all three scenarios.

Made-with: Cursor
2026-04-08 21:02:21 +08:00
xzq.xuandchengyongru 8d6f41e484 feat(feishu): streaming resuming + inline tool hints
Two improvements to Feishu streaming card experience:

1. Handle _resuming in send_delta: when a mid-turn _stream_end arrives
   with resuming=True (tool call between segments), flush current text
   to the card but keep the buffer alive so subsequent segments append
   to the same card instead of creating a new one.

2. Inline tool hints into streaming cards: when a tool hint arrives
   while a streaming card is active, append it to the card content
   (e.g. "🔧 web_fetch(...)") instead of sending a separate card.
   The hint is automatically stripped when the next delta arrives.

Made-with: Cursor
2026-04-08 21:02:21 +08:00
chengyongruandchengyongru 4962867112 fix(tool-hint): fold paths in exec commands instead of blind truncation
exec tool hints previously used val[:40] which cut paths mid-segment
(e.g. "D:\Documents\GitHub\nanobot.worktree…"). Now uses regex to
detect file paths in commands and abbreviates them properly, with
smart truncation at chain separators (&&, |, ;) as fallback.
2026-04-08 11:37:17 +08:00
JiajunandGitHub 473637ceff feat(feishu): add done emoji support for reaction lifecycle (#2899)
* feat(feishu): add done emoji support for reaction lifecycle

* feat(feishu): add done emoji support and update documentation
2026-04-07 23:56:23 +08:00
chengyongru c44d4f2b2b fix(test): fix two flaky tests on Windows
- test_exec_head_tail_truncation: use temp script file instead of
  python -c to avoid cmd.exe quote-parsing issues after PR #2893
- test_grep_files_with_matches_supports_head_limit_and_offset: query
  full result set first to avoid mtime-dependent sort assumption
2026-04-07 21:30:45 +08:00
Xubin Renandchengyongru ae27d69ecb fix(exec): add Windows support for shell command execution
ExecTool hardcoded bash, breaking exec on Windows. Now uses cmd.exe
via COMSPEC on Windows with a curated minimal env (PATH, SYSTEMROOT,
etc.) that excludes secrets. bwrap sandbox gracefully skips on Windows.
2026-04-07 21:13:32 +08:00
chengyongru ba38d41ad1 Merge remote-tracking branch 'origin/main' into nightly 2026-04-07 20:49:53 +08:00
chengyongruandGitHub ad4d095080 feat(memory):dream enhancement (#2887)
* feat(dream): enhance memory cleanup with staleness detection

- Phase 1: add [FILE-REMOVE] directive and staleness patterns (14-day
  threshold, completed tasks, superseded info, resolved tracking)
- Phase 2: add explicit cleanup rules, file paths section, and deletion
  guidance to prevent LLM path confusion
- Inject current date and file sizes into Phase 1 context for age-aware
  analysis
- Add _dream_debug() helper for observability (dream-debug.log in workspace)
- Log Phase 1 analysis output and Phase 2 tool events for debugging

Tested with glm-5-turbo: MEMORY.md reduced from 149 to 108-129 lines
across two rounds, correctly identifying and removing weather data,
detailed incident info, completed research, and stale discussions.

* refactor(dream): replace _dream_debug file logger with loguru

Remove the custom _dream_debug() helper that wrote to dream-debug.log
and use the existing loguru logger instead. Phase 1 analysis is logged
at debug level, tool events at info level — consistent with the rest
of the codebase and no extra log file to manage.

* fix(dream): make stale scan independent of conversation history

Reframe Phase 1 from a single comparison task to two independent
tasks: history diff AND proactive stale scan. The LLM was skipping
stale content that wasn't referenced in conversation history (e.g.
old triage snapshots). Now explicitly requires scanning memory files
for staleness patterns on every run.

* fix(dream): correct old_text param name and truncate debug log

- Phase 2 prompt: old_string -> old_text to match EditFileTool interface
- Phase 1 debug log: truncate analysis to 500 chars to avoid oversized lines

* refactor(dream): streamline prompts by separating concerns

Phase 1 owns all staleness judgment logic; Phase 2 is pure execution
guidance. Remove duplicated cleanup rules from Phase 2 since Phase 1
already determines what to add/remove. Fix remaining old_string -> old_text.
Total prompt size reduced ~45% (870 -> 480 tokens).

* fix(dream): add FILE-REMOVE execution guidance to Phase 2 prompt

Phase 2 was only processing [FILE] additions and ignoring [FILE-REMOVE]
deletions after the cleanup rules were removed. Add explicit mapping:
[FILE] → add content, [FILE-REMOVE] → delete content.
2026-04-07 15:41:54 +08:00
chengyongruandchengyongru 3723cd726e fix(msteams): remove optional deps from dev extras and gate tests
PyJWT and cryptography are optional msteams deps; they should not be
bundled into the generic dev install.  Tests now skip the entire file
when the deps are missing, following the dingtalk pattern.
2026-04-06 15:48:10 +08:00
Bob Johnsonandchengyongru 8f0b653a4c Fix MSTeams PR review follow-ups 2026-04-06 15:48:10 +08:00
T3chC0wb0yandchengyongru 5857f7fdd0 Add Microsoft Teams channel on current nightly base 2026-04-06 15:48:10 +08:00
chengyongruandchengyongru 7d2c62716c fix(dream): allow LLM to retry on tool errors instead of failing immediately
Dream Phase 2 uses fail_on_tool_error=True, which terminates the entire
run on the first tool error (e.g. old_text not found in edit_file).
Normal agent runs default to False so the LLM can self-correct and retry.
Dream should behave the same way.
2026-04-05 22:09:42 +08:00
89 changed files with 1779 additions and 9621 deletions
+12 -72
View File
@@ -1,86 +1,26 @@
# Project-specific
.worktrees/ .worktrees/
.assets .assets
.docs .docs
.env .env
.web .web
# Python bytecode & caches
*.pyc *.pyc
dist/
build/
*.egg-info/
*.egg
*.pycs
*.pyo *.pyo
*.pyd *.pyd
*.pyw *.pyw
*.pyz *.pyz
__pycache__/ *.pywz
*.egg-info/ *.pyzz
*.egg
.venv/ .venv/
venv/ venv/
.pytest_cache/ __pycache__/
.mypy_cache/
.ruff_cache/
.pytype/
.dmypy.json
dmypy.json
.tox/
.nox/
.hypothesis/
# Build & packaging
dist/
build/
*.manifest
*.spec
pip-wheel-metadata/
share/python-wheels/
# Test & coverage
.coverage
.coverage.*
htmlcov/
coverage.xml
*.cover
# Lock files (project policy)
poetry.lock poetry.lock
uv.lock .pytest_cache/
botpy.log
# Jupyter
.ipynb_checkpoints/
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Linux
.directory
# Editors & IDEs (local workspace / user settings)
.vscode/
.cursor/
.idea/
.fleet/
*.code-workspace
*.sublime-project
*.sublime-workspace
*.swp
*.swo
*~
nano.*.save nano.*.save
.DS_Store
# Environment & secrets (keep examples tracked if needed) uv.lock
.env.*
!.env.example
# Logs & temp
*.log
logs/
tmp/
temp/
*.tmp
+7 -113
View File
@@ -394,8 +394,7 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
"enabled": true, "enabled": true,
"token": "YOUR_BOT_TOKEN", "token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"], "allowFrom": ["YOUR_USER_ID"],
"groupPolicy": "mention", "groupPolicy": "mention"
"streaming": true
} }
} }
} }
@@ -406,7 +405,6 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
> - `"open"` — Respond to all messages > - `"open"` — Respond to all messages
> DMs always respond when the sender is in `allowFrom`. > DMs always respond when the sender is in `allowFrom`.
> - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session. > - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session.
> `streaming` defaults to `true`. Disable it only if you explicitly want non-streaming replies.
**5. Invite the bot** **5. Invite the bot**
- OAuth2 → URL Generator - OAuth2 → URL Generator
@@ -562,7 +560,6 @@ Uses **WebSocket** long connection — no public IP required.
"groupPolicy": "mention", "groupPolicy": "mention",
"reactEmoji": "OnIt", "reactEmoji": "OnIt",
"doneEmoji": "DONE", "doneEmoji": "DONE",
"toolHintPrefix": "🔧",
"streaming": true, "streaming": true,
"domain": "feishu" "domain": "feishu"
} }
@@ -576,7 +573,6 @@ Uses **WebSocket** long connection — no public IP required.
> `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond. > `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond.
> `reactEmoji`: Emoji for "processing" status (default: `OnIt`). See [available emojis](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce). > `reactEmoji`: Emoji for "processing" status (default: `OnIt`). See [available emojis](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce).
> `doneEmoji`: Optional emoji for "completed" status (e.g., `DONE`, `OK`, `HEART`). When set, bot adds this reaction after removing `reactEmoji`. > `doneEmoji`: Optional emoji for "completed" status (e.g., `DONE`, `OK`, `HEART`). When set, bot adds this reaction after removing `reactEmoji`.
> `toolHintPrefix`: Prefix for inline tool hints in streaming cards (default: `🔧`).
> `domain`: `"feishu"` (default) for China (open.feishu.cn), `"lark"` for international Lark (open.larksuite.com). > `domain`: `"feishu"` (default) for China (open.feishu.cn), `"lark"` for international Lark (open.larksuite.com).
**3. Run** **3. Run**
@@ -1053,30 +1049,6 @@ Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, To
``` ```
> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`). > For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
>
> `custom` is the right choice for providers that expose an OpenAI-compatible **chat completions** API. It does **not** force third-party endpoints onto the OpenAI/Azure **Responses API**.
>
> If your proxy or gateway is specifically Responses-API-compatible, use the `azure_openai` provider shape instead and point `apiBase` at that endpoint:
>
> ```json
> {
> "providers": {
> "azure_openai": {
> "apiKey": "your-api-key",
> "apiBase": "https://api.your-provider.com",
> "defaultModel": "your-model-name"
> }
> },
> "agents": {
> "defaults": {
> "provider": "azure_openai",
> "model": "your-model-name"
> }
> }
> }
> ```
>
> In short: **chat-completions-compatible endpoint → `custom`**; **Responses-compatible endpoint → `azure_openai`**.
</details> </details>
@@ -1338,7 +1310,6 @@ If you need to allow trusted private ranges such as Tailscale / CGNAT addresses,
| `brave` | `apiKey` | `BRAVE_API_KEY` | No | | `brave` | `apiKey` | `BRAVE_API_KEY` | No |
| `tavily` | `apiKey` | `TAVILY_API_KEY` | No | | `tavily` | `apiKey` | `TAVILY_API_KEY` | No |
| `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) | | `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) |
| `kagi` | `apiKey` | `KAGI_API_KEY` | No |
| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) | | `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
| `duckduckgo` (default) | — | — | Yes | | `duckduckgo` (default) | — | — | Yes |
@@ -1395,20 +1366,6 @@ If you need to allow trusted private ranges such as Tailscale / CGNAT addresses,
} }
``` ```
**Kagi:**
```json
{
"tools": {
"web": {
"search": {
"provider": "kagi",
"apiKey": "your-kagi-api-key"
}
}
}
}
```
**SearXNG** (self-hosted, no API key needed): **SearXNG** (self-hosted, no API key needed):
```json ```json
{ {
@@ -1546,13 +1503,13 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
### Auto Compact ### Auto Compact
When a user is idle for longer than a configured threshold, nanobot **proactively** compresses the older part of the session context into a summary while keeping a recent legal suffix of live messages. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary, the most recent live context, and fresh input. When a user is idle for longer than a configured TTL, nanobot **proactively** compresses the session context into a summary. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary and fresh input.
```json ```json
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"idleCompactAfterMinutes": 15 "sessionTtlMinutes": 15
} }
} }
} }
@@ -1560,18 +1517,15 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `agents.defaults.idleCompactAfterMinutes` | `0` (disabled) | Minutes of idle time before auto-compaction starts. Set to `0` to disable. Recommended: `15`close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. | | `agents.defaults.sessionTtlMinutes` | `0` (disabled) | Minutes of idle time before auto-compaction. Set to `0` to disable. Recommended: `15`matches typical LLM KV cache expiration, so compacted sessions won't waste cache on cold entries. |
`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
How it works: How it works:
1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration. 1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration.
2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages). 2. **Background compaction**: Expired sessions are summarized via LLM, then cleared.
3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix. 3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted).
4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
> [!TIP] > [!TIP]
> Think of auto compact as "summarize older context, keep the freshest live turns." It is not a hard session reset. > The summary survives bot restarts — it's stored in session metadata and recovered on the next message.
### Timezone ### Timezone
@@ -1595,52 +1549,6 @@ Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/Londo
> Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). > Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
### Unified Session
By default, each channel × chat ID combination gets its own session. If you use nanobot across multiple channels (e.g. Telegram + Discord + CLI) and want them to share the same conversation, enable `unifiedSession`:
```json
{
"agents": {
"defaults": {
"unifiedSession": true
}
}
}
```
When enabled, all incoming messages — regardless of which channel they arrive on — are routed into a single shared session. Switching from Telegram to Discord (or any other channel) continues the same conversation seamlessly.
| Behavior | `false` (default) | `true` |
|----------|-------------------|--------|
| Session key | `channel:chat_id` | `unified:default` |
| Cross-channel continuity | No | Yes |
| `/new` clears | Current channel session | Shared session |
| `/stop` finds tasks | By channel session | By shared session |
| Existing `session_key_override` (e.g. Telegram thread) | Respected | Still respected — not overwritten |
> This is designed for single-user, multi-device setups. It is **off by default** — existing users see zero behavior change.
### Disabled Skills
nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names:
```json
{
"agents": {
"defaults": {
"disabledSkills": ["github", "weather"]
}
}
}
```
Disabled skills are excluded from the main agent's skill summary, from always-on skill injection, and from subagent skill summaries. This is useful when some bundled skills are unnecessary for your deployment or should not be exposed to end users.
| Option | Default | Description |
|--------|---------|-------------|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
## 🧩 Multiple Instances ## 🧩 Multiple Instances
Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance. Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance.
@@ -1766,7 +1674,6 @@ time.
- `memory/history.jsonl` stores append-only summarized history - `memory/history.jsonl` stores append-only summarized history
- `SOUL.md`, `USER.md`, and `memory/MEMORY.md` store long-term knowledge managed by Dream - `SOUL.md`, `USER.md`, and `memory/MEMORY.md` store long-term knowledge managed by Dream
- `Dream` can also promote repeated workflows into reusable workspace skills under `skills/`
- `Dream` runs on a schedule and can also be triggered manually - `Dream` runs on a schedule and can also be triggered manually
- memory changes can be inspected and restored with built-in commands - memory changes can be inspected and restored with built-in commands
@@ -1882,19 +1789,6 @@ By default, the API binds to `127.0.0.1:8900`. You can change this in `config.js
- Single-message input: each request must contain exactly one `user` message - Single-message input: each request must contain exactly one `user` message
- Fixed model: omit `model`, or pass the same model shown by `/v1/models` - Fixed model: omit `model`, or pass the same model shown by `/v1/models`
- No streaming: `stream=true` is not supported - No streaming: `stream=true` is not supported
- API requests run in the synthetic `api` channel, so the `message` tool does **not** automatically deliver to Telegram/Discord/etc. To proactively send to another chat, call `message` with an explicit `channel` and `chat_id` for an enabled channel.
Example tool call for cross-channel delivery from an API session:
```json
{
"content": "Build finished successfully.",
"channel": "telegram",
"chat_id": "123456789"
}
```
If `channel` points to a channel that is not enabled in your config, nanobot will queue the outbound event but no platform delivery will occur.
### Endpoints ### Endpoints
+8 -63
View File
@@ -43,33 +43,18 @@ from typing import Any
from aiohttp import web from aiohttp import web
from loguru import logger from loguru import logger
from pydantic import Field
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
class WebhookChannel(BaseChannel): class WebhookChannel(BaseChannel):
name = "webhook" name = "webhook"
display_name = "Webhook" display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True) return {"enabled": False, "port": 9000, "allowFrom": []}
async def start(self) -> None: async def start(self) -> None:
"""Start an HTTP server that listens for incoming messages. """Start an HTTP server that listens for incoming messages.
@@ -78,7 +63,7 @@ class WebhookChannel(BaseChannel):
If it returns, the channel is considered dead. If it returns, the channel is considered dead.
""" """
self._running = True self._running = True
port = self.config.port port = self.config.get("port", 9000)
app = web.Application() app = web.Application()
app.router.add_post("/message", self._on_request) app.router.add_post("/message", self._on_request)
@@ -229,7 +214,7 @@ nanobot channels login <channel_name> --force # re-authenticate
| Method / Property | Description | | Method / Property | Description |
|-------------------|-------------| |-------------------|-------------|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. | | `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. | | `is_allowed(sender_id)` | Checks against `config["allowFrom"]`; `"*"` allows all, `[]` denies all. |
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. | | `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
| `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). | | `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). |
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. | | `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
@@ -299,9 +284,7 @@ class WebhookChannel(BaseChannel):
name = "webhook" name = "webhook"
display_name = "Webhook" display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus): def __init__(self, config, bus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus) super().__init__(config, bus)
self._buffers: dict[str, str] = {} self._buffers: dict[str, str] = {}
@@ -350,48 +333,12 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no
## Config ## Config
### Why Pydantic model is required Your channel receives config as a plain `dict`. Access fields with `.get()`:
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`**`dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
### Pattern
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
```python
from pydantic import Field
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
```
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
2. Convert `dict` → model in `__init__`:
```python
from typing import Any
from nanobot.bus.queue import MessageBus
class WebhookChannel(BaseChannel):
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
```
3. Access config as attributes (not `.get()`):
```python ```python
async def start(self) -> None: async def start(self) -> None:
port = self.config.port port = self.config.get("port", 9000)
token = self.config.token token = self.config.get("token", "")
``` ```
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself. `allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
@@ -401,11 +348,9 @@ Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
```python ```python
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True) return {"enabled": False, "port": 9000, "allowFrom": []}
``` ```
> **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
If not overridden, the base class returns `{"enabled": false}`. If not overridden, the base class returns `{"enabled": false}`.
## Naming Convention ## Naming Convention
+68
View File
@@ -0,0 +1,68 @@
# Microsoft Teams (MVP)
This repository includes a built-in `msteams` channel MVP for Microsoft Teams direct messages.
## Current scope
- Direct-message text in/out
- Tenant-aware OAuth token acquisition
- Conversation reference persistence for replies
- Public HTTPS webhook support through a tunnel or reverse proxy
## Not yet included
- Group/channel handling
- Attachments and cards
- Polls
- Richer Teams activity handling
## Example config
```json
{
"channels": {
"msteams": {
"enabled": true,
"appId": "YOUR_APP_ID",
"appPassword": "YOUR_APP_SECRET",
"tenantId": "YOUR_TENANT_ID",
"host": "0.0.0.0",
"port": 3978,
"path": "/api/messages",
"allowFrom": ["*"],
"replyInThread": true,
"mentionOnlyResponse": "Hi — what can I help with?",
"validateInboundAuth": false,
"restartNotifyEnabled": false,
"restartNotifyPreMessage": "Nanobot agent initiated a gateway restart. I will message again when the gateway is back online.",
"restartNotifyPostMessage": "Nanobot gateway is back online."
}
}
}
```
## Behavior notes
- `replyInThread: true` replies to the triggering Teams activity when a stored `activity_id` is available.
- `replyInThread: false` posts replies as normal conversation messages.
- If `replyInThread` is enabled but no `activity_id` is stored, Nanobot falls back to a normal conversation message.
- `mentionOnlyResponse` controls what Nanobot receives when a user sends only a bot mention such as `<at>Nanobot</at>`.
- Set `mentionOnlyResponse` to an empty string to ignore mention-only messages.
- `validateInboundAuth: true` enables inbound Bot Framework bearer-token validation.
- `validateInboundAuth: false` leaves inbound auth unenforced, which is safer while first validating a new relay, tunnel, or proxy path.
- When enabled, Nanobot validates the inbound bearer token signature, issuer, audience, token lifetime, and `serviceUrl` claim when present.
- `restartNotifyEnabled: true` enables optional Teams restart-notification configuration for external wrapper-script driven restarts.
- `restartNotifyPreMessage` and `restartNotifyPostMessage` control the before/after announcement text used by that external wrapper.
## Setup notes
1. Create or reuse a Microsoft Teams / Azure bot app registration.
2. Set the bot messaging endpoint to a public HTTPS URL ending in `/api/messages`.
3. Forward that public endpoint to `http://localhost:3978/api/messages`.
4. Start Nanobot with:
```bash
nanobot gateway
```
5. Optional: if you use an external restart wrapper (for example a script that stops and restarts the gateway), you can enable Teams restart announcements with `restartNotifyEnabled: true` and have the wrapper send `restartNotifyPreMessage` before restart and `restartNotifyPostMessage` after the gateway is back online.
+1 -23
View File
@@ -2,29 +2,7 @@
nanobot - A lightweight AI agent framework nanobot - A lightweight AI agent framework
""" """
from importlib.metadata import PackageNotFoundError, version as _pkg_version __version__ = "0.1.5"
from pathlib import Path
import tomllib
def _read_pyproject_version() -> str | None:
"""Read the source-tree version when package metadata is unavailable."""
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
if not pyproject.exists():
return None
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
return data.get("project", {}).get("version")
def _resolve_version() -> str:
try:
return _pkg_version("nanobot-ai")
except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info.
return _read_pyproject_version() or "0.1.5"
__version__ = _resolve_version()
__logo__ = "🐈" __logo__ = "🐈"
from nanobot.nanobot import Nanobot, RunResult from nanobot.nanobot import Nanobot, RunResult
@@ -2,20 +2,17 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Collection
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Coroutine from typing import TYPE_CHECKING, Callable, Coroutine
from loguru import logger from loguru import logger
from nanobot.session.manager import Session, SessionManager
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.memory import Consolidator from nanobot.agent.memory import Consolidator
from nanobot.session.manager import Session, SessionManager
class AutoCompact: class AutoCompact:
_RECENT_SUFFIX_MESSAGES = 8
def __init__(self, sessions: SessionManager, consolidator: Consolidator, def __init__(self, sessions: SessionManager, consolidator: Consolidator,
session_ttl_minutes: int = 0): session_ttl_minutes: int = 0):
self.sessions = sessions self.sessions = sessions
@@ -24,83 +21,47 @@ class AutoCompact:
self._archiving: set[str] = set() self._archiving: set[str] = set()
self._summaries: dict[str, tuple[str, datetime]] = {} self._summaries: dict[str, tuple[str, datetime]] = {}
def _is_expired(self, ts: datetime | str | None, def _is_expired(self, ts: datetime | str | None) -> bool:
now: datetime | None = None) -> bool:
if self._ttl <= 0 or not ts: if self._ttl <= 0 or not ts:
return False return False
if isinstance(ts, str): if isinstance(ts, str):
ts = datetime.fromisoformat(ts) ts = datetime.fromisoformat(ts)
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60 return (datetime.now() - ts).total_seconds() >= self._ttl * 60
@staticmethod @staticmethod
def _format_summary(text: str, last_active: datetime) -> str: def _format_summary(text: str, last_active: datetime) -> str:
idle_min = int((datetime.now() - last_active).total_seconds() / 60) idle_min = int((datetime.now() - last_active).total_seconds() / 60)
return f"Inactive for {idle_min} minutes.\nPrevious conversation summary: {text}" return f"Inactive for {idle_min} minutes.\nPrevious conversation summary: {text}"
def _split_unconsolidated( def check_expired(self, schedule_background: Callable[[Coroutine], None]) -> None:
self, session: Session,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Split live session tail into archiveable prefix and retained recent suffix."""
tail = list(session.messages[session.last_consolidated:])
if not tail:
return [], []
probe = Session(
key=session.key,
messages=tail.copy(),
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
probe.retain_recent_legal_suffix(self._RECENT_SUFFIX_MESSAGES)
kept = probe.messages
cut = len(tail) - len(kept)
return tail[:cut], kept
def check_expired(self, schedule_background: Callable[[Coroutine], None],
active_session_keys: Collection[str] = ()) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
now = datetime.now()
for info in self.sessions.list_sessions(): for info in self.sessions.list_sessions():
key = info.get("key", "") key = info.get("key", "")
if not key or key in self._archiving: if key and key not in self._archiving and self._is_expired(info.get("updated_at")):
continue
if key in active_session_keys:
continue
if self._is_expired(info.get("updated_at"), now):
self._archiving.add(key) self._archiving.add(key)
logger.debug("Auto-compact: scheduling archival for {} (idle > {} min)", key, self._ttl)
schedule_background(self._archive(key)) schedule_background(self._archive(key))
async def _archive(self, key: str) -> None: async def _archive(self, key: str) -> None:
try: try:
self.sessions.invalidate(key) self.sessions.invalidate(key)
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
archive_msgs, kept_msgs = self._split_unconsolidated(session) msgs = session.messages[session.last_consolidated:]
if not archive_msgs and not kept_msgs: if not msgs:
logger.debug("Auto-compact: skipping {}, no un-consolidated messages", key)
session.updated_at = datetime.now() session.updated_at = datetime.now()
self.sessions.save(session) self.sessions.save(session)
return return
n = len(msgs)
last_active = session.updated_at last_active = session.updated_at
summary = "" await self.consolidator.archive(msgs)
if archive_msgs: entry = self.consolidator.get_last_history_entry()
summary = await self.consolidator.archive(archive_msgs) or "" summary = (entry or {}).get("content", "")
if summary and summary != "(nothing)": if summary and summary != "(nothing)":
self._summaries[key] = (summary, last_active) self._summaries[key] = (summary, last_active)
session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()} session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
session.messages = kept_msgs session.clear()
session.last_consolidated = 0
session.updated_at = datetime.now()
self.sessions.save(session) self.sessions.save(session)
if archive_msgs: logger.info("Auto-compact: archived {} ({} messages, summary={})", key, n, bool(summary))
logger.info(
"Auto-compact: archived {} (archived={}, kept={}, summary={})",
key,
len(archive_msgs),
len(kept_msgs),
bool(summary),
)
except Exception: except Exception:
logger.exception("Auto-compact: failed for {}", key) logger.exception("Auto-compact: failed for {}", key)
finally: finally:
@@ -110,13 +71,11 @@ class AutoCompact:
if key in self._archiving or self._is_expired(session.updated_at): if key in self._archiving or self._is_expired(session.updated_at):
logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving) logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving)
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
# Hot path: summary from in-memory dict (process hasn't restarted).
# Also clean metadata copy so stale _last_summary never leaks to disk.
entry = self._summaries.pop(key, None) entry = self._summaries.pop(key, None)
if entry: if entry:
session.metadata.pop("_last_summary", None) session.metadata.pop("_last_summary", None)
return session, self._format_summary(entry[0], entry[1]) return session, self._format_summary(entry[0], entry[1])
if "_last_summary" in session.metadata: if not session.messages and "_last_summary" in session.metadata:
meta = session.metadata.pop("_last_summary") meta = session.metadata.pop("_last_summary")
self.sessions.save(session) self.sessions.save(session)
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"])) return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
+6 -19
View File
@@ -19,22 +19,17 @@ class ContextBuilder:
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"] BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
_MAX_RECENT_HISTORY = 50
_RUNTIME_CONTEXT_END = "[/Runtime Context]" _RUNTIME_CONTEXT_END = "[/Runtime Context]"
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None): def __init__(self, workspace: Path, timezone: str | None = None):
self.workspace = workspace self.workspace = workspace
self.timezone = timezone self.timezone = timezone
self.memory = MemoryStore(workspace) self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None) self.skills = SkillsLoader(workspace)
def build_system_prompt( def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
self,
skill_names: list[str] | None = None,
channel: str | None = None,
) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills.""" """Build the system prompt from identity, bootstrap files, memory, and skills."""
parts = [self._get_identity(channel=channel)] parts = [self._get_identity()]
bootstrap = self._load_bootstrap_files() bootstrap = self._load_bootstrap_files()
if bootstrap: if bootstrap:
@@ -54,16 +49,9 @@ class ContextBuilder:
if skills_summary: if skills_summary:
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary)) parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
if entries:
capped = entries[-self._MAX_RECENT_HISTORY:]
parts.append("# Recent History\n\n" + "\n".join(
f"- [{e['timestamp']}] {e['content']}" for e in capped
))
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
def _get_identity(self, channel: str | None = None) -> str: def _get_identity(self) -> str:
"""Get the core identity section.""" """Get the core identity section."""
workspace_path = str(self.workspace.expanduser().resolve()) workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system() system = platform.system()
@@ -74,7 +62,6 @@ class ContextBuilder:
workspace_path=workspace_path, workspace_path=workspace_path,
runtime=runtime, runtime=runtime,
platform_policy=render_template("agent/platform_policy.md", system=system), platform_policy=render_template("agent/platform_policy.md", system=system),
channel=channel or "",
) )
@staticmethod @staticmethod
@@ -138,7 +125,7 @@ class ContextBuilder:
else: else:
merged = [{"type": "text", "text": runtime_ctx}] + user_content merged = [{"type": "text", "text": runtime_ctx}] + user_content
messages = [ messages = [
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel)}, {"role": "system", "content": self.build_system_prompt(skill_names)},
*history, *history,
] ]
if messages[-1].get("role") == current_role: if messages[-1].get("role") == current_role:
-8
View File
@@ -29,9 +29,6 @@ class AgentHookContext:
class AgentHook: class AgentHook:
"""Minimal lifecycle surface for shared runner customization.""" """Minimal lifecycle surface for shared runner customization."""
def __init__(self, reraise: bool = False) -> None:
self._reraise = reraise
def wants_streaming(self) -> bool: def wants_streaming(self) -> bool:
return False return False
@@ -65,7 +62,6 @@ class CompositeHook(AgentHook):
__slots__ = ("_hooks",) __slots__ = ("_hooks",)
def __init__(self, hooks: list[AgentHook]) -> None: def __init__(self, hooks: list[AgentHook]) -> None:
super().__init__()
self._hooks = list(hooks) self._hooks = list(hooks)
def wants_streaming(self) -> bool: def wants_streaming(self) -> bool:
@@ -73,10 +69,6 @@ class CompositeHook(AgentHook):
async def _for_each_hook_safe(self, method_name: str, *args: Any, **kwargs: Any) -> None: async def _for_each_hook_safe(self, method_name: str, *args: Any, **kwargs: Any) -> None:
for h in self._hooks: for h in self._hooks:
if getattr(h, "_reraise", False):
await getattr(h, method_name)(*args, **kwargs)
continue
try: try:
await getattr(h, method_name)(*args, **kwargs) await getattr(h, method_name)(*args, **kwargs)
except Exception: except Exception:
+121 -284
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import dataclasses
import json import json
import os import os
import time import time
@@ -13,17 +12,16 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot.agent.autocompact import AutoCompact from nanobot.agent.auto_compact import AutoCompact
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
from nanobot.agent.memory import Consolidator, Dream from nanobot.agent.memory import Consolidator, Dream
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, 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
from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.notebook import NotebookEditTool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.search import GlobTool, GrepTool from nanobot.agent.tools.search import GlobTool, GrepTool
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
@@ -35,7 +33,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
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
from nanobot.utils.helpers import image_placeholder_text, truncate_text as truncate_text_fn from nanobot.utils.helpers import image_placeholder_text, truncate_text
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -43,9 +41,6 @@ if TYPE_CHECKING:
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
UNIFIED_SESSION_KEY = "unified:default"
class _LoopHook(AgentHook): class _LoopHook(AgentHook):
"""Core hook for the main loop.""" """Core hook for the main loop."""
@@ -60,7 +55,6 @@ class _LoopHook(AgentHook):
chat_id: str = "direct", chat_id: str = "direct",
message_id: str | None = None, message_id: str | None = None,
) -> None: ) -> None:
super().__init__(reraise=True)
self._loop = agent_loop self._loop = agent_loop
self._on_progress = on_progress self._on_progress = on_progress
self._on_stream = on_stream self._on_stream = on_stream
@@ -79,7 +73,7 @@ class _LoopHook(AgentHook):
prev_clean = strip_think(self._stream_buf) prev_clean = strip_think(self._stream_buf)
self._stream_buf += delta self._stream_buf += delta
new_clean = strip_think(self._stream_buf) new_clean = strip_think(self._stream_buf)
incremental = new_clean[len(prev_clean) :] incremental = new_clean[len(prev_clean):]
if incremental and self._on_stream: if incremental and self._on_stream:
await self._on_stream(incremental) await self._on_stream(incremental)
@@ -116,6 +110,43 @@ class _LoopHook(AgentHook):
return self._loop._strip_think(content) return self._loop._strip_think(content)
class _LoopHookChain(AgentHook):
"""Run the core hook before extra hooks."""
__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.
@@ -129,7 +160,6 @@ class AgentLoop:
""" """
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint" _RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn"
def __init__( def __init__(
self, self,
@@ -152,8 +182,6 @@ class AgentLoop:
timezone: str | None = None, timezone: str | None = None,
session_ttl_minutes: int = 0, session_ttl_minutes: int = 0,
hooks: list[AgentHook] | None = None, hooks: list[AgentHook] | None = None,
unified_session: bool = False,
disabled_skills: list[str] | None = None,
): ):
from nanobot.config.schema import ExecToolConfig, WebToolsConfig from nanobot.config.schema import ExecToolConfig, WebToolsConfig
@@ -186,7 +214,7 @@ class AgentLoop:
self._last_usage: dict[str, int] = {} self._last_usage: dict[str, int] = {}
self._extra_hooks: list[AgentHook] = hooks or [] self._extra_hooks: list[AgentHook] = hooks or []
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills) self.context = ContextBuilder(workspace, timezone=timezone)
self.sessions = session_manager or SessionManager(workspace) self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry() self.tools = ToolRegistry()
self.runner = AgentRunner(provider) self.runner = AgentRunner(provider)
@@ -199,12 +227,11 @@ class AgentLoop:
max_tool_result_chars=self.max_tool_result_chars, max_tool_result_chars=self.max_tool_result_chars,
exec_config=self.exec_config, exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace, restrict_to_workspace=restrict_to_workspace,
disabled_skills=disabled_skills,
) )
self._unified_session = unified_session
self._running = False self._running = False
self._mcp_servers = mcp_servers or {} self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, AsyncExitStack] = {} self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False self._mcp_connected = False
self._mcp_connecting = False self._mcp_connecting = False
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
@@ -245,35 +272,23 @@ class AgentLoop:
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
"""Register the default set of tools.""" """Register the default set of tools."""
allowed_dir = ( allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
)
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
self.tools.register( self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
ReadFileTool(
workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read
)
)
for cls in (WriteFileTool, EditFileTool, ListDirTool): for cls in (WriteFileTool, EditFileTool, ListDirTool):
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
for cls in (GlobTool, GrepTool): for cls in (GlobTool, GrepTool):
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(NotebookEditTool(workspace=self.workspace, allowed_dir=allowed_dir))
if self.exec_config.enable: if self.exec_config.enable:
self.tools.register( self.tools.register(ExecTool(
ExecTool( working_dir=str(self.workspace),
working_dir=str(self.workspace), timeout=self.exec_config.timeout,
timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace,
restrict_to_workspace=self.restrict_to_workspace, sandbox=self.exec_config.sandbox,
sandbox=self.exec_config.sandbox, path_append=self.exec_config.path_append,
path_append=self.exec_config.path_append, ))
allowed_env_keys=self.exec_config.allowed_env_keys,
)
)
if self.web_config.enable: if self.web_config.enable:
self.tools.register( self.tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy))
WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy)
)
self.tools.register(WebFetchTool(proxy=self.web_config.proxy)) self.tools.register(WebFetchTool(proxy=self.web_config.proxy))
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
self.tools.register(SpawnTool(manager=self.subagents)) self.tools.register(SpawnTool(manager=self.subagents))
@@ -288,19 +303,19 @@ class AgentLoop:
return return
self._mcp_connecting = True self._mcp_connecting = True
from nanobot.agent.tools.mcp import connect_mcp_servers from nanobot.agent.tools.mcp import connect_mcp_servers
try: try:
self._mcp_stacks = await connect_mcp_servers(self._mcp_servers, self.tools) self._mcp_stack = AsyncExitStack()
if self._mcp_stacks: await self._mcp_stack.__aenter__()
self._mcp_connected = True await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
else: self._mcp_connected = True
logger.warning("No MCP servers connected successfully (will retry next message)")
except asyncio.CancelledError:
logger.warning("MCP connection cancelled (will retry next message)")
self._mcp_stacks.clear()
except BaseException as e: except BaseException as e:
logger.error("Failed to connect MCP servers (will retry next message): {}", e) logger.error("Failed to connect MCP servers (will retry next message): {}", e)
self._mcp_stacks.clear() if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except Exception:
pass
self._mcp_stack = None
finally: finally:
self._mcp_connecting = False self._mcp_connecting = False
@@ -317,7 +332,6 @@ class AgentLoop:
if not text: if not text:
return None return None
from nanobot.utils.helpers import strip_think from nanobot.utils.helpers import strip_think
return strip_think(text) or None return strip_think(text) or None
@staticmethod @staticmethod
@@ -327,12 +341,6 @@ class AgentLoop:
return format_tool_hints(tool_calls) return format_tool_hints(tool_calls)
def _effective_session_key(self, msg: InboundMessage) -> str:
"""Return the session key used for task routing and mid-turn injections."""
if self._unified_session and not msg.session_key_override:
return UNIFIED_SESSION_KEY
return msg.session_key
async def _run_agent_loop( async def _run_agent_loop(
self, self,
initial_messages: list[dict], initial_messages: list[dict],
@@ -365,7 +373,9 @@ class AgentLoop:
message_id=message_id, message_id=message_id,
) )
hook: AgentHook = ( hook: AgentHook = (
CompositeHook([loop_hook] + self._extra_hooks) if self._extra_hooks else loop_hook _LoopHookChain(loop_hook, self._extra_hooks)
if self._extra_hooks
else loop_hook
) )
async def _checkpoint(payload: dict[str, Any]) -> None: async def _checkpoint(payload: dict[str, Any]) -> None:
@@ -373,30 +383,16 @@ class AgentLoop:
return return
self._set_runtime_checkpoint(session, payload) self._set_runtime_checkpoint(session, payload)
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]: async def _drain_pending() -> list[InboundMessage]:
"""Non-blocking drain of follow-up messages from the pending queue.""" """Non-blocking drain of follow-up messages from the pending queue."""
if pending_queue is None: if pending_queue is None:
return [] return []
items: list[dict[str, Any]] = [] items: list[InboundMessage] = []
while len(items) < limit: while True:
try: try:
pending_msg = pending_queue.get_nowait() items.append(pending_queue.get_nowait())
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
user_content = self.context._build_user_content(
pending_msg.content,
pending_msg.media if pending_msg.media else None,
)
runtime_ctx = self.context._build_runtime_context(
pending_msg.channel,
pending_msg.chat_id,
self.context.timezone,
)
if isinstance(user_content, str):
merged: str | list[dict[str, Any]] = f"{runtime_ctx}\n\n{user_content}"
else:
merged = [{"type": "text", "text": runtime_ctx}] + user_content
items.append({"role": "user", "content": merged})
return items return items
result = await self.runner.run(AgentRunSpec( result = await self.runner.run(AgentRunSpec(
@@ -434,10 +430,7 @@ class AgentLoop:
try: try:
msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
self.auto_compact.check_expired( self.auto_compact.check_expired(self._schedule_background)
self._schedule_background,
active_session_keys=self._pending_queues.keys(),
)
continue continue
except asyncio.CancelledError: except asyncio.CancelledError:
# Preserve real task cancellation so shutdown can complete cleanly. # Preserve real task cancellation so shutdown can complete cleanly.
@@ -456,46 +449,30 @@ class AgentLoop:
if result: if result:
await self.bus.publish_outbound(result) await self.bus.publish_outbound(result)
continue continue
effective_key = self._effective_session_key(msg)
# If this session already has an active pending queue (i.e. a task # If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn # is processing this session), route the message there for mid-turn
# injection instead of creating a competing task. # injection instead of creating a competing task.
if effective_key in self._pending_queues: if msg.session_key in self._pending_queues:
pending_msg = msg
if effective_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=effective_key,
)
try: try:
self._pending_queues[effective_key].put_nowait(pending_msg) self._pending_queues[msg.session_key].put_nowait(msg)
except asyncio.QueueFull: except asyncio.QueueFull:
logger.warning( logger.warning(
"Pending queue full for session {}, falling back to queued task", "Pending queue full for session {}, dropping follow-up",
effective_key, msg.session_key,
) )
else: else:
logger.info( logger.info(
"Routed follow-up message to pending queue for session {}", "Routed follow-up message to pending queue for session {}",
effective_key, msg.session_key,
) )
continue continue
# Compute the effective session key before dispatching
# This ensures /stop command can find tasks correctly when unified session is enabled
task = asyncio.create_task(self._dispatch(msg)) task = asyncio.create_task(self._dispatch(msg))
self._active_tasks.setdefault(effective_key, []).append(task) self._active_tasks.setdefault(msg.session_key, []).append(task)
task.add_done_callback( task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None)
lambda t, k=effective_key: self._active_tasks.get(k, [])
and self._active_tasks[k].remove(t)
if t in self._active_tasks.get(k, [])
else None
)
async def _dispatch(self, msg: InboundMessage) -> None: async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message: per-session serial, cross-session concurrent.""" """Process a message: per-session serial, cross-session concurrent."""
session_key = self._effective_session_key(msg) session_key = msg.session_key
if session_key != msg.session_key:
msg = dataclasses.replace(msg, session_key_override=session_key)
lock = self._session_locks.setdefault(session_key, asyncio.Lock()) lock = self._session_locks.setdefault(session_key, asyncio.Lock())
gate = self._concurrency_gate or nullcontext() gate = self._concurrency_gate or nullcontext()
@@ -584,12 +561,12 @@ class AgentLoop:
if self._background_tasks: if self._background_tasks:
await asyncio.gather(*self._background_tasks, return_exceptions=True) await asyncio.gather(*self._background_tasks, return_exceptions=True)
self._background_tasks.clear() self._background_tasks.clear()
for name, stack in self._mcp_stacks.items(): if self._mcp_stack:
try: try:
await stack.aclose() await self._mcp_stack.aclose()
except (RuntimeError, BaseExceptionGroup): except (RuntimeError, BaseExceptionGroup):
logger.debug("MCP server '{}' cleanup error (can be ignored)", name) pass # MCP SDK cancel scope cleanup is noisy but harmless
self._mcp_stacks.clear() self._mcp_stack = None
def _schedule_background(self, coro) -> None: def _schedule_background(self, coro) -> None:
"""Schedule a coroutine as a tracked background task (drained on shutdown).""" """Schedule a coroutine as a tracked background task (drained on shutdown)."""
@@ -614,16 +591,13 @@ class AgentLoop:
"""Process a single inbound message and return the response.""" """Process a single inbound message and return the response."""
# System messages: parse origin from chat_id ("channel:chat_id") # System messages: parse origin from chat_id ("channel:chat_id")
if msg.channel == "system": if msg.channel == "system":
channel, chat_id = ( channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id) else ("cli", msg.chat_id))
)
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)
if self._restore_runtime_checkpoint(session): if self._restore_runtime_checkpoint(session):
self.sessions.save(session) self.sessions.save(session)
if self._restore_pending_user_turn(session):
self.sessions.save(session)
session, pending = self.auto_compact.prepare_session(session, key) session, pending = self.auto_compact.prepare_session(session, key)
@@ -646,11 +620,8 @@ class AgentLoop:
self._clear_runtime_checkpoint(session) self._clear_runtime_checkpoint(session)
self.sessions.save(session) self.sessions.save(session)
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session)) self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
return OutboundMessage( return OutboundMessage(channel=channel, chat_id=chat_id,
channel=channel, content=final_content or "Background task completed.")
chat_id=chat_id,
content=final_content or "Background task completed.",
)
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
@@ -659,8 +630,6 @@ class AgentLoop:
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
if self._restore_runtime_checkpoint(session): if self._restore_runtime_checkpoint(session):
self.sessions.save(session) self.sessions.save(session)
if self._restore_pending_user_turn(session):
self.sessions.save(session)
session, pending = self.auto_compact.prepare_session(session, key) session, pending = self.auto_compact.prepare_session(session, key)
@@ -684,35 +653,16 @@ class AgentLoop:
current_message=msg.content, current_message=msg.content,
session_summary=pending, session_summary=pending,
media=msg.media if msg.media else None, media=msg.media if msg.media else None,
channel=msg.channel, channel=msg.channel, chat_id=msg.chat_id,
chat_id=msg.chat_id,
) )
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None: async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
meta = dict(msg.metadata or {}) meta = dict(msg.metadata or {})
meta["_progress"] = True meta["_progress"] = True
meta["_tool_hint"] = tool_hint meta["_tool_hint"] = tool_hint
await self.bus.publish_outbound( await self.bus.publish_outbound(OutboundMessage(
OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
channel=msg.channel, ))
chat_id=msg.chat_id,
content=content,
metadata=meta,
)
)
# Persist the triggering user message immediately, before running the
# agent loop. If the process is killed mid-turn (OOM, SIGKILL, self-
# restart, etc.), the existing runtime_checkpoint preserves the
# in-flight assistant/tool state but NOT the user message itself, so
# the user's prompt is silently lost on recovery. Saving it up front
# makes recovery possible from the session log alone.
user_persisted_early = False
if isinstance(msg.content, str) and msg.content.strip():
session.add_message("user", msg.content)
self._mark_pending_user_turn(session)
self.sessions.save(session)
user_persisted_early = True
final_content, _, all_msgs, stop_reason, had_injections = await self._run_agent_loop( final_content, _, all_msgs, stop_reason, had_injections = await self._run_agent_loop(
initial_messages, initial_messages,
@@ -720,8 +670,7 @@ class AgentLoop:
on_stream=on_stream, on_stream=on_stream,
on_stream_end=on_stream_end, on_stream_end=on_stream_end,
session=session, session=session,
channel=msg.channel, channel=msg.channel, chat_id=msg.chat_id,
chat_id=msg.chat_id,
message_id=msg.metadata.get("message_id"), message_id=msg.metadata.get("message_id"),
pending_queue=pending_queue, pending_queue=pending_queue,
) )
@@ -729,34 +678,27 @@ class AgentLoop:
if final_content is None or not final_content.strip(): if final_content is None or not final_content.strip():
final_content = EMPTY_FINAL_RESPONSE_MESSAGE final_content = EMPTY_FINAL_RESPONSE_MESSAGE
# Skip the already-persisted user message when saving the turn self._save_turn(session, all_msgs, 1 + len(history))
save_skip = 1 + len(history) + (1 if user_persisted_early else 0)
self._save_turn(session, all_msgs, save_skip)
self._clear_pending_user_turn(session)
self._clear_runtime_checkpoint(session) self._clear_runtime_checkpoint(session)
self.sessions.save(session) self.sessions.save(session)
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session)) self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
# When follow-up messages were injected mid-turn, a later natural # When follow-up messages were injected mid-turn, the LLM's final
# language reply may address those follow-ups and should not be # response addresses those follow-ups. Always send the response in
# suppressed just because MessageTool was used earlier in the turn. # this case, even if MessageTool was used earlier in the turn — the
# However, if the turn falls back to the empty-final-response # follow-up response is new content the user hasn't seen.
# placeholder, suppress it when the real user-visible output already if not had_injections:
# came from MessageTool. 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:
if not had_injections or stop_reason == "empty_final_response":
return None return None
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
meta = dict(msg.metadata or {}) meta = dict(msg.metadata or {})
if on_stream is not None and stop_reason != "error": if on_stream is not None:
meta["_streamed"] = True meta["_streamed"] = True
return OutboundMessage( return OutboundMessage(
channel=msg.channel, channel=msg.channel, chat_id=msg.chat_id, content=final_content,
chat_id=msg.chat_id,
content=final_content,
metadata=meta, metadata=meta,
) )
@@ -764,7 +706,7 @@ class AgentLoop:
self, self,
content: list[dict[str, Any]], content: list[dict[str, Any]],
*, *,
should_truncate_text: bool = False, truncate_text: bool = False,
drop_runtime: bool = False, drop_runtime: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Strip volatile multimodal payloads before writing session history.""" """Strip volatile multimodal payloads before writing session history."""
@@ -782,17 +724,18 @@ class AgentLoop:
): ):
continue continue
if block.get("type") == "image_url" and block.get("image_url", {}).get( if (
"url", "" block.get("type") == "image_url"
).startswith("data:image/"): and block.get("image_url", {}).get("url", "").startswith("data:image/")
):
path = (block.get("_meta") or {}).get("path", "") path = (block.get("_meta") or {}).get("path", "")
filtered.append({"type": "text", "text": image_placeholder_text(path)}) filtered.append({"type": "text", "text": image_placeholder_text(path)})
continue continue
if block.get("type") == "text" and isinstance(block.get("text"), str): if block.get("type") == "text" and isinstance(block.get("text"), str):
text = block["text"] text = block["text"]
if should_truncate_text and len(text) > self.max_tool_result_chars: if truncate_text and len(text) > self.max_tool_result_chars:
text = truncate_text_fn(text, self.max_tool_result_chars) text = truncate_text(text, self.max_tool_result_chars)
filtered.append({**block, "text": text}) filtered.append({**block, "text": text})
continue continue
@@ -803,7 +746,6 @@ class AgentLoop:
def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None: def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
"""Save new-turn messages into session, truncating large tool results.""" """Save new-turn messages into session, truncating large tool results."""
from datetime import datetime from datetime import datetime
for m in messages[skip:]: for m in messages[skip:]:
entry = dict(m) entry = dict(m)
role, content = entry.get("role"), entry.get("content") role, content = entry.get("role"), entry.get("content")
@@ -811,9 +753,9 @@ class AgentLoop:
continue # skip empty assistant messages — they poison session context continue # skip empty assistant messages — they poison session context
if role == "tool": if role == "tool":
if isinstance(content, str) and len(content) > self.max_tool_result_chars: if isinstance(content, str) and len(content) > self.max_tool_result_chars:
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars) entry["content"] = truncate_text(content, self.max_tool_result_chars)
elif isinstance(content, list): elif isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content, should_truncate_text=True) filtered = self._sanitize_persisted_blocks(content, truncate_text=True)
if not filtered: if not filtered:
continue continue
entry["content"] = filtered entry["content"] = filtered
@@ -843,92 +785,13 @@ class AgentLoop:
entry["content"] = filtered entry["content"] = filtered
entry.setdefault("timestamp", datetime.now().isoformat()) entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry) session.messages.append(entry)
# Persist cross-channel message tool calls into target sessions so
# that the target session has context when the user replies there.
self._persist_cross_channel_calls(session, messages[skip:])
session.updated_at = datetime.now() session.updated_at = datetime.now()
def _persist_cross_channel_calls(
self, source_session: Session, new_messages: list[dict[str, Any]]
) -> None:
"""Record cross-channel ``message`` tool calls into the target session.
When session A (e.g. websocket) uses the *message* tool to send to
channel B (e.g. feishu), the outbound message is delivered to the user
but is not recorded in session B's history. This causes session B to
lose context when the user replies on channel B.
This method detects such cross-channel sends and appends a lightweight
assistant entry to the target session so it has the necessary context.
Improvements over the initial implementation:
- Use ``sessions.get_or_create()`` instead of accessing ``_cache``
directly, so sessions persisted on disk but evicted from memory are
still found.
- Persist ``media`` file paths alongside ``content`` so the target
session retains full context about attachments.
- Record ``_source_session`` to make the provenance traceable.
"""
from datetime import datetime
for m in new_messages:
if m.get("role") != "assistant":
continue
tool_calls = m.get("tool_calls") or []
for tc in tool_calls:
func = tc.get("function", {})
if func.get("name") != "message":
continue
try:
args = json.loads(func.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
continue
target_channel = args.get("channel") or source_session.key.split(":", 1)[0]
target_chat_id = args.get("chat_id") or source_session.key.split(":", 1)[-1]
target_key = f"{target_channel}:{target_chat_id}"
if target_key == source_session.key:
continue # same session, nothing to do
content = args.get("content", "")
media = args.get("media")
if not content and not media:
continue
# Use the public API so disk-persisted sessions are loaded too.
target_session = self.sessions.get_or_create(target_key)
entry: dict[str, Any] = {
"role": "assistant",
"content": content,
"timestamp": datetime.now().isoformat(),
"_cross_channel": True,
"_source_session": source_session.key,
}
if media:
entry["_media"] = media
target_session.messages.append(entry)
target_session.updated_at = datetime.now()
self.sessions.save(target_session)
logger.info(
"Cross-channel message persisted: {} -> {}",
source_session.key, target_key,
)
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None: def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
"""Persist the latest in-flight turn state into session metadata.""" """Persist the latest in-flight turn state into session metadata."""
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
self.sessions.save(session) self.sessions.save(session)
def _mark_pending_user_turn(self, session: Session) -> None:
session.metadata[self._PENDING_USER_TURN_KEY] = True
def _clear_pending_user_turn(self, session: Session) -> None:
session.metadata.pop(self._PENDING_USER_TURN_KEY, None)
def _clear_runtime_checkpoint(self, session: Session) -> None: def _clear_runtime_checkpoint(self, session: Session) -> None:
if self._RUNTIME_CHECKPOINT_KEY in session.metadata: if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None) session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
@@ -972,15 +835,13 @@ class AgentLoop:
continue continue
tool_id = tool_call.get("id") tool_id = tool_call.get("id")
name = ((tool_call.get("function") or {}).get("name")) or "tool" name = ((tool_call.get("function") or {}).get("name")) or "tool"
restored_messages.append( restored_messages.append({
{ "role": "tool",
"role": "tool", "tool_call_id": tool_id,
"tool_call_id": tool_id, "name": name,
"name": name, "content": "Error: Task interrupted before this tool finished.",
"content": "Error: Task interrupted before this tool finished.", "timestamp": datetime.now().isoformat(),
"timestamp": datetime.now().isoformat(), })
}
)
overlap = 0 overlap = 0
max_overlap = min(len(session.messages), len(restored_messages)) max_overlap = min(len(session.messages), len(restored_messages))
@@ -995,30 +856,9 @@ class AgentLoop:
break break
session.messages.extend(restored_messages[overlap:]) session.messages.extend(restored_messages[overlap:])
self._clear_pending_user_turn(session)
self._clear_runtime_checkpoint(session) self._clear_runtime_checkpoint(session)
return True return True
def _restore_pending_user_turn(self, session: Session) -> bool:
"""Close a turn that only persisted the user message before crashing."""
from datetime import datetime
if not session.metadata.get(self._PENDING_USER_TURN_KEY):
return False
if session.messages and session.messages[-1].get("role") == "user":
session.messages.append(
{
"role": "assistant",
"content": "Error: Task interrupted before a response was generated.",
"timestamp": datetime.now().isoformat(),
}
)
session.updated_at = datetime.now()
self._clear_pending_user_turn(session)
return True
async def process_direct( async def process_direct(
self, self,
content: str, content: str,
@@ -1033,9 +873,6 @@ class AgentLoop:
await self._connect_mcp() await self._connect_mcp()
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
return await self._process_message( return await self._process_message(
msg, msg, session_key=session_key, on_progress=on_progress,
session_key=session_key, on_stream=on_stream, on_stream_end=on_stream_end,
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
) )
+18 -105
View File
@@ -290,7 +290,7 @@ class MemoryStore:
if not lines: if not lines:
return None return None
return json.loads(lines[-1]) return json.loads(lines[-1])
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError): except (FileNotFoundError, json.JSONDecodeError):
return None return None
def _write_entries(self, entries: list[dict[str, Any]]) -> None: def _write_entries(self, entries: list[dict[str, Any]]) -> None:
@@ -347,7 +347,6 @@ class Consolidator:
"""Lightweight consolidation: summarizes evicted messages into history.jsonl.""" """Lightweight consolidation: summarizes evicted messages into history.jsonl."""
_MAX_CONSOLIDATION_ROUNDS = 5 _MAX_CONSOLIDATION_ROUNDS = 5
_MAX_CHUNK_MESSAGES = 60 # hard cap per consolidation round
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift _SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
@@ -374,6 +373,10 @@ class Consolidator:
weakref.WeakValueDictionary() weakref.WeakValueDictionary()
) )
def get_last_history_entry(self) -> dict[str, Any] | None:
"""Return the most recent entry from history.jsonl."""
return self.store._read_last_entry()
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())
@@ -400,22 +403,6 @@ class Consolidator:
return last_boundary return last_boundary
def _cap_consolidation_boundary(
self,
session: Session,
end_idx: int,
) -> int | None:
"""Clamp the chunk size without breaking the user-turn boundary."""
start = session.last_consolidated
if end_idx - start <= self._MAX_CHUNK_MESSAGES:
return end_idx
capped_end = start + self._MAX_CHUNK_MESSAGES
for idx in range(capped_end, start, -1):
if session.messages[idx].get("role") == "user":
return idx
return None
def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]: def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
"""Estimate current prompt size for the normal session history view.""" """Estimate current prompt size for the normal session history view."""
history = session.get_history(max_messages=0) history = session.get_history(max_messages=0)
@@ -433,13 +420,13 @@ class Consolidator:
self._get_tool_definitions(), self._get_tool_definitions(),
) )
async def archive(self, messages: list[dict]) -> str | None: async def archive(self, messages: list[dict]) -> bool:
"""Summarize messages via LLM and append to history.jsonl. """Summarize messages via LLM and append to history.jsonl.
Returns the summary text on success, None if nothing to archive. Returns True on success (or degraded success), False if nothing to do.
""" """
if not messages: if not messages:
return None return False
try: try:
formatted = MemoryStore._format_messages(messages) formatted = MemoryStore._format_messages(messages)
response = await self.provider.chat_with_retry( response = await self.provider.chat_with_retry(
@@ -459,11 +446,11 @@ class Consolidator:
) )
summary = response.content or "[no summary]" summary = response.content or "[no summary]"
self.store.append_history(summary) self.store.append_history(summary)
return summary return True
except Exception: except Exception:
logger.warning("Consolidation LLM call failed, raw-dumping to history") logger.warning("Consolidation LLM call failed, raw-dumping to history")
self.store.raw_archive(messages) self.store.raw_archive(messages)
return None return True
async def maybe_consolidate_by_tokens(self, session: Session) -> None: async def maybe_consolidate_by_tokens(self, session: Session) -> None:
"""Loop: archive old messages until prompt fits within safe budget. """Loop: archive old messages until prompt fits within safe budget.
@@ -478,22 +465,16 @@ class Consolidator:
async with lock: async with lock:
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
target = budget // 2 target = budget // 2
try: estimated, source = self.estimate_session_prompt_tokens(session)
estimated, source = self.estimate_session_prompt_tokens(session)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0: if estimated <= 0:
return return
if estimated < budget: if estimated < budget:
unconsolidated_count = len(session.messages) - session.last_consolidated
logger.debug( logger.debug(
"Token consolidation idle {}: {}/{} via {}, msgs={}", "Token consolidation idle {}: {}/{} via {}",
session.key, session.key,
estimated, estimated,
self.context_window_tokens, self.context_window_tokens,
source, source,
unconsolidated_count,
) )
return return
@@ -511,15 +492,6 @@ class Consolidator:
return return
end_idx = boundary[0] end_idx = boundary[0]
end_idx = self._cap_consolidation_boundary(session, end_idx)
if end_idx is None:
logger.debug(
"Token consolidation: no capped boundary for {} (round {})",
session.key,
round_num,
)
return
chunk = session.messages[session.last_consolidated:end_idx] chunk = session.messages[session.last_consolidated:end_idx]
if not chunk: if not chunk:
return return
@@ -538,11 +510,7 @@ class Consolidator:
session.last_consolidated = end_idx session.last_consolidated = end_idx
self.sessions.save(session) self.sessions.save(session)
try: estimated, source = self.estimate_session_prompt_tokens(session)
estimated, source = self.estimate_session_prompt_tokens(session)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0: if estimated <= 0:
return return
@@ -582,60 +550,18 @@ class Dream:
def _build_tools(self) -> ToolRegistry: def _build_tools(self) -> ToolRegistry:
"""Build a minimal tool registry for the Dream agent.""" """Build a minimal tool registry for the Dream agent."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
tools = ToolRegistry() tools = ToolRegistry()
workspace = self.store.workspace workspace = self.store.workspace
# Allow reading builtin skills for reference during skill creation tools.register(ReadFileTool(workspace=workspace, allowed_dir=workspace))
extra_read = [BUILTIN_SKILLS_DIR] if BUILTIN_SKILLS_DIR.exists() else None
tools.register(ReadFileTool(
workspace=workspace,
allowed_dir=workspace,
extra_allowed_dirs=extra_read,
))
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace)) tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace))
# write_file resolves relative paths from workspace root, but can only
# write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
skills_dir = workspace / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
tools.register(WriteFileTool(workspace=workspace, allowed_dir=skills_dir))
return tools return tools
# -- skill listing --------------------------------------------------------
def _list_existing_skills(self) -> list[str]:
"""List existing skills as 'name — description' for dedup context."""
import re as _re
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
_DESC_RE = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
entries: dict[str, str] = {}
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
if not base.exists():
continue
for d in base.iterdir():
if not d.is_dir():
continue
skill_md = d / "SKILL.md"
if not skill_md.exists():
continue
# Prefer workspace skills over builtin (same name)
if d.name in entries and base == BUILTIN_SKILLS_DIR:
continue
content = skill_md.read_text(encoding="utf-8")[:500]
m = _DESC_RE.search(content)
desc = m.group(1).strip() if m else "(no description)"
entries[d.name] = desc
return [f"{name}{desc}" for name, desc in sorted(entries.items())]
# -- main entry ---------------------------------------------------------- # -- main entry ----------------------------------------------------------
async def run(self) -> bool: async def run(self) -> bool:
"""Process unprocessed history entries. Returns True if work was done.""" """Process unprocessed history entries. Returns True if work was done."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
last_cursor = self.store.get_last_dream_cursor() last_cursor = self.store.get_last_dream_cursor()
entries = self.store.read_unprocessed_history(since_cursor=last_cursor) entries = self.store.read_unprocessed_history(since_cursor=last_cursor)
if not entries: if not entries:
@@ -657,7 +583,6 @@ class Dream:
current_memory = self.store.read_memory() or "(empty)" current_memory = self.store.read_memory() or "(empty)"
current_soul = self.store.read_soul() or "(empty)" current_soul = self.store.read_soul() or "(empty)"
current_user = self.store.read_user() or "(empty)" current_user = self.store.read_user() or "(empty)"
file_context = ( file_context = (
f"## Current Date\n{current_date}\n\n" f"## Current Date\n{current_date}\n\n"
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n" f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
@@ -665,7 +590,7 @@ class Dream:
f"## Current USER.md ({len(current_user)} chars)\n{current_user}" f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
) )
# Phase 1: Analyze (no skills list — dedup is Phase 2's job) # Phase 1: Analyze
phase1_prompt = ( phase1_prompt = (
f"## Conversation History\n{history_text}\n\n{file_context}" f"## Conversation History\n{history_text}\n\n{file_context}"
) )
@@ -690,25 +615,13 @@ class Dream:
return False return False
# Phase 2: Delegate to AgentRunner with read_file / edit_file # Phase 2: Delegate to AgentRunner with read_file / edit_file
existing_skills = self._list_existing_skills() phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}"
skills_section = ""
if existing_skills:
skills_section = (
"\n\n## Existing Skills\n"
+ "\n".join(f"- {s}" for s in existing_skills)
)
phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}{skills_section}"
tools = self._tools tools = self._tools
skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
messages: list[dict[str, Any]] = [ messages: list[dict[str, Any]] = [
{ {
"role": "system", "role": "system",
"content": render_template( "content": render_template("agent/dream_phase2.md", strip=True),
"agent/dream_phase2.md",
strip=True,
skill_creator_path=str(skill_creator_path),
),
}, },
{"role": "user", "content": phase2_prompt}, {"role": "user", "content": phase2_prompt},
] ]
+33 -252
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
import inspect
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -25,27 +24,16 @@ from nanobot.utils.helpers import (
from nanobot.utils.runtime import ( from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE, EMPTY_FINAL_RESPONSE_MESSAGE,
build_finalization_retry_message, build_finalization_retry_message,
build_length_recovery_message,
ensure_nonempty_tool_result, ensure_nonempty_tool_result,
is_blank_text, is_blank_text,
repeated_external_lookup_error, repeated_external_lookup_error,
) )
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
_PERSISTED_MODEL_ERROR_PLACEHOLDER = "[Assistant reply unavailable due to model error.]"
_MAX_EMPTY_RETRIES = 2 _MAX_EMPTY_RETRIES = 2
_MAX_LENGTH_RECOVERIES = 3
_MAX_INJECTIONS_PER_TURN = 3 _MAX_INJECTIONS_PER_TURN = 3
_MAX_INJECTION_CYCLES = 5 _MAX_INJECTION_CYCLES = 5
_SNIP_SAFETY_BUFFER = 1024 _SNIP_SAFETY_BUFFER = 1024
_MICROCOMPACT_KEEP_RECENT = 10
_MICROCOMPACT_MIN_CHARS = 500
_COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "glob",
"web_search", "web_fetch", "list_dir",
})
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
@dataclass(slots=True) @dataclass(slots=True)
@@ -95,89 +83,37 @@ class AgentRunner:
def __init__(self, provider: LLMProvider): def __init__(self, provider: LLMProvider):
self.provider = provider self.provider = provider
@staticmethod async def _drain_injections(self, spec: AgentRunSpec) -> list[str]:
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
if isinstance(left, str) and isinstance(right, str):
return f"{left}\n\n{right}" if left else right
def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
return [
item if isinstance(item, dict) else {"type": "text", "text": str(item)}
for item in value
]
if value is None:
return []
return [{"type": "text", "text": str(value)}]
return _to_blocks(left) + _to_blocks(right)
@classmethod
def _append_injected_messages(
cls,
messages: list[dict[str, Any]],
injections: list[dict[str, Any]],
) -> None:
"""Append injected user messages while preserving role alternation."""
for injection in injections:
if (
messages
and injection.get("role") == "user"
and messages[-1].get("role") == "user"
):
merged = dict(messages[-1])
merged["content"] = cls._merge_message_content(
merged.get("content"),
injection.get("content"),
)
messages[-1] = merged
continue
messages.append(injection)
async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]:
"""Drain pending user messages via the injection callback. """Drain pending user messages via the injection callback.
Returns normalized user messages (capped by Returns all drained message contents (capped by
``_MAX_INJECTIONS_PER_TURN``), or an empty list when there is ``_MAX_INJECTIONS_PER_TURN``), or an empty list when there is
nothing to inject. Messages beyond the cap are logged so they nothing to inject. Messages beyond the cap are logged so they
are not silently lost. are not silently lost.
""" """
if spec.injection_callback is None: if spec.injection_callback is None:
return [] return []
try: try:
signature = inspect.signature(spec.injection_callback) items = await spec.injection_callback()
accepts_limit = (
"limit" in signature.parameters
or any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in signature.parameters.values()
)
)
if accepts_limit:
items = await spec.injection_callback(limit=_MAX_INJECTIONS_PER_TURN)
else:
items = await spec.injection_callback()
except Exception: except Exception:
logger.exception("injection_callback failed") logger.exception("injection_callback failed")
return [] return []
if not items: if not items:
return [] return []
injected_messages: list[dict[str, Any]] = [] # items are InboundMessage objects from _drain_pending
texts: list[str] = []
for item in items: for item in items:
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
injected_messages.append(item)
continue
text = getattr(item, "content", str(item)) text = getattr(item, "content", str(item))
if text.strip(): if text.strip():
injected_messages.append({"role": "user", "content": text}) texts.append(text)
if len(injected_messages) > _MAX_INJECTIONS_PER_TURN: if len(texts) > _MAX_INJECTIONS_PER_TURN:
dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN dropped = len(texts) - _MAX_INJECTIONS_PER_TURN
logger.warning( logger.warning(
"Injection callback returned {} messages, capping to {} ({} dropped)", "Injection batch has {} messages, capping to {} ({} dropped)",
len(injected_messages), _MAX_INJECTIONS_PER_TURN, dropped, len(texts), _MAX_INJECTIONS_PER_TURN, dropped,
) )
injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN] texts = texts[-_MAX_INJECTIONS_PER_TURN:]
return injected_messages return texts
async def run(self, spec: AgentRunSpec) -> AgentRunResult: async def run(self, spec: AgentRunSpec) -> AgentRunResult:
hook = spec.hook or AgentHook() hook = spec.hook or AgentHook()
@@ -190,36 +126,21 @@ class AgentRunner:
tool_events: list[dict[str, str]] = [] tool_events: list[dict[str, str]] = []
external_lookup_counts: dict[str, int] = {} external_lookup_counts: dict[str, int] = {}
empty_content_retries = 0 empty_content_retries = 0
length_recovery_count = 0
had_injections = False had_injections = False
injection_cycles = 0 injection_cycles = 0
for iteration in range(spec.max_iterations): for iteration in range(spec.max_iterations):
try: try:
# Keep the persisted conversation untouched. Context governance messages = self._apply_tool_result_budget(spec, messages)
# may repair or compact historical messages for the model, but messages_for_model = self._snip_history(spec, messages)
# those synthetic edits must not shift the append boundary used
# later when the caller saves only the new turn.
messages_for_model = self._drop_orphan_tool_results(messages)
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
messages_for_model = self._microcompact(messages_for_model)
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
messages_for_model = self._snip_history(spec, messages_for_model)
# Snipping may have created new orphans; clean them up.
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"Context governance failed on turn {} for {}: {}; applying minimal repair", "Context governance failed on turn {} for {}: {}; using raw messages",
iteration, iteration,
spec.session_key or "default", spec.session_key or "default",
exc, exc,
) )
try: messages_for_model = messages
messages_for_model = self._drop_orphan_tool_results(messages)
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
except Exception:
messages_for_model = messages
context = AgentHookContext(iteration=iteration, messages=messages) context = AgentHookContext(iteration=iteration, messages=messages)
await hook.before_iteration(context) await hook.before_iteration(context)
response = await self._request_model(spec, messages_for_model, hook, context) response = await self._request_model(spec, messages_for_model, hook, context)
@@ -263,6 +184,16 @@ class AgentRunner:
tool_events.extend(new_events) tool_events.extend(new_events)
context.tool_results = list(results) context.tool_results = list(results)
context.tool_events = list(new_events) context.tool_events = list(new_events)
if fatal_error is not None:
error = f"Error: {type(fatal_error).__name__}: {fatal_error}"
final_content = error
stop_reason = "tool_error"
self._append_final_message(messages, final_content)
context.final_content = final_content
context.error = error
context.stop_reason = stop_reason
await hook.after_iteration(context)
break
completed_tool_results: list[dict[str, Any]] = [] completed_tool_results: list[dict[str, Any]] = []
for tool_call, result in zip(response.tool_calls, results): for tool_call, result in zip(response.tool_calls, results):
tool_message = { tool_message = {
@@ -278,16 +209,6 @@ class AgentRunner:
} }
messages.append(tool_message) messages.append(tool_message)
completed_tool_results.append(tool_message) completed_tool_results.append(tool_message)
if fatal_error is not None:
error = f"Error: {type(fatal_error).__name__}: {fatal_error}"
final_content = error
stop_reason = "tool_error"
self._append_final_message(messages, final_content)
context.final_content = final_content
context.error = error
context.stop_reason = stop_reason
await hook.after_iteration(context)
break
await self._emit_checkpoint( await self._emit_checkpoint(
spec, spec,
{ {
@@ -300,14 +221,14 @@ class AgentRunner:
}, },
) )
empty_content_retries = 0 empty_content_retries = 0
length_recovery_count = 0
# Checkpoint 1: drain injections after tools, before next LLM call # Checkpoint 1: drain injections after tools, before next LLM call
if injection_cycles < _MAX_INJECTION_CYCLES: if injection_cycles < _MAX_INJECTION_CYCLES:
injections = await self._drain_injections(spec) injections = await self._drain_injections(spec)
if injections: if injections:
had_injections = True had_injections = True
injection_cycles += 1 injection_cycles += 1
self._append_injected_messages(messages, injections) for text in injections:
messages.append({"role": "user", "content": text})
logger.info( logger.info(
"Injected {} follow-up message(s) after tool execution ({}/{})", "Injected {} follow-up message(s) after tool execution ({}/{})",
len(injections), injection_cycles, _MAX_INJECTION_CYCLES, len(injections), injection_cycles, _MAX_INJECTION_CYCLES,
@@ -347,35 +268,6 @@ class AgentRunner:
context.tool_calls = list(response.tool_calls) context.tool_calls = list(response.tool_calls)
clean = hook.finalize_content(context, response.content) clean = hook.finalize_content(context, response.content)
if response.finish_reason == "length" and not is_blank_text(clean):
length_recovery_count += 1
if length_recovery_count <= _MAX_LENGTH_RECOVERIES:
logger.info(
"Output truncated on turn {} for {} ({}/{}); continuing",
iteration,
spec.session_key or "default",
length_recovery_count,
_MAX_LENGTH_RECOVERIES,
)
if hook.wants_streaming():
await hook.on_stream_end(context, resuming=True)
messages.append(build_assistant_message(
clean,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
))
messages.append(build_length_recovery_message())
await hook.after_iteration(context)
continue
assistant_message: dict[str, Any] | None = None
if response.finish_reason != "error" and not is_blank_text(clean):
assistant_message = build_assistant_message(
clean,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
)
# Check for mid-turn injections BEFORE signaling stream end. # Check for mid-turn injections BEFORE signaling stream end.
# If injections are found we keep the stream alive (resuming=True) # If injections are found we keep the stream alive (resuming=True)
# so streaming channels don't prematurely finalize the card. # so streaming channels don't prematurely finalize the card.
@@ -386,20 +278,8 @@ class AgentRunner:
had_injections = True had_injections = True
injection_cycles += 1 injection_cycles += 1
_injected_after_final = True _injected_after_final = True
if assistant_message is not None: for text in injections:
messages.append(assistant_message) messages.append({"role": "user", "content": text})
await self._emit_checkpoint(
spec,
{
"phase": "final_response",
"iteration": iteration,
"model": spec.model,
"assistant_message": assistant_message,
"completed_tool_results": [],
"pending_tool_calls": [],
},
)
self._append_injected_messages(messages, injections)
logger.info( logger.info(
"Injected {} follow-up message(s) after final response ({}/{})", "Injected {} follow-up message(s) after final response ({}/{})",
len(injections), injection_cycles, _MAX_INJECTION_CYCLES, len(injections), injection_cycles, _MAX_INJECTION_CYCLES,
@@ -416,7 +296,7 @@ class AgentRunner:
final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE
stop_reason = "error" stop_reason = "error"
error = final_content error = final_content
self._append_model_error_placeholder(messages) self._append_final_message(messages, final_content)
context.final_content = final_content context.final_content = final_content
context.error = error context.error = error
context.stop_reason = stop_reason context.stop_reason = stop_reason
@@ -433,7 +313,7 @@ class AgentRunner:
await hook.after_iteration(context) await hook.after_iteration(context)
break break
messages.append(assistant_message or build_assistant_message( messages.append(build_assistant_message(
clean, clean,
reasoning_content=response.reasoning_content, reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks, thinking_blocks=response.thinking_blocks,
@@ -681,12 +561,6 @@ class AgentRunner:
return return
messages.append(build_assistant_message(content)) messages.append(build_assistant_message(content))
@staticmethod
def _append_model_error_placeholder(messages: list[dict[str, Any]]) -> None:
if messages and messages[-1].get("role") == "assistant" and not messages[-1].get("tool_calls"):
return
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
def _normalize_tool_result( def _normalize_tool_result(
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
@@ -715,99 +589,6 @@ class AgentRunner:
return truncate_text(content, spec.max_tool_result_chars) return truncate_text(content, spec.max_tool_result_chars)
return content return content
@staticmethod
def _drop_orphan_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop tool results that have no matching assistant tool_call earlier in the history."""
declared: set[str] = set()
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
declared.add(str(tc["id"]))
if role == "tool":
tid = msg.get("tool_call_id")
if tid and str(tid) not in declared:
if updated is None:
updated = [dict(m) for m in messages[:idx]]
continue
if updated is not None:
updated.append(dict(msg))
if updated is None:
return messages
return updated
@staticmethod
def _backfill_missing_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Insert synthetic error results for orphaned tool_use blocks."""
declared: list[tuple[int, str, str]] = [] # (assistant_idx, call_id, name)
fulfilled: set[str] = set()
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
name = ""
func = tc.get("function")
if isinstance(func, dict):
name = func.get("name", "")
declared.append((idx, str(tc["id"]), name))
elif role == "tool":
tid = msg.get("tool_call_id")
if tid:
fulfilled.add(str(tid))
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
if not missing:
return messages
updated = list(messages)
offset = 0
for assistant_idx, call_id, name in missing:
insert_at = assistant_idx + 1 + offset
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
insert_at += 1
updated.insert(insert_at, {
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": _BACKFILL_CONTENT,
})
offset += 1
return updated
@staticmethod
def _microcompact(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Replace old compactable tool results with one-line summaries."""
compactable_indices: list[int] = []
for idx, msg in enumerate(messages):
if msg.get("role") == "tool" and msg.get("name") in _COMPACTABLE_TOOLS:
compactable_indices.append(idx)
if len(compactable_indices) <= _MICROCOMPACT_KEEP_RECENT:
return messages
stale = compactable_indices[: len(compactable_indices) - _MICROCOMPACT_KEEP_RECENT]
updated: list[dict[str, Any]] | None = None
for idx in stale:
msg = messages[idx]
content = msg.get("content")
if not isinstance(content, str) or len(content) < _MICROCOMPACT_MIN_CHARS:
continue
name = msg.get("name", "tool")
summary = f"[{name} result omitted from context]"
if updated is None:
updated = [dict(m) for m in messages]
updated[idx]["content"] = summary
return updated if updated is not None else messages
def _apply_tool_result_budget( def _apply_tool_result_budget(
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
+1 -5
View File
@@ -28,11 +28,10 @@ class SkillsLoader:
specific tools or perform certain tasks. specific tools or perform certain tasks.
""" """
def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None, disabled_skills: set[str] | None = None): def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None):
self.workspace = workspace self.workspace = workspace
self.workspace_skills = workspace / "skills" self.workspace_skills = workspace / "skills"
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
self.disabled_skills = disabled_skills or set()
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]: def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
if not base.exists(): if not base.exists():
@@ -67,9 +66,6 @@ class SkillsLoader:
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names) self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
) )
if self.disabled_skills:
skills = [s for s in skills if s["name"] not in self.disabled_skills]
if filter_unavailable: if filter_unavailable:
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))] return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
return skills return skills
+1 -7
View File
@@ -27,7 +27,6 @@ class _SubagentHook(AgentHook):
"""Logging-only hook for subagent execution.""" """Logging-only hook for subagent execution."""
def __init__(self, task_id: str) -> None: def __init__(self, task_id: str) -> None:
super().__init__()
self._task_id = task_id self._task_id = task_id
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
@@ -52,7 +51,6 @@ class SubagentManager:
web_config: "WebToolsConfig | None" = None, web_config: "WebToolsConfig | None" = None,
exec_config: "ExecToolConfig | None" = None, exec_config: "ExecToolConfig | None" = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
disabled_skills: list[str] | None = None,
): ):
from nanobot.config.schema import ExecToolConfig from nanobot.config.schema import ExecToolConfig
@@ -64,7 +62,6 @@ class SubagentManager:
self.max_tool_result_chars = max_tool_result_chars self.max_tool_result_chars = max_tool_result_chars
self.exec_config = exec_config or ExecToolConfig() self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.disabled_skills = set(disabled_skills or [])
self.runner = AgentRunner(provider) self.runner = AgentRunner(provider)
self._running_tasks: dict[str, asyncio.Task[None]] = {} self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
@@ -238,10 +235,7 @@ class SubagentManager:
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
time_ctx = ContextBuilder._build_runtime_context(None, None) time_ctx = ContextBuilder._build_runtime_context(None, None)
skills_summary = SkillsLoader( skills_summary = SkillsLoader(self.workspace).build_skills_summary()
self.workspace,
disabled_skills=self.disabled_skills,
).build_skills_summary()
return render_template( return render_template(
"agent/subagent_system.md", "agent/subagent_system.md",
time_ctx=time_ctx, time_ctx=time_ctx,
-105
View File
@@ -1,105 +0,0 @@
"""Track file-read state for read-before-edit warnings and read deduplication."""
from __future__ import annotations
import hashlib
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(slots=True)
class ReadState:
mtime: float
offset: int
limit: int | None
content_hash: str | None
can_dedup: bool
_state: dict[str, ReadState] = {}
def _hash_file(p: str) -> str | None:
try:
return hashlib.sha256(Path(p).read_bytes()).hexdigest()
except OSError:
return None
def record_read(path: str | Path, offset: int = 1, limit: int | None = None) -> None:
"""Record that a file was read (called after successful read)."""
p = str(Path(path).resolve())
try:
mtime = os.path.getmtime(p)
except OSError:
return
_state[p] = ReadState(
mtime=mtime,
offset=offset,
limit=limit,
content_hash=_hash_file(p),
can_dedup=True,
)
def record_write(path: str | Path) -> None:
"""Record that a file was written (updates mtime in state)."""
p = str(Path(path).resolve())
try:
mtime = os.path.getmtime(p)
except OSError:
_state.pop(p, None)
return
_state[p] = ReadState(
mtime=mtime,
offset=1,
limit=None,
content_hash=_hash_file(p),
can_dedup=False,
)
def check_read(path: str | Path) -> str | None:
"""Check if a file has been read and is fresh.
Returns None if OK, or a warning string.
When mtime changed but file content is identical (e.g. touch, editor save),
the check passes to avoid false-positive staleness warnings.
"""
p = str(Path(path).resolve())
entry = _state.get(p)
if entry is None:
return "Warning: file has not been read yet. Read it first to verify content before editing."
try:
current_mtime = os.path.getmtime(p)
except OSError:
return None
if current_mtime != entry.mtime:
if entry.content_hash and _hash_file(p) == entry.content_hash:
entry.mtime = current_mtime
return None
return "Warning: file has been modified since last read. Re-read to verify content before editing."
return None
def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
"""Return True if file was previously read with same params and mtime is unchanged."""
p = str(Path(path).resolve())
entry = _state.get(p)
if entry is None:
return False
if not entry.can_dedup:
return False
if entry.offset != offset or entry.limit != limit:
return False
try:
current_mtime = os.path.getmtime(p)
except OSError:
return False
return current_mtime == entry.mtime
def clear() -> None:
"""Clear all tracked state (useful for testing)."""
_state.clear()
+42 -472
View File
@@ -2,13 +2,11 @@
import difflib import difflib
import mimetypes import mimetypes
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.agent.tools import file_state
from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@@ -62,36 +60,6 @@ class _FsTool(Tool):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_BLOCKED_DEVICE_PATHS = frozenset({
"/dev/zero", "/dev/random", "/dev/urandom", "/dev/full",
"/dev/stdin", "/dev/stdout", "/dev/stderr",
"/dev/tty", "/dev/console",
"/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
})
def _is_blocked_device(path: str | Path) -> bool:
"""Check if path is a blocked device that could hang or produce infinite output."""
import re
raw = str(path)
if raw in _BLOCKED_DEVICE_PATHS:
return True
if re.match(r"/proc/\d+/fd/[012]$", raw) or re.match(r"/proc/self/fd/[012]$", raw):
return True
return False
def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
"""Parse a page range like '2-5' into 0-based (start, end) inclusive."""
parts = pages.strip().split("-")
if len(parts) == 1:
p = int(parts[0])
return max(0, p - 1), min(p - 1, total - 1)
start = int(parts[0])
end = int(parts[1])
return max(0, start - 1), min(end - 1, total - 1)
@tool_parameters( @tool_parameters(
tool_parameters_schema( tool_parameters_schema(
path=StringSchema("The file path to read"), path=StringSchema("The file path to read"),
@@ -105,7 +73,6 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
description="Maximum number of lines to read (default 2000)", description="Maximum number of lines to read (default 2000)",
minimum=1, minimum=1,
), ),
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
required=["path"], required=["path"],
) )
) )
@@ -114,7 +81,6 @@ class ReadFileTool(_FsTool):
_MAX_CHARS = 128_000 _MAX_CHARS = 128_000
_DEFAULT_LIMIT = 2000 _DEFAULT_LIMIT = 2000
_MAX_PDF_PAGES = 20
@property @property
def name(self) -> str: def name(self) -> str:
@@ -123,38 +89,24 @@ class ReadFileTool(_FsTool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return (
"Read a file (text or image). Text output format: LINE_NUM|CONTENT. " "Read the contents of a file. Returns numbered lines. "
"Images return visual content for analysis. " "Use offset and limit to paginate through large files."
"Use offset and limit for large files. "
"Cannot read non-image binary files. "
"Reads exceeding ~128K chars are truncated."
) )
@property @property
def read_only(self) -> bool: def read_only(self) -> bool:
return True return True
async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, pages: str | None = None, **kwargs: Any) -> Any: async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, **kwargs: Any) -> Any:
try: try:
if not path: if not path:
return "Error reading file: Unknown path" return "Error reading file: Unknown path"
# Device path blacklist
if _is_blocked_device(path):
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
fp = self._resolve(path) fp = self._resolve(path)
if _is_blocked_device(fp):
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
if not fp.exists(): if not fp.exists():
return f"Error: File not found: {path}" return f"Error: File not found: {path}"
if not fp.is_file(): if not fp.is_file():
return f"Error: Not a file: {path}" return f"Error: Not a file: {path}"
# PDF support
if fp.suffix.lower() == ".pdf":
return self._read_pdf(fp, pages)
raw = fp.read_bytes() raw = fp.read_bytes()
if not raw: if not raw:
return f"(Empty file: {path})" return f"(Empty file: {path})"
@@ -163,10 +115,6 @@ class ReadFileTool(_FsTool):
if mime and mime.startswith("image/"): if mime and mime.startswith("image/"):
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})") return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
# Read dedup: same path + offset + limit + unchanged mtime → stub
if file_state.is_unchanged(fp, offset=offset, limit=limit):
return f"[File unchanged since last read: {path}]"
try: try:
text_content = raw.decode("utf-8") text_content = raw.decode("utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
@@ -199,59 +147,12 @@ class ReadFileTool(_FsTool):
result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)" result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)"
else: else:
result += f"\n\n(End of file — {total} lines total)" result += f"\n\n(End of file — {total} lines total)"
file_state.record_read(fp, offset=offset, limit=limit)
return result return result
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return f"Error: {e}"
except Exception as e: except Exception as e:
return f"Error reading file: {e}" return f"Error reading file: {e}"
def _read_pdf(self, fp: Path, pages: str | None) -> str:
try:
import fitz # pymupdf
except ImportError:
return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf"
try:
doc = fitz.open(str(fp))
except Exception as e:
return f"Error reading PDF: {e}"
total_pages = len(doc)
if pages:
try:
start, end = _parse_page_range(pages, total_pages)
except (ValueError, IndexError):
doc.close()
return f"Error: Invalid page range '{pages}'. Use format like '1-5'."
if start > end or start >= total_pages:
doc.close()
return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)."
else:
start = 0
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
if end - start + 1 > self._MAX_PDF_PAGES:
end = start + self._MAX_PDF_PAGES - 1
parts: list[str] = []
for i in range(start, end + 1):
page = doc[i]
text = page.get_text().strip()
if text:
parts.append(f"--- Page {i + 1} ---\n{text}")
doc.close()
if not parts:
return f"(PDF has no extractable text: {fp})"
result = "\n\n".join(parts)
if end < total_pages - 1:
result += f"\n\n(Showing pages {start + 1}-{end + 1} of {total_pages}. Use pages='{end + 2}-{min(end + 1 + self._MAX_PDF_PAGES, total_pages)}' to continue.)"
if len(result) > self._MAX_CHARS:
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# write_file # write_file
@@ -274,11 +175,7 @@ class WriteFileTool(_FsTool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return "Write content to a file at the given path. Creates parent directories if needed."
"Write content to a file. Overwrites if the file already exists; "
"creates parent directories as needed. "
"For partial edits, prefer edit_file instead."
)
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str: async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
try: try:
@@ -289,7 +186,6 @@ class WriteFileTool(_FsTool):
fp = self._resolve(path) fp = self._resolve(path)
fp.parent.mkdir(parents=True, exist_ok=True) fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content, encoding="utf-8") fp.write_text(content, encoding="utf-8")
file_state.record_write(fp)
return f"Successfully wrote {len(content)} characters to {fp}" return f"Successfully wrote {len(content)} characters to {fp}"
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return f"Error: {e}"
@@ -301,269 +197,30 @@ class WriteFileTool(_FsTool):
# edit_file # edit_file
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_QUOTE_TABLE = str.maketrans({
"\u2018": "'", "\u2019": "'", # curly single → straight
"\u201c": '"', "\u201d": '"', # curly double → straight
"'": "'", '"': '"', # identity (kept for completeness)
})
def _normalize_quotes(s: str) -> str:
return s.translate(_QUOTE_TABLE)
def _curly_double_quotes(text: str) -> str:
parts: list[str] = []
opening = True
for ch in text:
if ch == '"':
parts.append("\u201c" if opening else "\u201d")
opening = not opening
else:
parts.append(ch)
return "".join(parts)
def _curly_single_quotes(text: str) -> str:
parts: list[str] = []
opening = True
for i, ch in enumerate(text):
if ch != "'":
parts.append(ch)
continue
prev_ch = text[i - 1] if i > 0 else ""
next_ch = text[i + 1] if i + 1 < len(text) else ""
if prev_ch.isalnum() and next_ch.isalnum():
parts.append("\u2019")
continue
parts.append("\u2018" if opening else "\u2019")
opening = not opening
return "".join(parts)
def _preserve_quote_style(old_text: str, actual_text: str, new_text: str) -> str:
"""Preserve curly quote style when a quote-normalized fallback matched."""
if _normalize_quotes(old_text.strip()) != _normalize_quotes(actual_text.strip()) or old_text == actual_text:
return new_text
styled = new_text
if any(ch in actual_text for ch in ("\u201c", "\u201d")) and '"' in styled:
styled = _curly_double_quotes(styled)
if any(ch in actual_text for ch in ("\u2018", "\u2019")) and "'" in styled:
styled = _curly_single_quotes(styled)
return styled
def _leading_ws(line: str) -> str:
return line[: len(line) - len(line.lstrip(" \t"))]
def _reindent_like_match(old_text: str, actual_text: str, new_text: str) -> str:
"""Preserve the outer indentation from the actual matched block."""
old_lines = old_text.split("\n")
actual_lines = actual_text.split("\n")
if len(old_lines) != len(actual_lines):
return new_text
comparable = [
(old_line, actual_line)
for old_line, actual_line in zip(old_lines, actual_lines)
if old_line.strip() and actual_line.strip()
]
if not comparable or any(
_normalize_quotes(old_line.strip()) != _normalize_quotes(actual_line.strip())
for old_line, actual_line in comparable
):
return new_text
old_ws = _leading_ws(comparable[0][0])
actual_ws = _leading_ws(comparable[0][1])
if actual_ws == old_ws:
return new_text
if old_ws:
if not actual_ws.startswith(old_ws):
return new_text
delta = actual_ws[len(old_ws):]
else:
delta = actual_ws
if not delta:
return new_text
return "\n".join((delta + line) if line else line for line in new_text.split("\n"))
@dataclass(slots=True)
class _MatchSpan:
start: int
end: int
text: str
line: int
def _find_exact_matches(content: str, old_text: str) -> list[_MatchSpan]:
matches: list[_MatchSpan] = []
start = 0
while True:
idx = content.find(old_text, start)
if idx == -1:
break
matches.append(
_MatchSpan(
start=idx,
end=idx + len(old_text),
text=content[idx : idx + len(old_text)],
line=content.count("\n", 0, idx) + 1,
)
)
start = idx + max(1, len(old_text))
return matches
def _find_trim_matches(content: str, old_text: str, *, normalize_quotes: bool = False) -> list[_MatchSpan]:
old_lines = old_text.splitlines()
if not old_lines:
return []
content_lines = content.splitlines()
content_lines_keepends = content.splitlines(keepends=True)
if len(content_lines) < len(old_lines):
return []
offsets: list[int] = []
pos = 0
for line in content_lines_keepends:
offsets.append(pos)
pos += len(line)
offsets.append(pos)
if normalize_quotes:
stripped_old = [_normalize_quotes(line.strip()) for line in old_lines]
else:
stripped_old = [line.strip() for line in old_lines]
matches: list[_MatchSpan] = []
window_size = len(stripped_old)
for i in range(len(content_lines) - window_size + 1):
window = content_lines[i : i + window_size]
if normalize_quotes:
comparable = [_normalize_quotes(line.strip()) for line in window]
else:
comparable = [line.strip() for line in window]
if comparable != stripped_old:
continue
start = offsets[i]
end = offsets[i + window_size]
if content_lines_keepends[i + window_size - 1].endswith("\n"):
end -= 1
matches.append(
_MatchSpan(
start=start,
end=end,
text=content[start:end],
line=i + 1,
)
)
return matches
def _find_quote_matches(content: str, old_text: str) -> list[_MatchSpan]:
norm_content = _normalize_quotes(content)
norm_old = _normalize_quotes(old_text)
matches: list[_MatchSpan] = []
start = 0
while True:
idx = norm_content.find(norm_old, start)
if idx == -1:
break
matches.append(
_MatchSpan(
start=idx,
end=idx + len(old_text),
text=content[idx : idx + len(old_text)],
line=content.count("\n", 0, idx) + 1,
)
)
start = idx + max(1, len(norm_old))
return matches
def _find_matches(content: str, old_text: str) -> list[_MatchSpan]:
"""Locate all matches using progressively looser strategies."""
for matcher in (
lambda: _find_exact_matches(content, old_text),
lambda: _find_trim_matches(content, old_text),
lambda: _find_trim_matches(content, old_text, normalize_quotes=True),
lambda: _find_quote_matches(content, old_text),
):
matches = matcher()
if matches:
return matches
return []
def _find_match_line_numbers(content: str, old_text: str) -> list[int]:
"""Return 1-based starting line numbers for the current matching strategies."""
return [match.line for match in _find_matches(content, old_text)]
def _collapse_internal_whitespace(text: str) -> str:
return "\n".join(" ".join(line.split()) for line in text.splitlines())
def _diagnose_near_match(old_text: str, actual_text: str) -> list[str]:
"""Return actionable hints describing why text was close but not exact."""
hints: list[str] = []
if old_text.lower() == actual_text.lower() and old_text != actual_text:
hints.append("letter case differs")
if _collapse_internal_whitespace(old_text) == _collapse_internal_whitespace(actual_text) and old_text != actual_text:
hints.append("whitespace differs")
if old_text.rstrip("\n") == actual_text.rstrip("\n") and old_text != actual_text:
hints.append("trailing newline differs")
if _normalize_quotes(old_text) == _normalize_quotes(actual_text) and old_text != actual_text:
hints.append("quote style differs")
return hints
def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], list[str]]:
"""Find the closest line-window match and return ratio/start/snippet/hints."""
lines = content.splitlines(keepends=True)
old_lines = old_text.splitlines(keepends=True)
window = max(1, len(old_lines))
best_ratio, best_start = -1.0, 0
best_window_lines: list[str] = []
for i in range(max(1, len(lines) - window + 1)):
current = lines[i : i + window]
ratio = difflib.SequenceMatcher(None, old_lines, current).ratio()
if ratio > best_ratio:
best_ratio, best_start = ratio, i
best_window_lines = current
actual_text = "".join(best_window_lines).replace("\r\n", "\n").rstrip("\n")
hints = _diagnose_near_match(old_text.replace("\r\n", "\n").rstrip("\n"), actual_text)
return best_ratio, best_start, best_window_lines, hints
def _find_match(content: str, old_text: str) -> tuple[str | None, int]: def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
"""Locate old_text in content with a multi-level fallback chain: """Locate old_text in content: exact first, then line-trimmed sliding window.
1. Exact substring match
2. Line-trimmed sliding window (handles indentation differences)
3. Smart quote normalization (curly ↔ straight quotes)
Both inputs should use LF line endings (caller normalises CRLF). Both inputs should use LF line endings (caller normalises CRLF).
Returns (matched_fragment, count) or (None, 0). Returns (matched_fragment, count) or (None, 0).
""" """
matches = _find_matches(content, old_text) if old_text in content:
if not matches: return old_text, content.count(old_text)
old_lines = old_text.splitlines()
if not old_lines:
return None, 0 return None, 0
return matches[0].text, len(matches) stripped_old = [l.strip() for l in old_lines]
content_lines = content.splitlines()
candidates = []
for i in range(len(content_lines) - len(stripped_old) + 1):
window = content_lines[i : i + len(stripped_old)]
if [l.strip() for l in window] == stripped_old:
candidates.append("\n".join(window))
if candidates:
return candidates[0], len(candidates)
return None, 0
@tool_parameters( @tool_parameters(
@@ -578,9 +235,6 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
class EditFileTool(_FsTool): class EditFileTool(_FsTool):
"""Edit a file by replacing text with fallback matching.""" """Edit a file by replacing text with fallback matching."""
_MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 # 1 GiB
_MARKDOWN_EXTS = frozenset({".md", ".mdx", ".markdown"})
@property @property
def name(self) -> str: def name(self) -> str:
return "edit_file" return "edit_file"
@@ -589,16 +243,10 @@ class EditFileTool(_FsTool):
def description(self) -> str: def description(self) -> str:
return ( return (
"Edit a file by replacing old_text with new_text. " "Edit a file by replacing old_text with new_text. "
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. " "Supports minor whitespace/line-ending differences. "
"If old_text matches multiple times, you must provide more context " "Set replace_all=true to replace every occurrence."
"or set replace_all=true. Shows a diff of the closest match on failure."
) )
@staticmethod
def _strip_trailing_ws(text: str) -> str:
"""Strip trailing whitespace from each line."""
return "\n".join(line.rstrip() for line in text.split("\n"))
async def execute( async def execute(
self, path: str | None = None, old_text: str | None = None, self, path: str | None = None, old_text: str | None = None,
new_text: str | None = None, new_text: str | None = None,
@@ -612,133 +260,55 @@ class EditFileTool(_FsTool):
if new_text is None: if new_text is None:
raise ValueError("Unknown new_text") raise ValueError("Unknown new_text")
# .ipynb detection
if path.endswith(".ipynb"):
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
fp = self._resolve(path) fp = self._resolve(path)
# Create-file semantics: old_text='' + file doesn't exist → create
if not fp.exists(): if not fp.exists():
if old_text == "": return f"Error: File not found: {path}"
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(new_text, encoding="utf-8")
file_state.record_write(fp)
return f"Successfully created {fp}"
return self._file_not_found_msg(path, fp)
# File size protection
try:
fsize = fp.stat().st_size
except OSError:
fsize = 0
if fsize > self._MAX_EDIT_FILE_SIZE:
return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB."
# Create-file: old_text='' but file exists and not empty → reject
if old_text == "":
raw = fp.read_bytes()
content = raw.decode("utf-8")
if content.strip():
return f"Error: Cannot create file — {path} already exists and is not empty."
fp.write_text(new_text, encoding="utf-8")
file_state.record_write(fp)
return f"Successfully edited {fp}"
# Read-before-edit check
warning = file_state.check_read(fp)
raw = fp.read_bytes() raw = fp.read_bytes()
uses_crlf = b"\r\n" in raw uses_crlf = b"\r\n" in raw
content = raw.decode("utf-8").replace("\r\n", "\n") content = raw.decode("utf-8").replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n") match, count = _find_match(content, old_text.replace("\r\n", "\n"))
matches = _find_matches(content, norm_old)
if not matches: if match is None:
return self._not_found_msg(old_text, content, path) return self._not_found_msg(old_text, content, path)
count = len(matches)
if count > 1 and not replace_all: if count > 1 and not replace_all:
line_numbers = [match.line for match in matches]
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
if len(line_numbers) > 3:
preview += ", ..."
location_hint = f" at {preview}" if preview else ""
return ( return (
f"Warning: old_text appears {count} times{location_hint}. " f"Warning: old_text appears {count} times. "
"Provide more context to make it unique, or set replace_all=true." "Provide more context to make it unique, or set replace_all=true."
) )
norm_new = new_text.replace("\r\n", "\n") norm_new = new_text.replace("\r\n", "\n")
new_content = content.replace(match, norm_new) if replace_all else content.replace(match, norm_new, 1)
# Trailing whitespace stripping (skip markdown to preserve double-space line breaks)
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
norm_new = self._strip_trailing_ws(norm_new)
selected = matches if replace_all else matches[:1]
new_content = content
for match in reversed(selected):
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
replacement = _reindent_like_match(norm_old, match.text, replacement)
# Delete-line cleanup: when deleting text (new_text=''), consume trailing
# newline to avoid leaving a blank line
end = match.end
if replacement == "" and not match.text.endswith("\n") and content[end:end + 1] == "\n":
end += 1
new_content = new_content[: match.start] + replacement + new_content[end:]
if uses_crlf: if uses_crlf:
new_content = new_content.replace("\n", "\r\n") new_content = new_content.replace("\n", "\r\n")
fp.write_bytes(new_content.encode("utf-8")) fp.write_bytes(new_content.encode("utf-8"))
file_state.record_write(fp) return f"Successfully edited {fp}"
msg = f"Successfully edited {fp}"
if warning:
msg = f"{warning}\n{msg}"
return msg
except PermissionError as e: except PermissionError as e:
return f"Error: {e}" return f"Error: {e}"
except Exception as e: except Exception as e:
return f"Error editing file: {e}" return f"Error editing file: {e}"
def _file_not_found_msg(self, path: str, fp: Path) -> str:
"""Build an error message with 'Did you mean ...?' suggestions."""
parent = fp.parent
suggestions: list[str] = []
if parent.is_dir():
siblings = [f.name for f in parent.iterdir() if f.is_file()]
close = difflib.get_close_matches(fp.name, siblings, n=3, cutoff=0.6)
suggestions = [str(parent / c) for c in close]
parts = [f"Error: File not found: {path}"]
if suggestions:
parts.append("Did you mean: " + ", ".join(suggestions) + "?")
return "\n".join(parts)
@staticmethod @staticmethod
def _not_found_msg(old_text: str, content: str, path: str) -> str: def _not_found_msg(old_text: str, content: str, path: str) -> str:
best_ratio, best_start, best_window_lines, hints = _best_window(old_text, content) lines = content.splitlines(keepends=True)
old_lines = old_text.splitlines(keepends=True)
window = len(old_lines)
best_ratio, best_start = 0.0, 0
for i in range(max(1, len(lines) - window + 1)):
ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio()
if ratio > best_ratio:
best_ratio, best_start = ratio, i
if best_ratio > 0.5: if best_ratio > 0.5:
diff = "\n".join(difflib.unified_diff( diff = "\n".join(difflib.unified_diff(
old_text.splitlines(keepends=True), old_lines, lines[best_start : best_start + window],
best_window_lines,
fromfile="old_text (provided)", fromfile="old_text (provided)",
tofile=f"{path} (actual, line {best_start + 1})", tofile=f"{path} (actual, line {best_start + 1})",
lineterm="", lineterm="",
)) ))
hint_text = "" return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
if hints:
hint_text = "\nPossible cause: " + ", ".join(hints) + "."
return (
f"Error: old_text not found in {path}."
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
)
if hints:
return (
f"Error: old_text not found in {path}. "
f"Possible cause: {', '.join(hints)}. "
"Copy the exact text from read_file and try again."
)
return f"Error: old_text not found in {path}. No similar text found. Verify the file content." return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
+19 -264
View File
@@ -57,7 +57,9 @@ def _normalize_schema_for_openai(schema: Any) -> dict[str, Any]:
if "properties" in normalized and isinstance(normalized["properties"], dict): if "properties" in normalized and isinstance(normalized["properties"], dict):
normalized["properties"] = { normalized["properties"] = {
name: _normalize_schema_for_openai(prop) if isinstance(prop, dict) else prop name: _normalize_schema_for_openai(prop)
if isinstance(prop, dict)
else prop
for name, prop in normalized["properties"].items() for name, prop in normalized["properties"].items()
} }
@@ -133,214 +135,36 @@ class MCPToolWrapper(Tool):
return "\n".join(parts) or "(no output)" return "\n".join(parts) or "(no output)"
class MCPResourceWrapper(Tool):
"""Wraps an MCP resource URI as a read-only nanobot Tool."""
def __init__(self, session, server_name: str, resource_def, resource_timeout: int = 30):
self._session = session
self._uri = resource_def.uri
self._name = f"mcp_{server_name}_resource_{resource_def.name}"
desc = resource_def.description or resource_def.name
self._description = f"[MCP Resource] {desc}\nURI: {self._uri}"
self._parameters: dict[str, Any] = {
"type": "object",
"properties": {},
"required": [],
}
self._resource_timeout = resource_timeout
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def parameters(self) -> dict[str, Any]:
return self._parameters
@property
def read_only(self) -> bool:
return True
async def execute(self, **kwargs: Any) -> str:
from mcp import types
try:
result = await asyncio.wait_for(
self._session.read_resource(self._uri),
timeout=self._resource_timeout,
)
except asyncio.TimeoutError:
logger.warning(
"MCP resource '{}' timed out after {}s", self._name, self._resource_timeout
)
return f"(MCP resource read timed out after {self._resource_timeout}s)"
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
return "(MCP resource read was cancelled)"
except Exception as exc:
logger.exception(
"MCP resource '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP resource read failed: {type(exc).__name__})"
parts: list[str] = []
for block in result.contents:
if isinstance(block, types.TextResourceContents):
parts.append(block.text)
elif isinstance(block, types.BlobResourceContents):
parts.append(f"[Binary resource: {len(block.blob)} bytes]")
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
class MCPPromptWrapper(Tool):
"""Wraps an MCP prompt as a read-only nanobot Tool."""
def __init__(self, session, server_name: str, prompt_def, prompt_timeout: int = 30):
self._session = session
self._prompt_name = prompt_def.name
self._name = f"mcp_{server_name}_prompt_{prompt_def.name}"
desc = prompt_def.description or prompt_def.name
self._description = (
f"[MCP Prompt] {desc}\n"
"Returns a filled prompt template that can be used as a workflow guide."
)
self._prompt_timeout = prompt_timeout
# Build parameters from prompt arguments
properties: dict[str, Any] = {}
required: list[str] = []
for arg in prompt_def.arguments or []:
prop: dict[str, Any] = {"type": "string"}
if getattr(arg, "description", None):
prop["description"] = arg.description
properties[arg.name] = prop
if arg.required:
required.append(arg.name)
self._parameters: dict[str, Any] = {
"type": "object",
"properties": properties,
"required": required,
}
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def parameters(self) -> dict[str, Any]:
return self._parameters
@property
def read_only(self) -> bool:
return True
async def execute(self, **kwargs: Any) -> str:
from mcp import types
from mcp.shared.exceptions import McpError
try:
result = await asyncio.wait_for(
self._session.get_prompt(self._prompt_name, arguments=kwargs),
timeout=self._prompt_timeout,
)
except asyncio.TimeoutError:
logger.warning("MCP prompt '{}' timed out after {}s", self._name, self._prompt_timeout)
return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)"
except McpError as exc:
logger.error(
"MCP prompt '{}' failed: code={} message={}",
self._name,
exc.error.code,
exc.error.message,
)
return f"(MCP prompt call failed: {exc.error.message} [code {exc.error.code}])"
except Exception as exc:
logger.exception(
"MCP prompt '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP prompt call failed: {type(exc).__name__})"
parts: list[str] = []
for message in result.messages:
content = message.content
# content is a single ContentBlock (not a list) in MCP SDK >= 1.x
if isinstance(content, types.TextContent):
parts.append(content.text)
elif isinstance(content, list):
for block in content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
else:
parts.append(str(content))
return "\n".join(parts) or "(no output)"
async def connect_mcp_servers( async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
) -> dict[str, AsyncExitStack]: ) -> None:
"""Connect to configured MCP servers and register their tools, resources, prompts. """Connect to configured MCP servers and register their tools."""
Returns a dict mapping server name -> its dedicated AsyncExitStack.
Each server gets its own stack and runs in its own task to prevent
cancel scope conflicts when multiple MCP servers are configured.
"""
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
async def connect_single_server(name: str, cfg) -> tuple[str, AsyncExitStack | None]: for name, cfg in mcp_servers.items():
server_stack = AsyncExitStack()
await server_stack.__aenter__()
try: try:
transport_type = cfg.type transport_type = cfg.type
if not transport_type: if not transport_type:
if cfg.command: if cfg.command:
transport_type = "stdio" transport_type = "stdio"
elif cfg.url: elif cfg.url:
# Convention: URLs ending with /sse use SSE transport; others use streamableHttp
transport_type = ( transport_type = (
"sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp" "sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp"
) )
else: else:
logger.warning("MCP server '{}': no command or url configured, skipping", name) logger.warning("MCP server '{}': no command or url configured, skipping", name)
await server_stack.aclose() continue
return name, None
if transport_type == "stdio": if transport_type == "stdio":
params = StdioServerParameters( params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=cfg.env or None command=cfg.command, args=cfg.args, env=cfg.env or None
) )
read, write = await server_stack.enter_async_context(stdio_client(params)) read, write = await stack.enter_async_context(stdio_client(params))
elif transport_type == "sse": elif transport_type == "sse":
def httpx_client_factory( def httpx_client_factory(
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None, timeout: httpx.Timeout | None = None,
@@ -358,26 +182,27 @@ async def connect_mcp_servers(
auth=auth, auth=auth,
) )
read, write = await server_stack.enter_async_context( read, write = await stack.enter_async_context(
sse_client(cfg.url, httpx_client_factory=httpx_client_factory) sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
) )
elif transport_type == "streamableHttp": elif transport_type == "streamableHttp":
http_client = await server_stack.enter_async_context( # Always provide an explicit httpx client so MCP HTTP transport does not
# inherit httpx's default 5s timeout and preempt the higher-level tool timeout.
http_client = await stack.enter_async_context(
httpx.AsyncClient( httpx.AsyncClient(
headers=cfg.headers or None, headers=cfg.headers or None,
follow_redirects=True, follow_redirects=True,
timeout=None, timeout=None,
) )
) )
read, write, _ = await server_stack.enter_async_context( read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client) streamable_http_client(cfg.url, http_client=http_client)
) )
else: else:
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type) logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
await server_stack.aclose() continue
return name, None
session = await server_stack.enter_async_context(ClientSession(read, write)) session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize() await session.initialize()
tools = await session.list_tools() tools = await session.list_tools()
@@ -422,76 +247,6 @@ async def connect_mcp_servers(
", ".join(available_wrapped_names) or "(none)", ", ".join(available_wrapped_names) or "(none)",
) )
try: logger.info("MCP server '{}': connected, {} tools registered", name, registered_count)
resources_result = await session.list_resources()
for resource in resources_result.resources:
wrapper = MCPResourceWrapper(
session, name, resource, resource_timeout=cfg.tool_timeout
)
registry.register(wrapper)
registered_count += 1
logger.debug(
"MCP: registered resource '{}' from server '{}'", wrapper.name, name
)
except Exception as e:
logger.debug("MCP server '{}': resources not supported or failed: {}", name, e)
try:
prompts_result = await session.list_prompts()
for prompt in prompts_result.prompts:
wrapper = MCPPromptWrapper(
session, name, prompt, prompt_timeout=cfg.tool_timeout
)
registry.register(wrapper)
registered_count += 1
logger.debug("MCP: registered prompt '{}' from server '{}'", wrapper.name, name)
except Exception as e:
logger.debug("MCP server '{}': prompts not supported or failed: {}", name, e)
logger.info(
"MCP server '{}': connected, {} capabilities registered", name, registered_count
)
return name, server_stack
except Exception as e: except Exception as e:
hint = "" logger.error("MCP server '{}': failed to connect: {}", name, e)
text = str(e).lower()
if any(
marker in text
for marker in (
"parse error",
"invalid json",
"unexpected token",
"jsonrpc",
"content-length",
)
):
hint = (
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
)
logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint)
try:
await server_stack.aclose()
except Exception:
pass
return name, None
server_stacks: dict[str, AsyncExitStack] = {}
tasks: list[asyncio.Task] = []
for name, cfg in mcp_servers.items():
task = asyncio.create_task(connect_single_server(name, cfg))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
name = list(mcp_servers.keys())[i]
if isinstance(result, BaseException):
if not isinstance(result, asyncio.CancelledError):
logger.error("MCP server '{}' connection task failed: {}", name, result)
elif result is not None and result[1] is not None:
server_stacks[result[0]] = result[1]
return server_stacks
-161
View File
@@ -1,161 +0,0 @@
"""NotebookEditTool — edit Jupyter .ipynb notebooks."""
from __future__ import annotations
import json
import uuid
from typing import Any
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.agent.tools.filesystem import _FsTool
def _new_cell(source: str, cell_type: str = "code", generate_id: bool = False) -> dict:
cell: dict[str, Any] = {
"cell_type": cell_type,
"source": source,
"metadata": {},
}
if cell_type == "code":
cell["outputs"] = []
cell["execution_count"] = None
if generate_id:
cell["id"] = uuid.uuid4().hex[:8]
return cell
def _make_empty_notebook() -> dict:
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
},
"cells": [],
}
@tool_parameters(
tool_parameters_schema(
path=StringSchema("Path to the .ipynb notebook file"),
cell_index=IntegerSchema(0, description="0-based index of the cell to edit", minimum=0),
new_source=StringSchema("New source content for the cell"),
cell_type=StringSchema(
"Cell type: 'code' or 'markdown' (default: code)",
enum=["code", "markdown"],
),
edit_mode=StringSchema(
"Mode: 'replace' (default), 'insert' (after target), or 'delete'",
enum=["replace", "insert", "delete"],
),
required=["path", "cell_index"],
)
)
class NotebookEditTool(_FsTool):
"""Edit Jupyter notebook cells: replace, insert, or delete."""
_VALID_CELL_TYPES = frozenset({"code", "markdown"})
_VALID_EDIT_MODES = frozenset({"replace", "insert", "delete"})
@property
def name(self) -> str:
return "notebook_edit"
@property
def description(self) -> str:
return (
"Edit a Jupyter notebook (.ipynb) cell. "
"Modes: replace (default) replaces cell content, "
"insert adds a new cell after the target index, "
"delete removes the cell at the index. "
"cell_index is 0-based."
)
async def execute(
self,
path: str | None = None,
cell_index: int = 0,
new_source: str = "",
cell_type: str = "code",
edit_mode: str = "replace",
**kwargs: Any,
) -> str:
try:
if not path:
return "Error: path is required"
if not path.endswith(".ipynb"):
return "Error: notebook_edit only works on .ipynb files. Use edit_file for other files."
if edit_mode not in self._VALID_EDIT_MODES:
return (
f"Error: Invalid edit_mode '{edit_mode}'. "
"Use one of: replace, insert, delete."
)
if cell_type not in self._VALID_CELL_TYPES:
return (
f"Error: Invalid cell_type '{cell_type}'. "
"Use one of: code, markdown."
)
fp = self._resolve(path)
# Create new notebook if file doesn't exist and mode is insert
if not fp.exists():
if edit_mode != "insert":
return f"Error: File not found: {path}"
nb = _make_empty_notebook()
cell = _new_cell(new_source, cell_type, generate_id=True)
nb["cells"].append(cell)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully created {fp} with 1 cell"
try:
nb = json.loads(fp.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
return f"Error: Failed to parse notebook: {e}"
cells = nb.get("cells", [])
nbformat_minor = nb.get("nbformat_minor", 0)
generate_id = nb.get("nbformat", 0) >= 4 and nbformat_minor >= 5
if edit_mode == "delete":
if cell_index < 0 or cell_index >= len(cells):
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
cells.pop(cell_index)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully deleted cell {cell_index} from {fp}"
if edit_mode == "insert":
insert_at = min(cell_index + 1, len(cells))
cell = _new_cell(new_source, cell_type, generate_id=generate_id)
cells.insert(insert_at, cell)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully inserted cell at index {insert_at} in {fp}"
# Default: replace
if cell_index < 0 or cell_index >= len(cells):
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
cells[cell_index]["source"] = new_source
if cell_type and cells[cell_index].get("cell_type") != cell_type:
cells[cell_index]["cell_type"] = cell_type
if cell_type == "code":
cells[cell_index].setdefault("outputs", [])
cells[cell_index].setdefault("execution_count", None)
elif "outputs" in cells[cell_index]:
del cells[cell_index]["outputs"]
cells[cell_index].pop("execution_count", None)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully edited cell {cell_index} in {fp}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error editing notebook: {e}"
-7
View File
@@ -68,13 +68,6 @@ class ToolRegistry:
params: dict[str, Any], params: dict[str, Any],
) -> tuple[Tool | None, dict[str, Any], str | None]: ) -> tuple[Tool | None, dict[str, Any], str | None]:
"""Resolve, cast, and validate one tool call.""" """Resolve, cast, and validate one tool call."""
# Guard against invalid parameter types (e.g., list instead of dict)
if not isinstance(params, dict) and name in ('write_file', 'read_file'):
return None, params, (
f"Error: Tool '{name}' parameters must be a JSON object, got {type(params).__name__}. "
"Use named parameters: tool_name(param1=\"value1\", param2=\"value2\")"
)
tool = self._tools.get(name) tool = self._tools.get(name)
if not tool: if not tool:
return None, params, ( return None, params, (
+5 -7
View File
@@ -142,9 +142,8 @@ class GlobTool(_SearchTool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return (
"Find files matching a glob pattern (e.g. '*.py', 'tests/**/test_*.py'). " "Find files matching a glob pattern. "
"Results are sorted by modification time (newest first). " "Simple patterns like '*.py' match by filename recursively."
"Skips .git, node_modules, __pycache__, and other noise directories."
) )
@property @property
@@ -262,10 +261,9 @@ class GrepTool(_SearchTool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return (
"Search file contents with a regex pattern. " "Search file contents with a regex-like pattern. "
"Default output_mode is files_with_matches (file paths only); " "Supports optional glob filtering, structured output modes, "
"use content mode for matching lines with context. " "type filters, pagination, and surrounding context lines."
"Skips binary and files >2 MB. Supports glob/type filtering."
) )
@property @property
+3 -50
View File
@@ -46,7 +46,6 @@ class ExecTool(Tool):
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
sandbox: str = "", sandbox: str = "",
path_append: str = "", path_append: str = "",
allowed_env_keys: list[str] | None = None,
): ):
self.timeout = timeout self.timeout = timeout
self.working_dir = working_dir self.working_dir = working_dir
@@ -61,19 +60,10 @@ class ExecTool(Tool):
r">\s*/dev/sd", # write to disk r">\s*/dev/sd", # write to disk
r"\b(shutdown|reboot|poweroff)\b", # system power r"\b(shutdown|reboot|poweroff)\b", # system power
r":\(\)\s*\{.*\};\s*:", # fork bomb r":\(\)\s*\{.*\};\s*:", # fork bomb
# Block writes to nanobot internal state files (#2989).
# history.jsonl / .dream_cursor are managed by append_history();
# direct writes corrupt the cursor format and crash /dream.
r">>?\s*\S*(?:history\.jsonl|\.dream_cursor)", # > / >> redirect
r"\btee\b[^|;&<>]*(?:history\.jsonl|\.dream_cursor)", # tee / tee -a
r"\b(?:cp|mv)\b(?:\s+[^\s|;&<>]+)+\s+\S*(?:history\.jsonl|\.dream_cursor)", # cp/mv target
r"\bdd\b[^|;&<>]*\bof=\S*(?:history\.jsonl|\.dream_cursor)", # dd of=
r"\bsed\s+-i[^|;&<>]*(?:history\.jsonl|\.dream_cursor)", # sed -i
] ]
self.allow_patterns = allow_patterns or [] self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.path_append = path_append self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
@property @property
def name(self) -> str: def name(self) -> str:
@@ -84,13 +74,7 @@ class ExecTool(Tool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return "Execute a shell command and return its output. Use with caution."
"Execute a shell command and return its output. "
"Prefer read_file/write_file/edit_file over cat/echo/sed, "
"and grep/glob over shell find/grep. "
"Use -y or --yes flags to avoid interactive prompts. "
"Output is truncated at 10 000 chars; timeout defaults to 60s."
)
@property @property
def exclusive(self) -> bool: def exclusive(self) -> bool:
@@ -101,21 +85,6 @@ class ExecTool(Tool):
timeout: int | None = None, **kwargs: Any, timeout: int | None = None, **kwargs: Any,
) -> str: ) -> str:
cwd = working_dir or self.working_dir or os.getcwd() cwd = working_dir or self.working_dir or os.getcwd()
# Prevent an LLM-supplied working_dir from escaping the configured
# workspace when restrict_to_workspace is enabled (#2826). Without
# this, a caller can pass working_dir="/etc" and then all absolute
# paths under /etc would pass the _guard_command check that anchors
# on cwd.
if self.restrict_to_workspace and self.working_dir:
try:
requested = Path(cwd).expanduser().resolve()
workspace_root = Path(self.working_dir).expanduser().resolve()
except Exception:
return "Error: working_dir could not be resolved"
if requested != workspace_root and workspace_root not in requested.parents:
return "Error: working_dir is outside the configured workspace"
guard_error = self._guard_command(command, cwd) guard_error = self._guard_command(command, cwd)
if guard_error: if guard_error:
return guard_error return guard_error
@@ -233,7 +202,7 @@ class ExecTool(Tool):
""" """
if _IS_WINDOWS: if _IS_WINDOWS:
sr = os.environ.get("SYSTEMROOT", r"C:\Windows") sr = os.environ.get("SYSTEMROOT", r"C:\Windows")
env = { return {
"SYSTEMROOT": sr, "SYSTEMROOT": sr,
"COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"), "COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"),
"USERPROFILE": os.environ.get("USERPROFILE", ""), "USERPROFILE": os.environ.get("USERPROFILE", ""),
@@ -243,29 +212,13 @@ class ExecTool(Tool):
"TMP": os.environ.get("TMP", f"{sr}\\Temp"), "TMP": os.environ.get("TMP", f"{sr}\\Temp"),
"PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"), "PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"),
"PATH": os.environ.get("PATH", f"{sr}\\system32;{sr}"), "PATH": os.environ.get("PATH", f"{sr}\\system32;{sr}"),
"APPDATA": os.environ.get("APPDATA", ""),
"LOCALAPPDATA": os.environ.get("LOCALAPPDATA", ""),
"ProgramData": os.environ.get("ProgramData", ""),
"ProgramFiles": os.environ.get("ProgramFiles", ""),
"ProgramFiles(x86)": os.environ.get("ProgramFiles(x86)", ""),
"ProgramW6432": os.environ.get("ProgramW6432", ""),
} }
for key in self.allowed_env_keys:
val = os.environ.get(key)
if val is not None:
env[key] = val
return env
home = os.environ.get("HOME", "/tmp") home = os.environ.get("HOME", "/tmp")
env = { return {
"HOME": home, "HOME": home,
"LANG": os.environ.get("LANG", "C.UTF-8"), "LANG": os.environ.get("LANG", "C.UTF-8"),
"TERM": os.environ.get("TERM", "dumb"), "TERM": os.environ.get("TERM", "dumb"),
} }
for key in self.allowed_env_keys:
val = os.environ.get(key)
if val is not None:
env[key] = val
return env
def _guard_command(self, command: str, cwd: str) -> str | None: def _guard_command(self, command: str, cwd: str) -> str | None:
"""Best-effort safety guard for potentially destructive commands.""" """Best-effort safety guard for potentially destructive commands."""
+2 -35
View File
@@ -84,11 +84,7 @@ class WebSearchTool(Tool):
"""Search the web using configured provider.""" """Search the web using configured provider."""
name = "web_search" name = "web_search"
description = ( description = "Search the web. Returns titles, URLs, and snippets."
"Search the web. Returns titles, URLs, and snippets. "
"count defaults to 5 (max 10). "
"Use web_fetch to read a specific page in full."
)
def __init__(self, config: WebSearchConfig | None = None, proxy: str | None = None): def __init__(self, config: WebSearchConfig | None = None, proxy: str | None = None):
from nanobot.config.schema import WebSearchConfig from nanobot.config.schema import WebSearchConfig
@@ -114,8 +110,6 @@ class WebSearchTool(Tool):
return await self._search_jina(query, n) return await self._search_jina(query, n)
elif provider == "brave": elif provider == "brave":
return await self._search_brave(query, n) return await self._search_brave(query, n)
elif provider == "kagi":
return await self._search_kagi(query, n)
else: else:
return f"Error: unknown search provider '{provider}'" return f"Error: unknown search provider '{provider}'"
@@ -206,29 +200,6 @@ class WebSearchTool(Tool):
logger.warning("Jina search failed ({}), falling back to DuckDuckGo", e) logger.warning("Jina search failed ({}), falling back to DuckDuckGo", e)
return await self._search_duckduckgo(query, n) return await self._search_duckduckgo(query, n)
async def _search_kagi(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "")
if not api_key:
logger.warning("KAGI_API_KEY not set, falling back to DuckDuckGo")
return await self._search_duckduckgo(query, n)
try:
async with httpx.AsyncClient(proxy=self.proxy) as client:
r = await client.get(
"https://kagi.com/api/v0/search",
params={"q": query, "limit": n},
headers={"Authorization": f"Bot {api_key}"},
timeout=10.0,
)
r.raise_for_status()
# t=0 items are search results; other values are related searches, etc.
items = [
{"title": d.get("title", ""), "url": d.get("url", ""), "content": d.get("snippet", "")}
for d in r.json().get("data", []) if d.get("t") == 0
]
return _format_results(query, items, n)
except Exception as e:
return f"Error: {e}"
async def _search_duckduckgo(self, query: str, n: int) -> str: async def _search_duckduckgo(self, query: str, n: int) -> str:
try: try:
# Note: duckduckgo_search is synchronous and does its own requests # Note: duckduckgo_search is synchronous and does its own requests
@@ -268,11 +239,7 @@ class WebFetchTool(Tool):
"""Fetch and extract content from a URL.""" """Fetch and extract content from a URL."""
name = "web_fetch" name = "web_fetch"
description = ( description = "Fetch URL and extract readable content (HTML → markdown/text)."
"Fetch a URL and extract readable content (HTML → markdown/text). "
"Output is capped at maxChars (default 50 000). "
"Works for most web pages and docs; may fail on login-walled or JS-heavy sites."
)
def __init__(self, max_chars: int = 50000, proxy: str | None = None): def __init__(self, max_chars: int = 50000, proxy: str | None = None):
self.max_chars = max_chars self.max_chars = max_chars
-38
View File
@@ -5,8 +5,6 @@ import json
import mimetypes import mimetypes
import os import os
import time import time
import zipfile
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
@@ -173,7 +171,6 @@ class DingTalkChannel(BaseChannel):
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"} _AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
_ZIP_BEFORE_UPLOAD_EXTS = {".htm", ".html"}
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
@@ -290,31 +287,6 @@ class DingTalkChannel(BaseChannel):
name = os.path.basename(urlparse(media_ref).path) name = os.path.basename(urlparse(media_ref).path)
return name or {"image": "image.jpg", "voice": "audio.amr", "video": "video.mp4"}.get(upload_type, "file.bin") return name or {"image": "image.jpg", "voice": "audio.amr", "video": "video.mp4"}.get(upload_type, "file.bin")
@staticmethod
def _zip_bytes(filename: str, data: bytes) -> tuple[bytes, str, str]:
stem = Path(filename).stem or "attachment"
safe_name = filename or "attachment.bin"
zip_name = f"{stem}.zip"
buffer = BytesIO()
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr(safe_name, data)
return buffer.getvalue(), zip_name, "application/zip"
def _normalize_upload_payload(
self,
filename: str,
data: bytes,
content_type: str | None,
) -> tuple[bytes, str, str | None]:
ext = Path(filename).suffix.lower()
if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html":
logger.info(
"DingTalk does not accept raw HTML attachments, zipping {} before upload",
filename,
)
return self._zip_bytes(filename, data)
return data, filename, content_type
async def _read_media_bytes( async def _read_media_bytes(
self, self,
media_ref: str, media_ref: str,
@@ -337,9 +309,6 @@ class DingTalkChannel(BaseChannel):
content_type = (resp.headers.get("content-type") or "").split(";")[0].strip() content_type = (resp.headers.get("content-type") or "").split(";")[0].strip()
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
return resp.content, filename, content_type or None return resp.content, filename, content_type or None
except httpx.TransportError as e:
logger.error("DingTalk media download network error ref={} err={}", media_ref, e)
raise
except Exception as e: except Exception as e:
logger.error("DingTalk media download error ref={} err={}", media_ref, e) logger.error("DingTalk media download error ref={} err={}", media_ref, e)
return None, None, None return None, None, None
@@ -391,9 +360,6 @@ class DingTalkChannel(BaseChannel):
logger.error("DingTalk media upload missing media_id body={}", text[:500]) logger.error("DingTalk media upload missing media_id body={}", text[:500])
return None return None
return str(media_id) return str(media_id)
except httpx.TransportError as e:
logger.error("DingTalk media upload network error type={} err={}", media_type, e)
raise
except Exception as e: except Exception as e:
logger.error("DingTalk media upload error type={} err={}", media_type, e) logger.error("DingTalk media upload error type={} err={}", media_type, e)
return None return None
@@ -443,9 +409,6 @@ class DingTalkChannel(BaseChannel):
return False return False
logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key)
return True return True
except httpx.TransportError as e:
logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e)
raise
except Exception as e: except Exception as e:
logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e) logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e)
return False return False
@@ -481,7 +444,6 @@ class DingTalkChannel(BaseChannel):
return False return False
filename = filename or self._guess_filename(media_ref, upload_type) filename = filename or self._guess_filename(media_ref, upload_type)
data, filename, content_type = self._normalize_upload_payload(filename, data, content_type)
file_type = Path(filename).suffix.lower().lstrip(".") file_type = Path(filename).suffix.lower().lstrip(".")
if not file_type: if not file_type:
guessed = mimetypes.guess_extension(content_type or "") guessed = mimetypes.guess_extension(content_type or "")
+7 -165
View File
@@ -4,8 +4,6 @@ from __future__ import annotations
import asyncio import asyncio
import importlib.util import importlib.util
import time
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, Literal
@@ -22,7 +20,6 @@ from nanobot.utils.helpers import safe_filename, split_message
DISCORD_AVAILABLE = importlib.util.find_spec("discord") is not None DISCORD_AVAILABLE = importlib.util.find_spec("discord") is not None
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp
import discord import discord
from discord import app_commands from discord import app_commands
from discord.abc import Messageable from discord.abc import Messageable
@@ -37,16 +34,6 @@ MAX_MESSAGE_LEN = 2000 # Discord message character limit
TYPING_INTERVAL_S = 8 TYPING_INTERVAL_S = 8
@dataclass
class _StreamBuf:
"""Per-chat streaming accumulator for progressive Discord message edits."""
text: str = ""
message: Any | None = None
last_edit: float = 0.0
stream_id: str | None = None
class DiscordConfig(Base): class DiscordConfig(Base):
"""Discord channel configuration.""" """Discord channel configuration."""
@@ -58,10 +45,6 @@ class DiscordConfig(Base):
read_receipt_emoji: str = "👀" read_receipt_emoji: str = "👀"
working_emoji: str = "🔧" working_emoji: str = "🔧"
working_emoji_delay: float = 2.0 working_emoji_delay: float = 2.0
streaming: bool = True
proxy: str | None = None
proxy_username: str | None = None
proxy_password: str | None = None
if DISCORD_AVAILABLE: if DISCORD_AVAILABLE:
@@ -69,15 +52,8 @@ if DISCORD_AVAILABLE:
class DiscordBotClient(discord.Client): class DiscordBotClient(discord.Client):
"""discord.py client that forwards events to the channel.""" """discord.py client that forwards events to the channel."""
def __init__( def __init__(self, channel: DiscordChannel, *, intents: discord.Intents) -> None:
self, super().__init__(intents=intents)
channel: DiscordChannel,
*,
intents: discord.Intents,
proxy: str | None = None,
proxy_auth: aiohttp.BasicAuth | None = None,
) -> None:
super().__init__(intents=intents, proxy=proxy, proxy_auth=proxy_auth)
self._channel = channel self._channel = channel
self.tree = app_commands.CommandTree(self) self.tree = app_commands.CommandTree(self)
self._register_app_commands() self._register_app_commands()
@@ -141,7 +117,6 @@ if DISCORD_AVAILABLE:
) )
for name, description, command_text in commands: for name, description, command_text in commands:
@self.tree.command(name=name, description=description) @self.tree.command(name=name, description=description)
async def command_handler( async def command_handler(
interaction: discord.Interaction, interaction: discord.Interaction,
@@ -198,9 +173,7 @@ if DISCORD_AVAILABLE:
else: else:
failed_media.append(Path(media_path).name) failed_media.append(Path(media_path).name)
for index, chunk in enumerate( for index, chunk in enumerate(self._build_chunks(msg.content or "", failed_media, sent_media)):
self._build_chunks(msg.content or "", failed_media, sent_media)
):
kwargs: dict[str, Any] = {"content": chunk} kwargs: dict[str, Any] = {"content": chunk}
if index == 0 and reference is not None and not sent_media: if index == 0 and reference is not None and not sent_media:
kwargs["reference"] = reference kwargs["reference"] = reference
@@ -269,7 +242,6 @@ class DiscordChannel(BaseChannel):
name = "discord" name = "discord"
display_name = "Discord" display_name = "Discord"
_STREAM_EDIT_INTERVAL = 0.8
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
@@ -291,7 +263,6 @@ class DiscordChannel(BaseChannel):
self._bot_user_id: str | None = None self._bot_user_id: str | None = None
self._pending_reactions: dict[str, Any] = {} # chat_id -> message object self._pending_reactions: dict[str, Any] = {} # chat_id -> message object
self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {} self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {}
self._stream_bufs: dict[str, _StreamBuf] = {}
async def start(self) -> None: async def start(self) -> None:
"""Start the Discord client.""" """Start the Discord client."""
@@ -306,29 +277,7 @@ class DiscordChannel(BaseChannel):
try: try:
intents = discord.Intents.none() intents = discord.Intents.none()
intents.value = self.config.intents intents.value = self.config.intents
self._client = DiscordBotClient(self, intents=intents)
proxy_auth = None
has_user = bool(self.config.proxy_username)
has_pass = bool(self.config.proxy_password)
if has_user and has_pass:
import aiohttp
proxy_auth = aiohttp.BasicAuth(
login=self.config.proxy_username,
password=self.config.proxy_password,
)
elif has_user != has_pass:
logger.warning(
"Discord proxy auth incomplete: both proxy_username and "
"proxy_password must be set; ignoring partial credentials",
)
self._client = DiscordBotClient(
self,
intents=intents,
proxy=self.config.proxy,
proxy_auth=proxy_auth,
)
except Exception as e: except Exception as e:
logger.error("Failed to initialize Discord client: {}", e) logger.error("Failed to initialize Discord client: {}", e)
self._client = None self._client = None
@@ -366,71 +315,11 @@ class DiscordChannel(BaseChannel):
await client.send_outbound(msg) await client.send_outbound(msg)
except Exception as e: except Exception as e:
logger.error("Error sending Discord message: {}", e) logger.error("Error sending Discord message: {}", e)
raise
finally: finally:
if not is_progress: if not is_progress:
await self._stop_typing(msg.chat_id) await self._stop_typing(msg.chat_id)
await self._clear_reactions(msg.chat_id) await self._clear_reactions(msg.chat_id)
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
"""Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client
if client is None or not client.is_ready():
logger.warning("Discord client not ready; dropping stream delta")
return
meta = metadata or {}
stream_id = meta.get("_stream_id")
if meta.get("_stream_end"):
buf = self._stream_bufs.get(chat_id)
if not buf or buf.message is None or not buf.text:
return
if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id:
return
await self._finalize_stream(chat_id, buf)
return
buf = self._stream_bufs.get(chat_id)
if buf is None or (
stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id
):
buf = _StreamBuf(stream_id=stream_id)
self._stream_bufs[chat_id] = buf
elif buf.stream_id is None:
buf.stream_id = stream_id
buf.text += delta
if not buf.text.strip():
return
target = await self._resolve_channel(chat_id)
if target is None:
logger.warning("Discord stream target {} unavailable", chat_id)
return
now = time.monotonic()
if buf.message is None:
try:
buf.message = await target.send(content=buf.text)
buf.last_edit = now
except Exception as e:
logger.warning("Discord stream initial send failed: {}", e)
raise
return
if (now - buf.last_edit) < self._STREAM_EDIT_INTERVAL:
return
try:
await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0])
buf.last_edit = now
except Exception as e:
logger.warning("Discord stream edit failed: {}", e)
raise
async def _handle_discord_message(self, message: discord.Message) -> None: async def _handle_discord_message(self, message: discord.Message) -> None:
"""Handle incoming Discord messages from discord.py.""" """Handle incoming Discord messages from discord.py."""
if message.author.bot: if message.author.bot:
@@ -484,47 +373,6 @@ class DiscordChannel(BaseChannel):
"""Backward-compatible alias for legacy tests/callers.""" """Backward-compatible alias for legacy tests/callers."""
await self._handle_discord_message(message) await self._handle_discord_message(message)
async def _resolve_channel(self, chat_id: str) -> Any | None:
"""Resolve a Discord channel from cache first, then network fetch."""
client = self._client
if client is None or not client.is_ready():
return None
channel_id = int(chat_id)
channel = client.get_channel(channel_id)
if channel is not None:
return channel
try:
return await client.fetch_channel(channel_id)
except Exception as e:
logger.warning("Discord channel {} unavailable: {}", chat_id, e)
return None
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
"""Commit the final streamed content and flush overflow chunks."""
chunks = DiscordBotClient._build_chunks(buf.text, [], False)
if not chunks:
self._stream_bufs.pop(chat_id, None)
return
try:
await buf.message.edit(content=chunks[0])
except Exception as e:
logger.warning("Discord final stream edit failed: {}", e)
raise
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
if target is None:
logger.warning("Discord stream follow-up target {} unavailable", chat_id)
self._stream_bufs.pop(chat_id, None)
return
for extra_chunk in chunks[1:]:
await target.send(content=extra_chunk)
self._stream_bufs.pop(chat_id, None)
await self._stop_typing(chat_id)
await self._clear_reactions(chat_id)
def _should_accept_inbound( def _should_accept_inbound(
self, self,
message: discord.Message, message: discord.Message,
@@ -575,11 +423,7 @@ class DiscordChannel(BaseChannel):
@staticmethod @staticmethod
def _build_inbound_metadata(message: discord.Message) -> dict[str, str | None]: def _build_inbound_metadata(message: discord.Message) -> dict[str, str | None]:
"""Build metadata for inbound Discord messages.""" """Build metadata for inbound Discord messages."""
reply_to = ( reply_to = str(message.reference.message_id) if message.reference and message.reference.message_id else None
str(message.reference.message_id)
if message.reference and message.reference.message_id
else None
)
return { return {
"message_id": str(message.id), "message_id": str(message.id),
"guild_id": str(message.guild.id) if message.guild else None, "guild_id": str(message.guild.id) if message.guild else None,
@@ -594,9 +438,7 @@ class DiscordChannel(BaseChannel):
if self.config.group_policy == "mention": if self.config.group_policy == "mention":
bot_user_id = self._bot_user_id bot_user_id = self._bot_user_id
if bot_user_id is None: if bot_user_id is None:
logger.debug( logger.debug("Discord message in {} ignored (bot identity unavailable)", message.channel.id)
"Discord message in {} ignored (bot identity unavailable)", message.channel.id
)
return False return False
if any(str(user.id) == bot_user_id for user in message.mentions): if any(str(user.id) == bot_user_id for user in message.mentions):
@@ -638,6 +480,7 @@ class DiscordChannel(BaseChannel):
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
async def _clear_reactions(self, chat_id: str) -> None: async def _clear_reactions(self, chat_id: str) -> None:
"""Remove all pending reactions after bot replies.""" """Remove all pending reactions after bot replies."""
# Cancel delayed working emoji if it hasn't fired yet # Cancel delayed working emoji if it hasn't fired yet
@@ -664,7 +507,6 @@ class DiscordChannel(BaseChannel):
async def _reset_runtime_state(self, close_client: bool) -> None: async def _reset_runtime_state(self, close_client: bool) -> None:
"""Reset client and typing state.""" """Reset client and typing state."""
await self._cancel_all_typing() await self._cancel_all_typing()
self._stream_bufs.clear()
if close_client and self._client is not None and not self._client.is_closed(): if close_client and self._client is not None and not self._client.is_closed():
try: try:
await self._client.close() await self._client.close()
+522
View File
@@ -0,0 +1,522 @@
"""Microsoft Teams channel MVP using a tiny built-in HTTP webhook server.
Scope:
- DM-focused MVP
- text inbound/outbound
- conversation reference persistence
- sender allowlist support
- optional inbound Bot Framework bearer-token validation
- no attachments/cards/polls yet
"""
from __future__ import annotations
import asyncio
import html
import importlib.util
import json
import re
import threading
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import TYPE_CHECKING, Any
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_workspace_path
from nanobot.config.schema import Base
MSTEAMS_AVAILABLE = importlib.util.find_spec("jwt") is not None
if TYPE_CHECKING:
import jwt
if MSTEAMS_AVAILABLE:
import jwt
class MSTeamsConfig(Base):
"""Microsoft Teams channel configuration."""
enabled: bool = False
app_id: str = ""
app_password: str = ""
tenant_id: str = ""
host: str = "0.0.0.0"
port: int = 3978
path: str = "/api/messages"
allow_from: list[str] = Field(default_factory=list)
reply_in_thread: bool = True
mention_only_response: str = "Hi — what can I help with?"
validate_inbound_auth: bool = False
restart_notify_enabled: bool = False
restart_notify_pre_message: str = (
"Nanobot agent initiated a gateway restart. I will message again when the gateway is back online."
)
restart_notify_post_message: str = "Nanobot gateway is back online."
@dataclass
class ConversationRef:
"""Minimal stored conversation reference for replies."""
service_url: str
conversation_id: str
bot_id: str | None = None
activity_id: str | None = None
conversation_type: str | None = None
tenant_id: str | None = None
class MSTeamsChannel(BaseChannel):
"""Microsoft Teams channel (DM-first MVP)."""
name = "msteams"
display_name = "Microsoft Teams"
@classmethod
def default_config(cls) -> dict[str, Any]:
return MSTeamsConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = MSTeamsConfig.model_validate(config)
super().__init__(config, bus)
self.config: MSTeamsConfig = config
self._loop: asyncio.AbstractEventLoop | None = None
self._server: ThreadingHTTPServer | None = None
self._server_thread: threading.Thread | None = None
self._http: httpx.AsyncClient | None = None
self._token: str | None = None
self._token_expires_at: float = 0.0
self._botframework_openid_config_url = (
"https://login.botframework.com/v1/.well-known/openidconfiguration"
)
self._botframework_openid_config: dict[str, Any] | None = None
self._botframework_openid_config_expires_at: float = 0.0
self._botframework_jwks: dict[str, Any] | None = None
self._botframework_jwks_expires_at: float = 0.0
self._refs_path = get_workspace_path() / "state" / "msteams_conversations.json"
self._refs_path.parent.mkdir(parents=True, exist_ok=True)
self._conversation_refs: dict[str, ConversationRef] = self._load_refs()
async def start(self) -> None:
"""Start the Teams webhook listener."""
if not MSTEAMS_AVAILABLE:
logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
return
if not self.config.app_id or not self.config.app_password:
logger.error("MSTeams app_id/app_password not configured")
return
self._loop = asyncio.get_running_loop()
self._http = httpx.AsyncClient(timeout=30.0)
self._running = True
channel = self
class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
if self.path != channel.config.path:
self.send_response(404)
self.end_headers()
return
try:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length > 0 else b"{}"
payload = json.loads(raw.decode("utf-8"))
except Exception as e:
logger.warning("MSTeams invalid request body: {}", e)
self.send_response(400)
self.end_headers()
return
auth_header = self.headers.get("Authorization", "")
if channel.config.validate_inbound_auth:
try:
fut = asyncio.run_coroutine_threadsafe(
channel._validate_inbound_auth(auth_header, payload),
channel._loop,
)
fut.result(timeout=15)
except Exception as e:
logger.warning("MSTeams inbound auth validation failed: {}", e)
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"error":"unauthorized"}')
return
try:
fut = asyncio.run_coroutine_threadsafe(
channel._handle_activity(payload),
channel._loop,
)
fut.result(timeout=15)
except Exception as e:
logger.warning("MSTeams activity handling failed: {}", e)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, format: str, *args: Any) -> None:
return
self._server = ThreadingHTTPServer((self.config.host, self.config.port), Handler)
self._server_thread = threading.Thread(
target=self._server.serve_forever,
name="nanobot-msteams",
daemon=True,
)
self._server_thread.start()
logger.info(
"MSTeams webhook listening on http://{}:{}{}",
self.config.host,
self.config.port,
self.config.path,
)
while self._running:
await asyncio.sleep(1)
async def stop(self) -> None:
"""Stop the channel."""
self._running = False
if self._server:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._server_thread and self._server_thread.is_alive():
self._server_thread.join(timeout=2)
self._server_thread = None
if self._http:
await self._http.aclose()
self._http = None
async def send(self, msg: OutboundMessage) -> None:
"""Send a plain text reply into an existing Teams conversation."""
if not self._http:
raise RuntimeError("MSTeams HTTP client not initialized")
ref = self._conversation_refs.get(str(msg.chat_id))
if not ref:
raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}")
token = await self._get_access_token()
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
url = f"{base_url}/{ref.activity_id}" if use_thread_reply else base_url
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
payload = {
"type": "message",
"text": msg.content or " ",
}
if use_thread_reply:
payload["replyToId"] = ref.activity_id
try:
resp = await self._http.post(url, headers=headers, json=payload)
resp.raise_for_status()
logger.info("MSTeams message sent to {}", ref.conversation_id)
except Exception as e:
logger.error("MSTeams send failed: {}", e)
raise
async def _handle_activity(self, activity: dict[str, Any]) -> None:
"""Handle inbound Teams/Bot Framework activity."""
if activity.get("type") != "message":
return
conversation = activity.get("conversation") or {}
from_user = activity.get("from") or {}
recipient = activity.get("recipient") or {}
channel_data = activity.get("channelData") or {}
sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip()
conversation_id = str(conversation.get("id") or "").strip()
service_url = str(activity.get("serviceUrl") or "").strip()
activity_id = str(activity.get("id") or "").strip()
conversation_type = str(conversation.get("conversationType") or "").strip()
if not sender_id or not conversation_id or not service_url:
return
if recipient.get("id") and from_user.get("id") == recipient.get("id"):
return
# DM-only MVP: ignore group/channel traffic for now
if conversation_type and conversation_type not in ("personal", ""):
logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type)
return
text = self._sanitize_inbound_text(activity)
if not text:
text = self.config.mention_only_response.strip()
if not text:
logger.debug("MSTeams ignoring empty message after Teams text sanitization")
return
self._conversation_refs[conversation_id] = ConversationRef(
service_url=service_url,
conversation_id=conversation_id,
bot_id=str(recipient.get("id") or "") or None,
activity_id=activity_id or None,
conversation_type=conversation_type or None,
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
)
self._save_refs()
await self._handle_message(
sender_id=sender_id,
chat_id=conversation_id,
content=text,
metadata={
"msteams": {
"activity_id": activity_id,
"conversation_id": conversation_id,
"conversation_type": conversation_type or "personal",
"from_name": from_user.get("name"),
}
},
)
def _sanitize_inbound_text(self, activity: dict[str, Any]) -> str:
"""Extract the user-authored text from a Teams activity."""
text = str(activity.get("text") or "")
text = self._strip_possible_bot_mention(text)
channel_data = activity.get("channelData") or {}
reply_to_id = str(activity.get("replyToId") or "").strip()
normalized_preview = html.unescape(text).replace("&rsquo", "").strip()
normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n")
preview_lines = [line.strip() for line in normalized_preview.split("\n")]
while preview_lines and not preview_lines[0]:
preview_lines.pop(0)
first_line = preview_lines[0] if preview_lines else ""
looks_like_quote_wrapper = first_line.lower().startswith("replying to ") or first_line.startswith("FWDIOC-BOT")
if reply_to_id or channel_data.get("messageType") == "reply" or looks_like_quote_wrapper:
text = self._normalize_teams_reply_quote(text)
return text.strip()
def _strip_possible_bot_mention(self, text: str) -> str:
"""Remove simple Teams mention markup from message text."""
cleaned = re.sub(r"<at\b[^>]*>.*?</at>", " ", text, flags=re.IGNORECASE | re.DOTALL)
cleaned = re.sub(r"[^\S\r\n]+", " ", cleaned)
cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
return cleaned.strip()
def _normalize_teams_reply_quote(self, text: str) -> str:
"""Normalize Teams quoted replies into a compact structured form."""
cleaned = html.unescape(text).replace("&rsquo", "").strip()
if not cleaned:
return ""
normalized_newlines = cleaned.replace("\r\n", "\n").replace("\r", "\n")
lines = [line.strip() for line in normalized_newlines.split("\n")]
while lines and not lines[0]:
lines.pop(0)
# Observed native Teams reply wrapper:
# Replying to Bob Smith
# actual reply text
if len(lines) >= 2 and lines[0].lower().startswith("replying to "):
quoted = lines[0][len("replying to ") :].strip(" :")
reply = "\n".join(lines[1:]).strip()
return self._format_reply_with_quote(quoted, reply)
# Observed FWDIOC relay wrapper where the quoted content is surfaced after a
# synthetic "FWDIOC-BOT" header, sometimes with a blank line separating quote
# and reply, and sometimes as a compact line-based fallback shape.
if lines and lines[0].strip().startswith("FWDIOC-BOT"):
body = normalized_newlines.split("\n", 1)[1] if "\n" in normalized_newlines else ""
body = body.lstrip()
parts = re.split(r"\n\s*\n", body, maxsplit=1)
if len(parts) == 2:
quoted = re.sub(r"\s+", " ", parts[0]).strip()
reply = re.sub(r"\s+", " ", parts[1]).strip()
if quoted or reply:
return self._format_reply_with_quote(quoted, reply)
body_lines = [line.strip() for line in body.split("\n") if line.strip()]
if body_lines:
quoted = " ".join(body_lines[:-1]).strip()
reply = body_lines[-1].strip()
if quoted and reply:
return self._format_reply_with_quote(quoted, reply)
# Observed compact fallback where the relay flattens everything into one line
# and appends the literal reply text marker at the end.
compact = re.sub(r"\s+", " ", normalized_newlines).strip()
if compact.startswith("FWDIOC-BOT "):
compact = compact[len("FWDIOC-BOT ") :].strip()
marker = " Reply with quote test"
if compact.endswith(marker):
quoted = compact[: -len(marker)].strip()
reply = marker.strip()
return self._format_reply_with_quote(quoted, reply)
return cleaned
def _format_reply_with_quote(self, quoted: str, reply: str) -> str:
"""Format a quoted reply for the model without Teams wrapper noise."""
quoted = quoted.strip()
reply = reply.strip()
if quoted and reply:
return f"User is replying to: {quoted}\nUser reply: {reply}"
if reply:
return reply
return quoted
async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None:
"""Validate inbound Bot Framework bearer token."""
if not MSTEAMS_AVAILABLE:
raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
if not auth_header.lower().startswith("bearer "):
raise ValueError("missing bearer token")
token = auth_header.split(" ", 1)[1].strip()
if not token:
raise ValueError("empty bearer token")
header = jwt.get_unverified_header(token)
kid = str(header.get("kid") or "").strip()
if not kid:
raise ValueError("missing token kid")
jwks = await self._get_botframework_jwks()
keys = jwks.get("keys") or []
jwk = next((key for key in keys if key.get("kid") == kid), None)
if not jwk:
raise ValueError(f"signing key not found for kid={kid}")
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
claims = jwt.decode(
token,
key=public_key,
algorithms=["RS256"],
audience=self.config.app_id,
issuer="https://api.botframework.com",
options={
"require": ["exp", "nbf", "iss", "aud"],
},
)
claim_service_url = str(
claims.get("serviceurl") or claims.get("serviceUrl") or "",
).strip()
activity_service_url = str(activity.get("serviceUrl") or "").strip()
if claim_service_url and activity_service_url and claim_service_url != activity_service_url:
raise ValueError("serviceUrl claim mismatch")
async def _get_botframework_openid_config(self) -> dict[str, Any]:
"""Fetch and cache Bot Framework OpenID configuration."""
import time
now = time.time()
if self._botframework_openid_config and now < self._botframework_openid_config_expires_at:
return self._botframework_openid_config
if not self._http:
raise RuntimeError("MSTeams HTTP client not initialized")
resp = await self._http.get(self._botframework_openid_config_url)
resp.raise_for_status()
self._botframework_openid_config = resp.json()
self._botframework_openid_config_expires_at = now + 3600
return self._botframework_openid_config
async def _get_botframework_jwks(self) -> dict[str, Any]:
"""Fetch and cache Bot Framework JWKS."""
import time
now = time.time()
if self._botframework_jwks and now < self._botframework_jwks_expires_at:
return self._botframework_jwks
if not self._http:
raise RuntimeError("MSTeams HTTP client not initialized")
openid_config = await self._get_botframework_openid_config()
jwks_uri = str(openid_config.get("jwks_uri") or "").strip()
if not jwks_uri:
raise RuntimeError("Bot Framework OpenID config missing jwks_uri")
resp = await self._http.get(jwks_uri)
resp.raise_for_status()
self._botframework_jwks = resp.json()
self._botframework_jwks_expires_at = now + 3600
return self._botframework_jwks
def _load_refs(self) -> dict[str, ConversationRef]:
"""Load stored conversation references."""
if not self._refs_path.exists():
return {}
try:
data = json.loads(self._refs_path.read_text(encoding="utf-8"))
out: dict[str, ConversationRef] = {}
for key, value in data.items():
out[key] = ConversationRef(**value)
return out
except Exception as e:
logger.warning("Failed to load MSTeams conversation refs: {}", e)
return {}
def _save_refs(self) -> None:
"""Persist conversation references."""
try:
data = {
key: {
"service_url": ref.service_url,
"conversation_id": ref.conversation_id,
"bot_id": ref.bot_id,
"activity_id": ref.activity_id,
"conversation_type": ref.conversation_type,
"tenant_id": ref.tenant_id,
}
for key, ref in self._conversation_refs.items()
}
self._refs_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
except Exception as e:
logger.warning("Failed to save MSTeams conversation refs: {}", e)
async def _get_access_token(self) -> str:
"""Fetch an access token for Bot Framework / Azure Bot auth."""
import time
now = time.time()
if self._token and now < self._token_expires_at - 60:
return self._token
if not self._http:
raise RuntimeError("MSTeams HTTP client not initialized")
tenant = (self.config.tenant_id or "").strip() or "botframework.com"
token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
data = {
"grant_type": "client_credentials",
"client_id": self.config.app_id,
"client_secret": self.config.app_password,
"scope": "https://api.botframework.com/.default",
}
resp = await self._http.post(token_url, data=data)
resp.raise_for_status()
payload = resp.json()
self._token = payload["access_token"]
self._token_expires_at = now + int(payload.get("expires_in", 3600))
return self._token
-8
View File
@@ -280,9 +280,6 @@ class QQChannel(BaseChannel):
msg_id=msg_id, msg_id=msg_id,
content=msg.content.strip(), content=msg.content.strip(),
) )
except (aiohttp.ClientError, OSError):
# Network / transport errors — propagate so ChannelManager can retry
raise
except Exception: except Exception:
logger.exception("Error sending QQ message to chat_id={}", msg.chat_id) logger.exception("Error sending QQ message to chat_id={}", msg.chat_id)
@@ -365,12 +362,7 @@ class QQChannel(BaseChannel):
logger.info("QQ media sent: {}", filename) logger.info("QQ media sent: {}", filename)
return True return True
except (aiohttp.ClientError, OSError) as e:
# Network / transport errors — propagate for retry by caller
logger.warning("QQ send media network error filename={} err={}", filename, e)
raise
except Exception as e: except Exception as e:
# API-level or other non-network errors — return False so send() can fallback
logger.error("QQ send media failed filename={} err={}", filename, e) logger.error("QQ send media failed filename={} err={}", filename, e)
return False return False
+7 -19
View File
@@ -166,7 +166,6 @@ def _markdown_to_telegram_html(text: str) -> str:
_SEND_MAX_RETRIES = 3 _SEND_MAX_RETRIES = 3
_SEND_RETRY_BASE_DELAY = 0.5 # seconds, doubled each retry _SEND_RETRY_BASE_DELAY = 0.5 # seconds, doubled each retry
_STREAM_EDIT_INTERVAL_DEFAULT = 0.6 # min seconds between edit_message_text calls
@dataclass @dataclass
@@ -191,7 +190,6 @@ class TelegramConfig(Base):
connection_pool_size: int = 32 connection_pool_size: int = 32
pool_timeout: float = 5.0 pool_timeout: float = 5.0
streaming: bool = True streaming: bool = True
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
class TelegramChannel(BaseChannel): class TelegramChannel(BaseChannel):
@@ -221,6 +219,8 @@ class TelegramChannel(BaseChannel):
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
return TelegramConfig().model_dump(by_alias=True) return TelegramConfig().model_dump(by_alias=True)
_STREAM_EDIT_INTERVAL = 0.6 # min seconds between edit_message_text calls
def __init__(self, config: Any, bus: MessageBus): def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict): if isinstance(config, dict):
config = TelegramConfig.model_validate(config) config = TelegramConfig.model_validate(config)
@@ -316,10 +316,10 @@ class TelegramChannel(BaseChannel):
) )
self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help)) self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help))
# Add message handler for text, photos, voice, documents, and locations # Add message handler for text, photos, voice, documents
self._app.add_handler( self._app.add_handler(
MessageHandler( MessageHandler(
(filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL | filters.LOCATION) (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL)
& ~filters.COMMAND, & ~filters.COMMAND,
self._on_message self._on_message
) )
@@ -520,10 +520,7 @@ class TelegramChannel(BaseChannel):
reply_parameters=reply_params, reply_parameters=reply_params,
**(thread_kwargs or {}), **(thread_kwargs or {}),
) )
except BadRequest as e: except Exception as e:
# Only fall back to plain text on actual HTML parse/format errors.
# Network errors (TimedOut, NetworkError) should propagate immediately
# to avoid doubling connection demand during pool exhaustion.
logger.warning("HTML parse failed, falling back to plain text: {}", e) logger.warning("HTML parse failed, falling back to plain text: {}", e)
try: try:
await self._call_with_retry( await self._call_with_retry(
@@ -570,10 +567,7 @@ class TelegramChannel(BaseChannel):
chat_id=int_chat_id, message_id=buf.message_id, chat_id=int_chat_id, message_id=buf.message_id,
text=html, parse_mode="HTML", text=html, parse_mode="HTML",
) )
except BadRequest as e: except Exception as e:
# Only fall back to plain text on actual HTML parse/format errors.
# Network errors (TimedOut, NetworkError) should propagate immediately
# to avoid doubling connection demand during pool exhaustion.
if self._is_not_modified_error(e): if self._is_not_modified_error(e):
logger.debug("Final stream edit already applied for {}", chat_id) logger.debug("Final stream edit already applied for {}", chat_id)
self._stream_bufs.pop(chat_id, None) self._stream_bufs.pop(chat_id, None)
@@ -625,7 +619,7 @@ class TelegramChannel(BaseChannel):
except Exception as e: except Exception as e:
logger.warning("Stream initial send failed: {}", e) logger.warning("Stream initial send failed: {}", e)
raise # Let ChannelManager handle retry raise # Let ChannelManager handle retry
elif (now - buf.last_edit) >= self.config.stream_edit_interval: elif (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL:
try: try:
await self._call_with_retry( await self._call_with_retry(
self._app.bot.edit_message_text, self._app.bot.edit_message_text,
@@ -890,12 +884,6 @@ class TelegramChannel(BaseChannel):
if message.caption: if message.caption:
content_parts.append(message.caption) content_parts.append(message.caption)
# Location content
if message.location:
lat = message.location.latitude
lon = message.location.longitude
content_parts.append(f"[location: {lat}, {lon}]")
# Download current message media # Download current message media
current_media_paths, current_media_parts = await self._download_message_media( current_media_paths, current_media_parts = await self._download_message_media(
message, add_failure_content=True message, add_failure_content=True
-36
View File
@@ -985,43 +985,7 @@ class WeixinChannel(BaseChannel):
for media_path in (msg.media or []): for media_path in (msg.media or []):
try: try:
await self._send_media_file(msg.chat_id, media_path, ctx_token) await self._send_media_file(msg.chat_id, media_path, ctx_token)
except (httpx.TimeoutException, httpx.TransportError) as net_err:
# Network/transport errors: do NOT fall back to text —
# the text send would also likely fail, and the outer
# except will re-raise so ChannelManager retries properly.
logger.error(
"Network error sending WeChat media {}: {}",
media_path,
net_err,
)
raise
except httpx.HTTPStatusError as http_err:
status_code = (
http_err.response.status_code
if http_err.response is not None
else 0
)
if status_code >= 500:
# Server-side / retryable HTTP error — same as network.
logger.error(
"Server error ({} {}) sending WeChat media {}: {}",
status_code,
http_err.response.reason_phrase
if http_err.response is not None
else "",
media_path,
http_err,
)
raise
# 4xx client errors are NOT retryable — fall back to text.
filename = Path(media_path).name
logger.error("Failed to send WeChat media {}: {}", media_path, http_err)
await self._send_text(
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
)
except Exception as e: except Exception as e:
# Non-network errors (format, file-not-found, etc.):
# notify the user via text fallback.
filename = Path(media_path).name filename = Path(media_path).name
logger.error("Failed to send WeChat media {}: {}", media_path, e) logger.error("Failed to send WeChat media {}: {}", media_path, e)
# Notify user about failure via text # Notify user about failure via text
+2 -8
View File
@@ -590,8 +590,6 @@ def serve(
mcp_servers=runtime_config.tools.mcp_servers, mcp_servers=runtime_config.tools.mcp_servers,
channels_config=runtime_config.channels, channels_config=runtime_config.channels,
timezone=runtime_config.agents.defaults.timezone, timezone=runtime_config.agents.defaults.timezone,
unified_session=runtime_config.agents.defaults.unified_session,
disabled_skills=runtime_config.agents.defaults.disabled_skills,
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes, session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
) )
@@ -684,8 +682,6 @@ def gateway(
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
channels_config=config.channels, channels_config=config.channels,
timezone=config.agents.defaults.timezone, timezone=config.agents.defaults.timezone,
unified_session=config.agents.defaults.unified_session,
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes, session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
) )
@@ -918,8 +914,6 @@ def agent(
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
channels_config=config.channels, channels_config=config.channels,
timezone=config.agents.defaults.timezone, timezone=config.agents.defaults.timezone,
unified_session=config.agents.defaults.unified_session,
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes, session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
) )
restart_notice = consume_restart_notice_from_env() restart_notice = consume_restart_notice_from_env()
@@ -1125,7 +1119,7 @@ def channels_status(
table = Table(title="Channel Status") table = Table(title="Channel Status")
table.add_column("Channel", style="cyan") table.add_column("Channel", style="cyan")
table.add_column("Enabled") table.add_column("Enabled", style="green")
for name, cls in sorted(discover_all().items()): for name, cls in sorted(discover_all().items()):
section = getattr(config.channels, name, None) section = getattr(config.channels, name, None)
@@ -1260,7 +1254,7 @@ def plugins_list():
table = Table(title="Channel Plugins") table = Table(title="Channel Plugins")
table.add_column("Name", style="cyan") table.add_column("Name", style="cyan")
table.add_column("Source", style="magenta") table.add_column("Source", style="magenta")
table.add_column("Enabled") table.add_column("Enabled", style="green")
for name in sorted(all_channels): for name in sorted(all_channels):
cls = all_channels[name] cls = all_channels[name]
+3 -11
View File
@@ -74,16 +74,9 @@ class AgentDefaults(Base):
max_tool_iterations: int = 200 max_tool_iterations: int = 200
max_tool_result_chars: int = 16_000 max_tool_result_chars: int = 16_000
provider_retry_mode: Literal["standard", "persistent"] = "standard" provider_retry_mode: Literal["standard", "persistent"] = "standard"
reasoning_effort: str | None = None # low / medium / high / adaptive - 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"
unified_session: bool = False # Share one session across all channels (single-user multi-device) session_ttl_minutes: int = Field(default=0, ge=0) # Auto /new after idle (0 = disabled)
disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
session_ttl_minutes: int = Field(
default=0,
ge=0,
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
serialization_alias="idleCompactAfterMinutes",
) # Auto-compact idle threshold in minutes (0 = disabled)
dream: DreamConfig = Field(default_factory=DreamConfig) dream: DreamConfig = Field(default_factory=DreamConfig)
@@ -160,7 +153,7 @@ class GatewayConfig(Base):
class WebSearchConfig(Base): class WebSearchConfig(Base):
"""Web search tool configuration.""" """Web search tool configuration."""
provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina, kagi provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina
api_key: str = "" api_key: str = ""
base_url: str = "" # SearXNG base URL base_url: str = "" # SearXNG base URL
max_results: int = 5 max_results: int = 5
@@ -184,7 +177,6 @@ class ExecToolConfig(Base):
timeout: int = 60 timeout: int = 60
path_append: str = "" path_append: str = ""
sandbox: str = "" # sandbox backend: "" (none) or "bwrap" sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
allowed_env_keys: list[str] = Field(default_factory=list) # Env var names to pass through to subprocess (e.g. ["GOPATH", "JAVA_HOME"])
class MCPServerConfig(Base): class MCPServerConfig(Base):
"""MCP server connection configuration (stdio or HTTP).""" """MCP server connection configuration (stdio or HTTP)."""
+47 -173
View File
@@ -4,12 +4,10 @@ import asyncio
import json import json
import time import time
import uuid import uuid
from dataclasses import asdict
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Coroutine, Literal from typing import Any, Callable, Coroutine, Literal
from filelock import FileLock
from loguru import logger from loguru import logger
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore
@@ -71,26 +69,28 @@ class CronService:
self, self,
store_path: Path, store_path: Path,
on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None, on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None,
max_sleep_ms: int = 300_000, # 5 minutes
): ):
self.store_path = store_path self.store_path = store_path
self._action_path = store_path.parent / "action.jsonl"
self._lock = FileLock(str(self._action_path.parent) + ".lock")
self.on_job = on_job self.on_job = on_job
self._store: CronStore | None = None self._store: CronStore | None = None
self._last_mtime: float = 0.0
self._timer_task: asyncio.Task | None = None self._timer_task: asyncio.Task | None = None
self._running = False self._running = False
self._timer_active = False
self.max_sleep_ms = max_sleep_ms
def _load_jobs(self) -> tuple[list[CronJob], int]: def _load_store(self) -> CronStore:
jobs = [] """Load jobs from disk. Reloads automatically if file was modified externally."""
version = 1 if self._store and self.store_path.exists():
mtime = self.store_path.stat().st_mtime
if mtime != self._last_mtime:
logger.info("Cron: jobs.json modified externally, reloading")
self._store = None
if self._store:
return self._store
if self.store_path.exists(): if self.store_path.exists():
try: try:
data = json.loads(self.store_path.read_text(encoding="utf-8")) data = json.loads(self.store_path.read_text(encoding="utf-8"))
jobs = [] jobs = []
version = data.get("version", 1)
for j in data.get("jobs", []): for j in data.get("jobs", []):
jobs.append(CronJob( jobs.append(CronJob(
id=j["id"], id=j["id"],
@@ -129,57 +129,12 @@ class CronService:
updated_at_ms=j.get("updatedAtMs", 0), updated_at_ms=j.get("updatedAtMs", 0),
delete_after_run=j.get("deleteAfterRun", False), delete_after_run=j.get("deleteAfterRun", False),
)) ))
self._store = CronStore(jobs=jobs)
except Exception as e: except Exception as e:
logger.warning("Failed to load cron store: {}", e) logger.warning("Failed to load cron store: {}", e)
return jobs, version self._store = CronStore()
else:
def _merge_action(self): self._store = CronStore()
if not self._action_path.exists():
return
jobs_map = {j.id: j for j in self._store.jobs}
def _update(params: dict):
j = CronJob.from_dict(params)
jobs_map[j.id] = j
def _del(params: dict):
if job_id := params.get("job_id"):
jobs_map.pop(job_id)
with self._lock:
with open(self._action_path, "r", encoding="utf-8") as f:
changed = False
for line in f:
try:
line = line.strip()
action = json.loads(line)
if "action" not in action:
continue
if action["action"] == "del":
_del(action.get("params", {}))
else:
_update(action.get("params", {}))
changed = True
except Exception as exp:
logger.debug(f"load action line error: {exp}")
continue
self._store.jobs = list(jobs_map.values())
if self._running and changed:
self._action_path.write_text("", encoding="utf-8")
self._save_store()
return
def _load_store(self) -> CronStore:
"""Load jobs from disk. Reloads automatically if file was modified externally.
- Reload every time because it needs to merge operations on the jobs object from other instances.
- During _on_timer execution, return the existing store to prevent concurrent
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
"""
if self._timer_active and self._store:
return self._store
jobs, version = self._load_jobs()
self._store = CronStore(version=version, jobs=jobs)
self._merge_action()
return self._store return self._store
@@ -235,7 +190,8 @@ class CronService:
} }
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
self._last_mtime = self.store_path.stat().st_mtime
async def start(self) -> None: async def start(self) -> None:
"""Start the cron service.""" """Start the cron service."""
self._running = True self._running = True
@@ -274,14 +230,11 @@ class CronService:
if self._timer_task: if self._timer_task:
self._timer_task.cancel() self._timer_task.cancel()
if not self._running: next_wake = self._get_next_wake_ms()
if not next_wake or not self._running:
return return
next_wake = self._get_next_wake_ms() delay_ms = max(0, next_wake - _now_ms())
if next_wake is None:
delay_ms = self.max_sleep_ms
else:
delay_ms = min(self.max_sleep_ms, max(0, next_wake - _now_ms()))
delay_s = delay_ms / 1000 delay_s = delay_ms / 1000
async def tick(): async def tick():
@@ -295,23 +248,18 @@ class CronService:
"""Handle timer tick - run due jobs.""" """Handle timer tick - run due jobs."""
self._load_store() self._load_store()
if not self._store: if not self._store:
self._arm_timer()
return return
self._timer_active = True now = _now_ms()
try: due_jobs = [
now = _now_ms() j for j in self._store.jobs
due_jobs = [ if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
j for j in self._store.jobs ]
if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
]
for job in due_jobs: for job in due_jobs:
await self._execute_job(job) await self._execute_job(job)
self._save_store() self._save_store()
finally:
self._timer_active = False
self._arm_timer() self._arm_timer()
async def _execute_job(self, job: CronJob) -> None: async def _execute_job(self, job: CronJob) -> None:
@@ -355,13 +303,6 @@ class CronService:
# Compute next run # Compute next run
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
def _append_action(self, action: Literal["add", "del", "update"], params: dict):
self.store_path.parent.mkdir(parents=True, exist_ok=True)
with self._lock:
with open(self._action_path, "a", encoding="utf-8") as f:
f.write(json.dumps({"action": action, "params": params}, ensure_ascii=False) + "\n")
# ========== Public API ========== # ========== Public API ==========
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
@@ -381,6 +322,7 @@ class CronService:
delete_after_run: bool = False, delete_after_run: bool = False,
) -> CronJob: ) -> CronJob:
"""Add a new job.""" """Add a new job."""
store = self._load_store()
_validate_schedule_for_add(schedule) _validate_schedule_for_add(schedule)
now = _now_ms() now = _now_ms()
@@ -401,13 +343,10 @@ class CronService:
updated_at_ms=now, updated_at_ms=now,
delete_after_run=delete_after_run, delete_after_run=delete_after_run,
) )
if self._running:
store = self._load_store() store.jobs.append(job)
store.jobs.append(job) self._save_store()
self._save_store() self._arm_timer()
self._arm_timer()
else:
self._append_action("add", asdict(job))
logger.info("Cron: added job '{}' ({})", name, job.id) logger.info("Cron: added job '{}' ({})", name, job.id)
return job return job
@@ -441,11 +380,8 @@ class CronService:
removed = len(store.jobs) < before removed = len(store.jobs) < before
if removed: if removed:
if self._running: self._save_store()
self._save_store() self._arm_timer()
self._arm_timer()
else:
self._append_action("del", {"job_id": job_id})
logger.info("Cron: removed job {}", job_id) logger.info("Cron: removed job {}", job_id)
return "removed" return "removed"
@@ -462,85 +398,23 @@ class CronService:
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
else: else:
job.state.next_run_at_ms = None job.state.next_run_at_ms = None
if self._running: self._save_store()
self._save_store() self._arm_timer()
self._arm_timer()
else:
self._append_action("update", asdict(job))
return job return job
return None return None
def update_job(
self,
job_id: str,
*,
name: str | None = None,
schedule: CronSchedule | None = None,
message: str | None = None,
deliver: bool | None = None,
channel: str | None = ...,
to: str | None = ...,
delete_after_run: bool | None = None,
) -> CronJob | Literal["not_found", "protected"]:
"""Update mutable fields of an existing job. System jobs cannot be updated.
For ``channel`` and ``to``, pass an explicit value (including ``None``)
to update; omit (sentinel ``...``) to leave unchanged.
"""
store = self._load_store()
job = next((j for j in store.jobs if j.id == job_id), None)
if job is None:
return "not_found"
if job.payload.kind == "system_event":
return "protected"
if schedule is not None:
_validate_schedule_for_add(schedule)
job.schedule = schedule
if name is not None:
job.name = name
if message is not None:
job.payload.message = message
if deliver is not None:
job.payload.deliver = deliver
if channel is not ...:
job.payload.channel = channel
if to is not ...:
job.payload.to = to
if delete_after_run is not None:
job.delete_after_run = delete_after_run
job.updated_at_ms = _now_ms()
if job.enabled:
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
if self._running:
self._save_store()
self._arm_timer()
else:
self._append_action("update", asdict(job))
logger.info("Cron: updated job '{}' ({})", job.name, job.id)
return job
async def run_job(self, job_id: str, force: bool = False) -> bool: async def run_job(self, job_id: str, force: bool = False) -> bool:
"""Manually run a job without disturbing the service's running state.""" """Manually run a job."""
was_running = self._running store = self._load_store()
self._running = True for job in store.jobs:
try: if job.id == job_id:
store = self._load_store() if not force and not job.enabled:
for job in store.jobs: return False
if job.id == job_id: await self._execute_job(job)
if not force and not job.enabled: self._save_store()
return False
await self._execute_job(job)
self._save_store()
return True
return False
finally:
self._running = was_running
if was_running:
self._arm_timer() self._arm_timer()
return True
return False
def get_job(self, job_id: str) -> CronJob | None: def get_job(self, job_id: str) -> CronJob | None:
"""Get a job by ID.""" """Get a job by ID."""
-12
View File
@@ -61,18 +61,6 @@ class CronJob:
updated_at_ms: int = 0 updated_at_ms: int = 0
delete_after_run: bool = False delete_after_run: bool = False
@classmethod
def from_dict(cls, kwargs: dict):
state_kwargs = dict(kwargs.get("state", {}))
state_kwargs["run_history"] = [
record if isinstance(record, CronRunRecord) else CronRunRecord(**record)
for record in state_kwargs.get("run_history", [])
]
kwargs["schedule"] = CronSchedule(**kwargs.get("schedule", {"kind": "every"}))
kwargs["payload"] = CronPayload(**kwargs.get("payload", {}))
kwargs["state"] = CronJobState(**state_kwargs)
return cls(**kwargs)
@dataclass @dataclass
class CronStore: class CronStore:
-2
View File
@@ -81,8 +81,6 @@ class Nanobot:
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
timezone=defaults.timezone, timezone=defaults.timezone,
unified_session=defaults.unified_session,
disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes, session_ttl_minutes=defaults.session_ttl_minutes,
) )
return cls(loop) return cls(loop)
+2 -8
View File
@@ -380,15 +380,9 @@ class AnthropicProvider(LLMProvider):
if system: if system:
kwargs["system"] = system kwargs["system"] = system
if reasoning_effort == "adaptive": if thinking_enabled:
# Adaptive thinking: model decides when and how much to think
# Supported on claude-sonnet-4-6 and claude-opus-4-6.
# Also auto-enables interleaved thinking between tool calls.
kwargs["thinking"] = {"type": "adaptive"}
kwargs["temperature"] = 1.0
elif thinking_enabled:
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)} budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
budget = budget_map.get(reasoning_effort.lower(), 4096) budget = budget_map.get(reasoning_effort.lower(), 4096) # type: ignore[union-attr]
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget} kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
kwargs["max_tokens"] = max(max_tokens, budget + 4096) kwargs["max_tokens"] = max(max_tokens, budget + 4096)
kwargs["temperature"] = 1.0 kwargs["temperature"] = 1.0
+1 -84
View File
@@ -353,64 +353,6 @@ class LLMProvider(ABC):
# Unknown 429 defaults to WAIT+retry. # Unknown 429 defaults to WAIT+retry.
return True return True
@staticmethod
def _enforce_role_alternation(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Merge consecutive same-role messages and drop trailing assistant messages.
Some providers (OpenAI-compat, Azure, vLLM, Ollama, etc.) reject requests
where the last message is 'assistant' (prefill not supported) or two
consecutive non-system messages share the same role.
"""
if not messages:
return messages
merged: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
if (
merged
and role != "system"
and role not in ("tool",)
and merged[-1].get("role") == role
and role in ("user", "assistant")
):
prev = merged[-1]
if role == "assistant":
prev_has_tools = bool(prev.get("tool_calls"))
curr_has_tools = bool(msg.get("tool_calls"))
if curr_has_tools:
merged[-1] = dict(msg)
continue
if prev_has_tools:
continue
prev_content = prev.get("content") or ""
curr_content = msg.get("content") or ""
if isinstance(prev_content, str) and isinstance(curr_content, str):
prev["content"] = (prev_content + "\n\n" + curr_content).strip()
else:
merged[-1] = dict(msg)
else:
merged.append(dict(msg))
last_popped = None
while merged and merged[-1].get("role") == "assistant":
last_popped = merged.pop()
# If removing trailing assistant messages left only system messages,
# the request would be invalid for most providers (e.g. Zhipu/GLM
# error 1214). Recover by converting the last popped assistant
# message to a user message so the LLM can still see the content.
if (
merged
and last_popped is not None
and not any(m.get("role") in ("user", "tool") for m in merged)
):
recovered = dict(last_popped)
recovered["role"] = "user"
merged.append(recovered)
return merged
@staticmethod @staticmethod
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None: def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url blocks with text placeholder. Returns None if no images found.""" """Replace image_url blocks with text placeholder. Returns None if no images found."""
@@ -433,26 +375,6 @@ class LLMProvider(ABC):
result.append(msg) result.append(msg)
return result if found else None return result if found else None
@staticmethod
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image_url blocks with text placeholder *in-place*.
Mutates the content lists of the original message dicts so that
callers holding references to those dicts also see the stripped
version.
"""
found = False
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
for i, b in enumerate(content):
if isinstance(b, dict) and b.get("type") == "image_url":
path = (b.get("_meta") or {}).get("path", "")
placeholder = image_placeholder_text(path, empty="[image omitted]")
content[i] = {"type": "text", "text": placeholder}
found = True
return found
async def _safe_chat(self, **kwargs: Any) -> LLMResponse: async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
"""Call chat() and convert unexpected exceptions to error responses.""" """Call chat() and convert unexpected exceptions to error responses."""
try: try:
@@ -704,12 +626,7 @@ class LLMProvider(ABC):
) )
retry_kw = dict(kw) retry_kw = dict(kw)
retry_kw["messages"] = stripped retry_kw["messages"] = stripped
result = await call(**retry_kw) return await call(**retry_kw)
# Permanently strip images from the original messages so
# subsequent iterations do not repeat the error-retry cycle.
if result.finish_reason != "error":
self._strip_image_content_inplace(original_messages)
return result
return response return response
if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT: if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT:
+15 -191
View File
@@ -26,12 +26,6 @@ else:
from openai import AsyncOpenAI from openai import AsyncOpenAI
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses import (
consume_sdk_stream,
convert_messages,
convert_tools,
parse_response_output,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.providers.registry import ProviderSpec from nanobot.providers.registry import ProviderSpec
@@ -119,14 +113,6 @@ def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | No
return bool(api_base and "openrouter" in api_base.lower()) return bool(api_base and "openrouter" in api_base.lower())
def _is_direct_openai_base(api_base: str | None) -> bool:
"""Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways."""
if not api_base:
return True
normalized = api_base.strip().lower().rstrip("/")
return "api.openai.com" in normalized and "openrouter" not in normalized
class OpenAICompatProvider(LLMProvider): class OpenAICompatProvider(LLMProvider):
"""Unified provider for all OpenAI-compatible APIs. """Unified provider for all OpenAI-compatible APIs.
@@ -151,7 +137,6 @@ class OpenAICompatProvider(LLMProvider):
self._setup_env(api_key, api_base) self._setup_env(api_key, api_base)
effective_base = api_base or (spec.default_api_base if spec else None) or None effective_base = api_base or (spec.default_api_base if spec else None) or None
self._effective_base = effective_base
default_headers = {"x-session-affinity": uuid.uuid4().hex} default_headers = {"x-session-affinity": uuid.uuid4().hex}
if _uses_openrouter_attribution(spec, effective_base): if _uses_openrouter_attribution(spec, effective_base):
default_headers.update(_DEFAULT_OPENROUTER_HEADERS) default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
@@ -243,13 +228,9 @@ class OpenAICompatProvider(LLMProvider):
tc_clean["id"] = map_id(tc_clean.get("id")) tc_clean["id"] = map_id(tc_clean.get("id"))
normalized.append(tc_clean) normalized.append(tc_clean)
clean["tool_calls"] = normalized clean["tool_calls"] = normalized
if clean.get("role") == "assistant":
# Some OpenAI-compatible gateways reject assistant messages
# that mix non-empty content with tool_calls.
clean["content"] = None
if "tool_call_id" in clean and clean["tool_call_id"]: if "tool_call_id" in clean and clean["tool_call_id"]:
clean["tool_call_id"] = map_id(clean["tool_call_id"]) clean["tool_call_id"] = map_id(clean["tool_call_id"])
return self._enforce_role_alternation(sanitized) return sanitized
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Build kwargs # Build kwargs
@@ -340,88 +321,6 @@ class OpenAICompatProvider(LLMProvider):
return kwargs return kwargs
def _should_use_responses_api(
self,
model: str | None,
reasoning_effort: str | None,
) -> bool:
"""Use Responses API only for direct OpenAI requests that benefit from it."""
if self._spec and self._spec.name != "openai":
return False
if not _is_direct_openai_base(self._effective_base):
return False
model_name = (model or self.default_model).lower()
if reasoning_effort and reasoning_effort.lower() != "none":
return True
return any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
@staticmethod
def _should_fallback_from_responses_error(e: Exception) -> bool:
"""Fallback only for likely Responses API compatibility errors."""
response = getattr(e, "response", None)
status_code = getattr(e, "status_code", None)
if status_code is None and response is not None:
status_code = getattr(response, "status_code", None)
if status_code not in {400, 404, 422}:
return False
body = (
getattr(e, "body", None)
or getattr(e, "doc", None)
or getattr(response, "text", None)
)
body_text = str(body).lower() if body is not None else ""
compatibility_markers = (
"responses",
"response api",
"max_output_tokens",
"instructions",
"previous_response",
"unsupported",
"not supported",
"unknown parameter",
"unrecognized request argument",
)
return any(marker in body_text for marker in compatibility_markers)
def _build_responses_body(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
model: str | None,
max_tokens: int,
temperature: float,
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
) -> dict[str, Any]:
"""Build a Responses API body for direct OpenAI requests."""
model_name = model or self.default_model
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
instructions, input_items = convert_messages(sanitized_messages)
body: dict[str, Any] = {
"model": model_name,
"instructions": instructions or None,
"input": input_items,
"max_output_tokens": max(1, max_tokens),
"store": False,
"stream": False,
}
if self._supports_temperature(model_name, reasoning_effort):
body["temperature"] = temperature
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
body["include"] = ["reasoning.encrypted_content"]
if tools:
body["tools"] = convert_tools(tools)
body["tool_choice"] = tool_choice or "auto"
return body
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Response parsing # Response parsing
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -556,12 +455,7 @@ class OpenAICompatProvider(LLMProvider):
finish_reason = str(choice0.get("finish_reason") or "stop") finish_reason = str(choice0.get("finish_reason") or "stop")
raw_tool_calls: list[Any] = [] raw_tool_calls: list[Any] = []
# StepFun Plan: fallback to reasoning field when content is empty
if not content and msg0.get("reasoning"):
content = self._extract_text_content(msg0.get("reasoning"))
reasoning_content = msg0.get("reasoning_content") reasoning_content = msg0.get("reasoning_content")
if not reasoning_content and msg0.get("reasoning"):
reasoning_content = self._extract_text_content(msg0.get("reasoning"))
for ch in choices: for ch in choices:
ch_map = self._maybe_mapping(ch) or {} ch_map = self._maybe_mapping(ch) or {}
m = self._maybe_mapping(ch_map.get("message")) or {} m = self._maybe_mapping(ch_map.get("message")) or {}
@@ -617,8 +511,6 @@ class OpenAICompatProvider(LLMProvider):
finish_reason = ch.finish_reason finish_reason = ch.finish_reason
if not content and m.content: if not content and m.content:
content = m.content content = m.content
if not content and getattr(m, "reasoning", None):
content = m.reasoning
tool_calls = [] tool_calls = []
for tc in raw_tool_calls: for tc in raw_tool_calls:
@@ -635,16 +527,12 @@ class OpenAICompatProvider(LLMProvider):
function_provider_specific_fields=fn_prov, function_provider_specific_fields=fn_prov,
)) ))
reasoning_content = getattr(msg, "reasoning_content", None) or None
if not reasoning_content and getattr(msg, "reasoning", None):
reasoning_content = msg.reasoning
return LLMResponse( return LLMResponse(
content=content, content=content,
tool_calls=tool_calls, tool_calls=tool_calls,
finish_reason=finish_reason or "stop", finish_reason=finish_reason or "stop",
usage=self._extract_usage(response), usage=self._extract_usage(response),
reasoning_content=reasoning_content, reasoning_content=getattr(msg, "reasoning_content", None) or None,
) )
@classmethod @classmethod
@@ -705,8 +593,6 @@ class OpenAICompatProvider(LLMProvider):
if text: if text:
content_parts.append(text) content_parts.append(text)
text = cls._extract_text_content(delta.get("reasoning_content")) text = cls._extract_text_content(delta.get("reasoning_content"))
if not text:
text = cls._extract_text_content(delta.get("reasoning"))
if text: if text:
reasoning_parts.append(text) reasoning_parts.append(text)
for idx, tc in enumerate(delta.get("tool_calls") or []): for idx, tc in enumerate(delta.get("tool_calls") or []):
@@ -725,8 +611,6 @@ class OpenAICompatProvider(LLMProvider):
content_parts.append(delta.content) content_parts.append(delta.content)
if delta: if delta:
reasoning = getattr(delta, "reasoning_content", None) reasoning = getattr(delta, "reasoning_content", None)
if not reasoning:
reasoning = getattr(delta, "reasoning", None)
if reasoning: if reasoning:
reasoning_parts.append(reasoning) reasoning_parts.append(reasoning)
for tc in (delta.tool_calls or []) if delta else []: for tc in (delta.tool_calls or []) if delta else []:
@@ -799,12 +683,7 @@ class OpenAICompatProvider(LLMProvider):
} }
@staticmethod @staticmethod
def _handle_error( def _handle_error(e: Exception) -> LLMResponse:
e: Exception,
*,
spec: ProviderSpec | None = None,
api_base: str | None = None,
) -> LLMResponse:
body = ( body = (
getattr(e, "doc", None) getattr(e, "doc", None)
or getattr(e, "body", None) or getattr(e, "body", None)
@@ -812,15 +691,6 @@ class OpenAICompatProvider(LLMProvider):
) )
body_text = body if isinstance(body, str) else str(body) if body is not None else "" body_text = body if isinstance(body, str) else str(body) if body is not None else ""
msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}" msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}"
text = f"{body_text} {e}".lower()
if spec and spec.is_local and ("502" in text or "connection" in text or "refused" in text):
msg += (
"\nHint: this is a local model endpoint. Check that the local server is reachable at "
f"{api_base or spec.default_api_base}, and if you are using a proxy/tunnel, make sure it "
"can reach your local Ollama/vLLM service instead of routing localhost through the remote host."
)
response = getattr(e, "response", None) response = getattr(e, "response", None)
retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None)) retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None))
if retry_after is None: if retry_after is None:
@@ -846,25 +716,14 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse: ) -> LLMResponse:
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
try: try:
if self._should_use_responses_api(model, reasoning_effort):
try:
body = self._build_responses_body(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
return parse_response_output(await self._client.responses.create(**body))
except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error):
raise
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
return self._parse(await self._client.chat.completions.create(**kwargs)) return self._parse(await self._client.chat.completions.create(**kwargs))
except Exception as e: except Exception as e:
return self._handle_error(e, spec=self._spec, api_base=self.api_base) return self._handle_error(e)
async def chat_stream( async def chat_stream(
self, self,
@@ -877,49 +736,14 @@ class OpenAICompatProvider(LLMProvider):
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True}
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90")) idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try: try:
if self._should_use_responses_api(model, reasoning_effort):
try:
body = self._build_responses_body(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
body["stream"] = True
stream = await self._client.responses.create(**body)
async def _timed_stream():
stream_iter = stream.__aiter__()
while True:
try:
yield await asyncio.wait_for(
stream_iter.__anext__(),
timeout=idle_timeout_s,
)
except StopAsyncIteration:
break
content, tool_calls, finish_reason, usage, reasoning_content = await consume_sdk_stream(
_timed_stream(),
on_content_delta,
)
return LLMResponse(
content=content or None,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
reasoning_content=reasoning_content,
)
except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error):
raise
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True}
stream = await self._client.chat.completions.create(**kwargs) stream = await self._client.chat.completions.create(**kwargs)
chunks: list[Any] = [] chunks: list[Any] = []
stream_iter = stream.__aiter__() stream_iter = stream.__aiter__()
@@ -947,7 +771,7 @@ class OpenAICompatProvider(LLMProvider):
error_kind="timeout", error_kind="timeout",
) )
except Exception as e: except Exception as e:
return self._handle_error(e, spec=self._spec, api_base=self.api_base) return self._handle_error(e)
def get_default_model(self) -> str: def get_default_model(self) -> str:
return self.default_model return self.default_model
-6
View File
@@ -57,12 +57,6 @@ class Session:
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"): for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"):
if key in message: if key in message:
entry[key] = message[key] entry[key] = message[key]
# Annotate cross-channel messages so the LLM knows the provenance,
# but keep the entry clean of internal metadata keys.
if message.get("_cross_channel"):
source = message.get("_source_session", "unknown")
prefix = f"[Sent from {source}] "
entry["content"] = prefix + (entry.get("content") or "")
out.append(entry) out.append(entry)
return out return out
+2
View File
@@ -1,5 +1,7 @@
# Agent Instructions # Agent Instructions
You are a helpful AI assistant. Be concise, accurate, and friendly.
## Scheduled Reminders ## Scheduled Reminders
Before scheduling reminders, check available skills and follow skill guidance first. Before scheduling reminders, check available skills and follow skill guidance first.
+17 -5
View File
@@ -2,8 +2,20 @@
I am nanobot 🐈, a personal AI assistant. I am nanobot 🐈, a personal AI assistant.
I solve problems by doing, not by describing what I would do. ## Personality
I keep responses short unless depth is asked for.
I say what I know, flag what I don't, and never fake confidence. - Helpful and friendly
I stay friendly and curious — I'd rather ask a good question than guess wrong. - Concise and to the point
I treat the user's time as the scarcest resource, and their trust as the most valuable. - Curious and eager to learn
## Values
- Accuracy over speed
- User privacy and safety
- Transparency in actions
## Communication Style
- Be clear and direct
- Explain reasoning when helpful
- Ask clarifying questions when needed
-7
View File
@@ -3,7 +3,6 @@ Compare conversation history against current memory files. Also scan memory file
Output one line per finding: Output one line per finding:
[FILE] atomic fact (not already in memory) [FILE] atomic fact (not already in memory)
[FILE-REMOVE] reason for removal [FILE-REMOVE] reason for removal
[SKILL] kebab-case-name: one-line description of the reusable pattern
Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context) Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context)
@@ -19,12 +18,6 @@ Staleness — flag for [FILE-REMOVE]:
- Detailed incident info after 14 days — reduce to one-line summary - Detailed incident info after 14 days — reduce to one-line summary
- Superseded: approaches replaced by newer solutions, deprecated dependencies - Superseded: approaches replaced by newer solutions, deprecated dependencies
Skill discovery — flag [SKILL] when ALL of these are true:
- A specific, repeatable workflow appeared 2+ times in the conversation history
- It involves clear steps (not vague preferences like "likes concise answers")
- It is substantial enough to warrant its own instruction set (not trivial like "read a file")
- Do not worry about duplicates — the next phase will check against existing skills
Do not add: current weather, transient status, temporary errors, conversational filler. Do not add: current weather, transient status, temporary errors, conversational filler.
[SKIP] if nothing needs updating. [SKIP] if nothing needs updating.
-13
View File
@@ -1,13 +1,11 @@
Update memory files based on the analysis below. Update memory files based on the analysis below.
- [FILE] entries: add the described content to the appropriate file - [FILE] entries: add the described content to the appropriate file
- [FILE-REMOVE] entries: delete the corresponding content from memory files - [FILE-REMOVE] entries: delete the corresponding content from memory files
- [SKILL] entries: create a new skill under skills/<name>/SKILL.md using write_file
## File paths (relative to workspace root) ## File paths (relative to workspace root)
- SOUL.md - SOUL.md
- USER.md - USER.md
- memory/MEMORY.md - memory/MEMORY.md
- skills/<name>/SKILL.md (for [SKILL] entries only)
Do NOT guess paths. Do NOT guess paths.
@@ -19,17 +17,6 @@ Do NOT guess paths.
- Surgical edits only — never rewrite entire files - Surgical edits only — never rewrite entire files
- If nothing to update, stop without calling tools - If nothing to update, stop without calling tools
## Skill creation rules (for [SKILL] entries)
- Use write_file to create skills/<name>/SKILL.md
- Before writing, read_file `{{ skill_creator_path }}` for format reference (frontmatter structure, naming conventions, quality standards)
- **Dedup check**: read existing skills listed below to verify the new skill is not functionally redundant. Skip creation if an existing skill already covers the same workflow.
- Include YAML frontmatter with name and description fields
- Keep SKILL.md under 2000 words — concise and actionable
- Include: when to use, steps, output format, at least one example
- Do NOT overwrite existing skills — skip if the skill directory already exists
- Reference specific tools the agent has access to (read_file, write_file, exec, web_search, etc.)
- Skills are instruction sets, not code — do not include implementation code
## Quality ## Quality
- Every line must carry standalone value - Every line must carry standalone value
- Concise bullets under clear headers - Concise bullets under clear headers
+8 -25
View File
@@ -12,32 +12,15 @@ Your workspace is at: {{ workspace_path }}
- Custom skills: {{ workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md - Custom skills: {{ workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md
{{ platform_policy }} {{ platform_policy }}
{% if channel == 'telegram' or channel == 'qq' or channel == 'discord' %}
## Format Hint
This conversation is on a messaging app. Use short paragraphs. Avoid large headings (#, ##). Use **bold** sparingly. No tables — use plain lists.
{% elif channel == 'whatsapp' or channel == 'sms' %}
## Format Hint
This conversation is on a text messaging platform that does not render markdown. Use plain text only.
{% elif channel == 'email' %}
## Format Hint
This conversation is via email. Structure with clear sections. Markdown may not render — keep formatting simple.
{% elif channel == 'cli' or channel == 'mochat' %}
## Format Hint
Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting.
{% endif %}
## Execution Rules ## nanobot Guidelines
- State intent before tool calls, but NEVER predict or claim results before receiving them.
- Act, don't narrate. If you can do it with a tool, do it now — never end a turn with just a plan or promise. - Before modifying a file, read it first. Do not assume files or directories exist.
- Read before you write. Do not assume a file exists or contains what you expect. - After writing or editing a file, re-read it if accuracy matters.
- If a tool call fails, diagnose the error and retry with a different approach before reporting failure. - If a tool call fails, analyze the error before retrying with a different approach.
- When information is missing, look it up with tools first. Only ask the user when tools cannot answer. - Ask for clarification when the request is ambiguous.
- After multi-step changes, verify the result (re-read the file, run the test, check the output). - Prefer built-in `grep` / `glob` tools for workspace search before falling back to `exec`.
- On broad searches, use `grep(output_mode="count")` or `grep(output_mode="files_with_matches")` to scope the result set before requesting full content.
## Search & Discovery
- Prefer built-in `grep` / `glob` over `exec` for workspace search.
- On broad searches, use `grep(output_mode="count")` to scope before requesting full content.
{% include 'agent/_snippets/untrusted_content.md' %} {% include 'agent/_snippets/untrusted_content.md' %}
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel. Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
+4 -4
View File
@@ -17,10 +17,10 @@ from loguru import logger
def strip_think(text: str) -> str: def strip_think(text: str) -> str:
"""Remove thinking blocks and any unclosed trailing tag.""" """Remove thinking blocks and any unclosed trailing tag."""
text = re.sub(r"<think>[\s\S]*?</think>", "", text) text = re.sub(r"<think>[\s\S]*?</think>", "", text)
text = re.sub(r"^\s*<think>[\s\S]*$", "", text) text = re.sub(r"<think>[\s\S]*$", "", text)
# Gemma 4 and similar models use <thought>...</thought> blocks # Gemma 4 and similar models use <thought>...</thought> blocks
text = re.sub(r"<thought>[\s\S]*?</thought>", "", text) text = re.sub(r"<thought>[\s\S]*?</thought>", "", text)
text = re.sub(r"^\s*<thought>[\s\S]*$", "", text) text = re.sub(r"<thought>[\s\S]*$", "", text)
return text.strip() return text.strip()
@@ -275,7 +275,7 @@ def build_assistant_message(
thinking_blocks: list[dict] | None = None, thinking_blocks: list[dict] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build a provider-safe assistant message with optional reasoning fields.""" """Build a provider-safe assistant message with optional reasoning fields."""
msg: dict[str, Any] = {"role": "assistant", "content": content or ""} 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 or thinking_blocks: if reasoning_content is not None or thinking_blocks:
@@ -420,7 +420,7 @@ def build_status_content(
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 // 1000}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" token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out"
if cached and last_in: if cached and last_in:
token_line += f" ({cached * 100 // last_in}% cached)" token_line += f" ({cached * 100 // last_in}% cached)"
-10
View File
@@ -19,11 +19,6 @@ FINALIZATION_RETRY_PROMPT = (
"Please provide your response to the user based on the conversation above." "Please provide your response to the user based on the conversation above."
) )
LENGTH_RECOVERY_PROMPT = (
"Output limit reached. Continue exactly where you left off "
"— no recap, no apology. Break remaining work into smaller steps if needed."
)
def empty_tool_result_message(tool_name: str) -> str: def empty_tool_result_message(tool_name: str) -> str:
"""Short prompt-safe marker for tools that completed without visible output.""" """Short prompt-safe marker for tools that completed without visible output."""
@@ -55,11 +50,6 @@ def build_finalization_retry_message() -> dict[str, str]:
return {"role": "user", "content": FINALIZATION_RETRY_PROMPT} return {"role": "user", "content": FINALIZATION_RETRY_PROMPT}
def build_length_recovery_message() -> dict[str, str]:
"""Prompt the model to continue after hitting output token limit."""
return {"role": "user", "content": LENGTH_RECOVERY_PROMPT}
def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None: def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None:
"""Stable signature for repeated external lookups we want to throttle.""" """Stable signature for repeated external lookups we want to throttle."""
if tool_name == "web_fetch": if tool_name == "web_fetch":
+5 -12
View File
@@ -19,11 +19,9 @@ _TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = {
"list_dir": (["path"], "ls {}", True, False), "list_dir": (["path"], "ls {}", True, False),
} }
# Matches file paths embedded in shell commands, including quoted paths with spaces. # Matches file paths embedded in shell commands (Windows drive, ~/, or absolute after space)
_PATH_IN_CMD_RE = re.compile( _PATH_IN_CMD_RE = re.compile(
r'"(?P<double>(?:[A-Za-z]:[/\\]|~/|/)[^"]+)"' r"(?:[A-Za-z]:[/\\]|~/|(?<=\s)/)[^\s;&|<>\"']+"
r"|'(?P<single>(?:[A-Za-z]:[/\\]|~/|/)[^']+)'"
r"|(?P<bare>(?:[A-Za-z]:[/\\]|~/|(?<=\s)/)[^\s;&|<>\"']+)"
) )
@@ -94,14 +92,9 @@ def _fmt_known(tc, fmt: tuple) -> str:
def _abbreviate_command(cmd: str, max_len: int = 40) -> str: def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
"""Abbreviate paths in a command string, then truncate.""" """Abbreviate paths in a command string, then truncate."""
def _replace_path(match: re.Match[str]) -> str: abbreviated = _PATH_IN_CMD_RE.sub(
if match.group("double") is not None: lambda m: abbreviate_path(m.group(), max_len=25), cmd
return f'"{abbreviate_path(match.group("double"), max_len=25)}"' )
if match.group("single") is not None:
return f"'{abbreviate_path(match.group('single'), max_len=25)}'"
return abbreviate_path(match.group("bare"), max_len=25)
abbreviated = _PATH_IN_CMD_RE.sub(_replace_path, cmd)
if len(abbreviated) <= max_len: if len(abbreviated) <= max_len:
return abbreviated return abbreviated
return abbreviated[:max_len - 1] + "\u2026" return abbreviated[:max_len - 1] + "\u2026"
+4 -5
View File
@@ -50,7 +50,6 @@ dependencies = [
"tiktoken>=0.12.0,<1.0.0", "tiktoken>=0.12.0,<1.0.0",
"jinja2>=3.1.0,<4.0.0", "jinja2>=3.1.0,<4.0.0",
"dulwich>=0.22.0,<1.0.0", "dulwich>=0.22.0,<1.0.0",
"filelock>=3.25.2",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -64,6 +63,10 @@ weixin = [
"qrcode[pil]>=8.0", "qrcode[pil]>=8.0",
"pycryptodome>=3.20.0", "pycryptodome>=3.20.0",
] ]
msteams = [
"PyJWT>=2.0,<3.0",
"cryptography>=41.0",
]
matrix = [ matrix = [
"matrix-nio[e2e]>=0.25.2", "matrix-nio[e2e]>=0.25.2",
@@ -76,16 +79,12 @@ discord = [
langsmith = [ langsmith = [
"langsmith>=0.1.0", "langsmith>=0.1.0",
] ]
pdf = [
"pymupdf>=1.25.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", "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",
"pymupdf>=1.25.0",
] ]
[project.scripts] [project.scripts]
+115 -194
View File
@@ -35,13 +35,6 @@ def _make_loop(tmp_path: Path, session_ttl_minutes: int = 15) -> AgentLoop:
return loop return loop
def _add_turns(session, turns: int, *, prefix: str = "msg") -> None:
"""Append simple user/assistant turns to a session."""
for i in range(turns):
session.add_message("user", f"{prefix} user {i}")
session.add_message("assistant", f"{prefix} assistant {i}")
class TestSessionTTLConfig: class TestSessionTTLConfig:
"""Test session TTL configuration.""" """Test session TTL configuration."""
@@ -55,23 +48,6 @@ class TestSessionTTLConfig:
defaults = AgentDefaults(session_ttl_minutes=30) defaults = AgentDefaults(session_ttl_minutes=30)
assert defaults.session_ttl_minutes == 30 assert defaults.session_ttl_minutes == 30
def test_user_friendly_alias_is_supported(self):
"""Config should accept idleCompactAfterMinutes as the preferred JSON key."""
defaults = AgentDefaults.model_validate({"idleCompactAfterMinutes": 30})
assert defaults.session_ttl_minutes == 30
def test_legacy_alias_is_still_supported(self):
"""Config should still accept the old sessionTtlMinutes key for compatibility."""
defaults = AgentDefaults.model_validate({"sessionTtlMinutes": 30})
assert defaults.session_ttl_minutes == 30
def test_serializes_with_user_friendly_alias(self):
"""Config dumps should use idleCompactAfterMinutes for JSON output."""
defaults = AgentDefaults(session_ttl_minutes=30)
data = defaults.model_dump(mode="json", by_alias=True)
assert data["idleCompactAfterMinutes"] == 30
assert "sessionTtlMinutes" not in data
class TestAgentLoopTTLParam: class TestAgentLoopTTLParam:
"""Test that AutoCompact receives and stores session_ttl_minutes.""" """Test that AutoCompact receives and stores session_ttl_minutes."""
@@ -125,7 +101,7 @@ class TestAutoCompact:
loop.sessions.save(s2) loop.sessions.save(s2)
async def _fake_archive(messages): async def _fake_archive(messages):
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.auto_compact.check_expired(loop._schedule_background) loop.auto_compact.check_expired(loop._schedule_background)
@@ -137,28 +113,28 @@ class TestAutoCompact:
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_archives_prefix_and_keeps_recent_suffix(self, tmp_path): async def test_auto_compact_archives_and_clears(self, tmp_path):
"""_archive should summarize the old prefix and keep a recent legal suffix.""" """_archive should archive un-consolidated messages and clear session."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6) for i in range(4):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session) loop.sessions.save(session)
archived_messages = [] archived_messages = []
async def _fake_archive(messages): async def _fake_archive(messages):
archived_messages.extend(messages) archived_messages.extend(messages)
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
assert len(archived_messages) == 4 assert len(archived_messages) == 8
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 0
assert session_after.messages[0]["content"] == "msg user 2"
assert session_after.messages[-1]["content"] == "msg assistant 5"
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -166,13 +142,17 @@ class TestAutoCompact:
"""_archive should store the summary in _summaries.""" """_archive should store the summary in _summaries."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="hello") session.add_message("user", "hello")
session.add_message("assistant", "hi there")
loop.sessions.save(session) loop.sessions.save(session)
async def _fake_archive(messages): async def _fake_archive(messages):
return "User said hello." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "User said hello.",
}
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -180,7 +160,7 @@ class TestAutoCompact:
assert entry is not None assert entry is not None
assert entry[0] == "User said hello." assert entry[0] == "User said hello."
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 0
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -194,7 +174,7 @@ class TestAutoCompact:
async def _fake_archive(messages): async def _fake_archive(messages):
nonlocal archive_called nonlocal archive_called
archive_called = True archive_called = True
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
@@ -210,7 +190,9 @@ class TestAutoCompact:
"""_archive should only archive un-consolidated messages.""" """_archive should only archive un-consolidated messages."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 14) for i in range(10):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
session.last_consolidated = 18 session.last_consolidated = 18
loop.sessions.save(session) loop.sessions.save(session)
@@ -219,7 +201,7 @@ class TestAutoCompact:
async def _fake_archive(messages): async def _fake_archive(messages):
nonlocal archived_count nonlocal archived_count
archived_count = len(messages) archived_count = len(messages)
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
@@ -253,7 +235,7 @@ class TestAutoCompactIdleDetection:
"""Proactive auto-new archives expired session; _process_message reloads it.""" """Proactive auto-new archives expired session; _process_message reloads it."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="old") session.add_message("user", "old message")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
@@ -261,9 +243,12 @@ class TestAutoCompactIdleDetection:
async def _fake_archive(messages): async def _fake_archive(messages):
archived_messages.extend(messages) archived_messages.extend(messages)
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
}
# Simulate proactive archive completing before message arrives # Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -272,8 +257,7 @@ class TestAutoCompactIdleDetection:
await loop._process_message(msg) await loop._process_message(msg)
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(archived_messages) == 4 assert not any(m["content"] == "old message" for m in session_after.messages)
assert not any(m["content"] == "old user 0" for m in session_after.messages)
assert any(m["content"] == "new msg" for m in session_after.messages) assert any(m["content"] == "new msg" for m in session_after.messages)
await loop.close_mcp() await loop.close_mcp()
@@ -327,7 +311,7 @@ class TestAutoCompactIdleDetection:
loop.sessions.save(session) loop.sessions.save(session)
async def _fake_archive(messages): async def _fake_archive(messages):
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
@@ -351,14 +335,17 @@ class TestAutoCompactSystemMessages:
"""Proactive auto-new archives expired session; system messages reload it.""" """Proactive auto-new archives expired session; system messages reload it."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="old") session.add_message("user", "old message from subagent context")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
async def _fake_archive(messages): async def _fake_archive(messages):
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
}
# Simulate proactive archive completing before system message arrives # Simulate proactive archive completing before system message arrives
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -371,7 +358,7 @@ class TestAutoCompactSystemMessages:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert not any( assert not any(
m["content"] == "old user 0" m["content"] == "old message from subagent context"
for m in session_after.messages for m in session_after.messages
) )
await loop.close_mcp() await loop.close_mcp()
@@ -385,7 +372,8 @@ class TestAutoCompactEdgeCases:
"""Auto-new should not inject when archive produces '(nothing)'.""" """Auto-new should not inject when archive produces '(nothing)'."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="thanks") session.add_message("user", "thanks")
session.add_message("assistant", "you're welcome")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
@@ -396,18 +384,18 @@ class TestAutoCompactEdgeCases:
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 0
# "(nothing)" summary should not be stored # "(nothing)" summary should not be stored
assert "cli:test" not in loop.auto_compact._summaries assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_archive_failure_still_keeps_recent_suffix(self, tmp_path): async def test_auto_compact_archive_failure_still_clears(self, tmp_path):
"""Auto-new should keep the recent suffix even if LLM archive falls back to raw dump.""" """Auto-new should clear session even if LLM archive fails (raw_archive fallback)."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="important") session.add_message("user", "important data")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
@@ -417,13 +405,14 @@ class TestAutoCompactEdgeCases:
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES # Session should be cleared (archive falls back to raw dump)
assert len(session_after.messages) == 0
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path): async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path):
"""Short expired sessions keep recent messages; checkpoint restore still works on resume.""" """Runtime checkpoint is restored; proactive archive handles the expired session."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.metadata[AgentLoop._RUNTIME_CHECKPOINT_KEY] = { session.metadata[AgentLoop._RUNTIME_CHECKPOINT_KEY] = {
@@ -439,9 +428,12 @@ class TestAutoCompactEdgeCases:
async def _fake_archive(messages): async def _fake_archive(messages):
archived_messages.extend(messages) archived_messages.extend(messages)
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
}
# Simulate proactive archive completing before message arrives # Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -449,10 +441,8 @@ class TestAutoCompactEdgeCases:
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="continue") msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="continue")
await loop._process_message(msg) await loop._process_message(msg)
session_after = loop.sessions.get_or_create("cli:test") # The checkpoint-restored message should have been archived by proactive path
assert archived_messages == [] assert len(archived_messages) >= 1
assert any(m["content"] == "previous message" for m in session_after.messages)
assert any(m["content"] == "interrupted response" for m in session_after.messages)
await loop.close_mcp() await loop.close_mcp()
@@ -468,17 +458,11 @@ class TestAutoCompactIntegration:
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
# Phase 1: User has a conversation longer than the retained recent suffix # Phase 1: User has a conversation
session.add_message("user", "I'm learning English, teach me past tense") session.add_message("user", "I'm learning English, teach me past tense")
session.add_message("assistant", "Past tense is used for actions completed in the past...") session.add_message("assistant", "Past tense is used for actions completed in the past...")
session.add_message("user", "Give me an example") session.add_message("user", "Give me an example")
session.add_message("assistant", '"I walked to the store yesterday."') session.add_message("assistant", '"I walked to the store yesterday."')
session.add_message("user", "Give me another example")
session.add_message("assistant", '"She visited Paris last year."')
session.add_message("user", "Quiz me")
session.add_message("assistant", "What is the past tense of go?")
session.add_message("user", "I think it is went")
session.add_message("assistant", "Correct.")
loop.sessions.save(session) loop.sessions.save(session)
# Phase 2: Time passes (simulate idle) # Phase 2: Time passes (simulate idle)
@@ -502,7 +486,7 @@ class TestAutoCompactIntegration:
# Phase 4: Verify # Phase 4: Verify
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
# The oldest messages should be trimmed from live session history # Old messages should be gone
assert not any( assert not any(
"past tense is used" in str(m.get("content", "")) for m in session_after.messages "past tense is used" in str(m.get("content", "")) for m in session_after.messages
) )
@@ -525,8 +509,8 @@ class TestAutoCompactIntegration:
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runtime_context_markers_not_persisted_for_multi_paragraph_turn(self, tmp_path): async def test_multi_paragraph_user_message_preserved(self, tmp_path):
"""Auto-compact resume context must not leak runtime markers into persisted session history.""" """Multi-paragraph user messages must be fully preserved after auto-new."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.add_message("user", "old message") session.add_message("user", "old message")
@@ -534,9 +518,12 @@ class TestAutoCompactIntegration:
loop.sessions.save(session) loop.sessions.save(session)
async def _fake_archive(messages): async def _fake_archive(messages):
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
}
# Simulate proactive archive completing before message arrives # Simulate proactive archive completing before message arrives
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -548,11 +535,16 @@ class TestAutoCompactIntegration:
await loop._process_message(msg) await loop._process_message(msg)
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert any(m.get("content") == "old message" for m in session_after.messages) user_msgs = [m for m in session_after.messages if m.get("role") == "user"]
for persisted in session_after.messages: assert len(user_msgs) >= 1
content = str(persisted.get("content", "")) # All three paragraphs must be preserved
assert "[Runtime Context" not in content persisted = user_msgs[-1]["content"]
assert "[/Runtime Context]" not in content assert "Paragraph one" in persisted
assert "Paragraph two" in persisted
assert "Paragraph three" in persisted
# No runtime context markers in persisted message
assert "[Runtime Context" not in persisted
assert "[/Runtime Context]" not in persisted
await loop.close_mcp() await loop.close_mcp()
@@ -560,12 +552,9 @@ class TestProactiveAutoCompact:
"""Test proactive auto-new on idle ticks (TimeoutError path in run loop).""" """Test proactive auto-new on idle ticks (TimeoutError path in run loop)."""
@staticmethod @staticmethod
async def _run_check_expired(loop, active_session_keys=()): async def _run_check_expired(loop):
"""Helper: run check_expired via callback and wait for background tasks.""" """Helper: run check_expired via callback and wait for background tasks."""
loop.auto_compact.check_expired( loop.auto_compact.check_expired(loop._schedule_background)
loop._schedule_background,
active_session_keys=active_session_keys,
)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -588,7 +577,8 @@ class TestProactiveAutoCompact:
"""Expired session should be archived during idle tick.""" """Expired session should be archived during idle tick."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 5, prefix="old") session.add_message("user", "old message")
session.add_message("assistant", "old response")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
@@ -596,14 +586,17 @@ class TestProactiveAutoCompact:
async def _fake_archive(messages): async def _fake_archive(messages):
archived_messages.extend(messages) archived_messages.extend(messages)
return "User chatted about old things." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "User chatted about old things.",
}
await self._run_check_expired(loop) await self._run_check_expired(loop)
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 0
assert len(archived_messages) == 2 assert len(archived_messages) == 2
entry = loop.auto_compact._summaries.get("cli:test") entry = loop.auto_compact._summaries.get("cli:test")
assert entry is not None assert entry is not None
@@ -629,7 +622,7 @@ class TestProactiveAutoCompact:
"""Should not archive the same session twice if already in progress.""" """Should not archive the same session twice if already in progress."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="old") session.add_message("user", "old message")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
@@ -642,7 +635,7 @@ class TestProactiveAutoCompact:
archive_count += 1 archive_count += 1
started.set() started.set()
await block_forever.wait() await block_forever.wait()
return "Summary." return True
loop.consolidator.archive = _slow_archive loop.consolidator.archive = _slow_archive
@@ -666,7 +659,7 @@ class TestProactiveAutoCompact:
"""Proactive archive failure should be caught and not block future ticks.""" """Proactive archive failure should be caught and not block future ticks."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="old") session.add_message("user", "old message")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
@@ -695,7 +688,7 @@ class TestProactiveAutoCompact:
async def _fake_archive(messages): async def _fake_archive(messages):
nonlocal archive_called nonlocal archive_called
archive_called = True archive_called = True
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
@@ -704,105 +697,13 @@ class TestProactiveAutoCompact:
assert not archive_called assert not archive_called
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio
async def test_skip_expired_session_with_active_agent_task(self, tmp_path):
"""Expired session with an active agent task should NOT be archived."""
loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="old")
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
# Simulate an active agent task for this session
await self._run_check_expired(loop, active_session_keys={"cli:test"})
assert archive_count == 0
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 12 # All messages preserved
await loop.close_mcp()
@pytest.mark.asyncio
async def test_archive_after_active_task_completes(self, tmp_path):
"""Session should be archived on next tick after active task completes."""
loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="old")
session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
# First tick: active task, skip
await self._run_check_expired(loop, active_session_keys={"cli:test"})
assert archive_count == 0
# Second tick: task completed, should archive
await self._run_check_expired(loop)
assert archive_count == 1
await loop.close_mcp()
@pytest.mark.asyncio
async def test_partial_active_set_only_archives_inactive_expired(self, tmp_path):
"""With multiple sessions, only the expired+inactive one should be archived."""
loop = _make_loop(tmp_path, session_ttl_minutes=15)
# Session A: expired, no active task -> should be archived
s1 = loop.sessions.get_or_create("cli:expired_idle")
_add_turns(s1, 6, prefix="old_a")
s1.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(s1)
# Session B: expired, has active task -> should be skipped
s2 = loop.sessions.get_or_create("cli:expired_active")
_add_turns(s2, 6, prefix="old_b")
s2.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(s2)
# Session C: recent, no active task -> should be skipped
s3 = loop.sessions.get_or_create("cli:recent")
s3.add_message("user", "recent")
loop.sessions.save(s3)
archive_count = 0
async def _fake_archive(messages):
nonlocal archive_count
archive_count += 1
return "Summary."
loop.consolidator.archive = _fake_archive
await self._run_check_expired(loop, active_session_keys={"cli:expired_active"})
assert archive_count == 1
s1_after = loop.sessions.get_or_create("cli:expired_idle")
assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
s2_after = loop.sessions.get_or_create("cli:expired_active")
assert len(s2_after.messages) == 12 # Preserved
s3_after = loop.sessions.get_or_create("cli:recent")
assert len(s3_after.messages) == 1 # Preserved
await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_reschedule_after_successful_archive(self, tmp_path): async def test_no_reschedule_after_successful_archive(self, tmp_path):
"""Already-archived session should NOT be re-scheduled on subsequent ticks.""" """Already-archived session should NOT be re-scheduled on subsequent ticks."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 5, prefix="old") session.add_message("user", "old message")
session.add_message("assistant", "old response")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
@@ -811,9 +712,12 @@ class TestProactiveAutoCompact:
async def _fake_archive(messages): async def _fake_archive(messages):
nonlocal archive_count nonlocal archive_count
archive_count += 1 archive_count += 1
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
}
# First tick: archives the session # First tick: archives the session
await self._run_check_expired(loop) await self._run_check_expired(loop)
@@ -837,7 +741,7 @@ class TestProactiveAutoCompact:
async def _fake_archive(messages): async def _fake_archive(messages):
nonlocal archive_count nonlocal archive_count
archive_count += 1 archive_count += 1
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
@@ -855,7 +759,8 @@ class TestProactiveAutoCompact:
"""After successful compact + user sends new messages + idle again, should compact again.""" """After successful compact + user sends new messages + idle again, should compact again."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 5, prefix="first") session.add_message("user", "first conversation")
session.add_message("assistant", "first response")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
@@ -864,9 +769,12 @@ class TestProactiveAutoCompact:
async def _fake_archive(messages): async def _fake_archive(messages):
nonlocal archive_count nonlocal archive_count
archive_count += 1 archive_count += 1
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
}
# First compact cycle # First compact cycle
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -896,14 +804,18 @@ class TestSummaryPersistence:
"""After archive, _last_summary should be in session metadata.""" """After archive, _last_summary should be in session metadata."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="hello") session.add_message("user", "hello")
session.add_message("assistant", "hi there")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
async def _fake_archive(messages): async def _fake_archive(messages):
return "User said hello." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "User said hello.",
}
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -920,15 +832,19 @@ class TestSummaryPersistence:
"""Summary should be recovered from metadata when _summaries is empty (simulates restart).""" """Summary should be recovered from metadata when _summaries is empty (simulates restart)."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="hello") session.add_message("user", "hello")
session.add_message("assistant", "hi there")
last_active = datetime.now() - timedelta(minutes=20) last_active = datetime.now() - timedelta(minutes=20)
session.updated_at = last_active session.updated_at = last_active
loop.sessions.save(session) loop.sessions.save(session)
async def _fake_archive(messages): async def _fake_archive(messages):
return "User said hello." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "User said hello.",
}
# Archive # Archive
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -939,7 +855,6 @@ class TestSummaryPersistence:
# prepare_session should recover summary from metadata # prepare_session should recover summary from metadata
reloaded = loop.sessions.get_or_create("cli:test") reloaded = loop.sessions.get_or_create("cli:test")
assert len(reloaded.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test") _, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary is not None assert summary is not None
@@ -954,14 +869,17 @@ class TestSummaryPersistence:
"""_last_summary should be removed from metadata after being consumed.""" """_last_summary should be removed from metadata after being consumed."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="hello") session.add_message("user", "hello")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
async def _fake_archive(messages): async def _fake_archive(messages):
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
}
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
@@ -985,14 +903,17 @@ class TestSummaryPersistence:
"""In-memory _summaries path should also clean up _last_summary from metadata.""" """In-memory _summaries path should also clean up _last_summary from metadata."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="hello") session.add_message("user", "hello")
session.updated_at = datetime.now() - timedelta(minutes=20) session.updated_at = datetime.now() - timedelta(minutes=20)
loop.sessions.save(session) loop.sessions.save(session)
async def _fake_archive(messages): async def _fake_archive(messages):
return "Summary." return True
loop.consolidator.archive = _fake_archive loop.consolidator.archive = _fake_archive
loop.consolidator.get_last_history_entry = lambda: {
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
}
await loop.auto_compact._archive("cli:test") await loop.auto_compact._archive("cli:test")
+3 -52
View File
@@ -46,7 +46,7 @@ class TestConsolidatorSummarize:
{"role": "assistant", "content": "Done, fixed the race condition."}, {"role": "assistant", "content": "Done, fixed the race condition."},
] ]
result = await consolidator.archive(messages) result = await consolidator.archive(messages)
assert result == "User fixed a bug in the auth module." assert result is True
entries = store.read_unprocessed_history(since_cursor=0) entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1 assert len(entries) == 1
@@ -55,14 +55,14 @@ class TestConsolidatorSummarize:
mock_provider.chat_with_retry.side_effect = Exception("API error") mock_provider.chat_with_retry.side_effect = Exception("API error")
messages = [{"role": "user", "content": "hello"}] messages = [{"role": "user", "content": "hello"}]
result = await consolidator.archive(messages) result = await consolidator.archive(messages)
assert result is None # no summary on raw dump fallback assert result is True # always succeeds
entries = store.read_unprocessed_history(since_cursor=0) entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1 assert len(entries) == 1
assert "[RAW]" in entries[0]["content"] assert "[RAW]" in entries[0]["content"]
async def test_summarize_skips_empty_messages(self, consolidator): async def test_summarize_skips_empty_messages(self, consolidator):
result = await consolidator.archive([]) result = await consolidator.archive([])
assert result is None assert result is False
class TestConsolidatorTokenBudget: class TestConsolidatorTokenBudget:
@@ -76,52 +76,3 @@ class TestConsolidatorTokenBudget:
consolidator.archive = AsyncMock(return_value=True) consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session) await consolidator.maybe_consolidate_by_tokens(session)
consolidator.archive.assert_not_called() consolidator.archive.assert_not_called()
async def test_chunk_cap_preserves_user_turn_boundary(self, consolidator):
"""Chunk cap should rewind to the last user boundary within the cap."""
consolidator._SAFETY_BUFFER = 0
session = MagicMock()
session.last_consolidated = 0
session.key = "test:key"
session.messages = [
{
"role": "user" if i in {0, 50, 61} else "assistant",
"content": f"m{i}",
}
for i in range(70)
]
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session)
archived_chunk = consolidator.archive.await_args.args[0]
assert len(archived_chunk) == 50
assert archived_chunk[0]["content"] == "m0"
assert archived_chunk[-1]["content"] == "m49"
assert session.last_consolidated == 50
async def test_chunk_cap_skips_when_no_user_boundary_within_cap(self, consolidator):
"""If the cap would cut mid-turn, consolidation should skip that round."""
consolidator._SAFETY_BUFFER = 0
session = MagicMock()
session.last_consolidated = 0
session.key = "test:key"
session.messages = [
{
"role": "user" if i in {0, 61} else "assistant",
"content": f"m{i}",
}
for i in range(70)
]
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(1200, "tiktoken"))
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session)
consolidator.archive.assert_not_awaited()
assert session.last_consolidated == 0
-119
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime as real_datetime from datetime import datetime as real_datetime
from importlib.resources import files as pkg_files from importlib.resources import files as pkg_files
from pathlib import Path from pathlib import Path
@@ -87,124 +86,6 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
assert "Return exactly: OK" in user_content assert "Return exactly: OK" in user_content
def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
builder.memory.append_history("User asked about weather in Tokyo")
builder.memory.append_history("Agent fetched forecast via web_search")
prompt = builder.build_system_prompt()
assert "# Recent History" in prompt
assert "User asked about weather in Tokyo" in prompt
assert "Agent fetched forecast via web_search" in prompt
assert re.search(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]", prompt)
def test_recent_history_capped_at_max(tmp_path) -> None:
"""Only the most recent _MAX_RECENT_HISTORY entries are injected."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
for i in range(builder._MAX_RECENT_HISTORY + 20):
builder.memory.append_history(f"entry-{i}")
prompt = builder.build_system_prompt()
assert "entry-0" not in prompt
assert "entry-19" not in prompt
assert f"entry-{builder._MAX_RECENT_HISTORY + 19}" in prompt
def test_no_recent_history_when_dream_has_processed_all(tmp_path) -> None:
"""If Dream has consumed everything, no Recent History section should appear."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
cursor = builder.memory.append_history("already processed entry")
builder.memory.set_last_dream_cursor(cursor)
prompt = builder.build_system_prompt()
assert "# Recent History" not in prompt
def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
"""When Dream has processed some entries, only the unprocessed ones appear."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
c1 = builder.memory.append_history("old conversation about Python")
c2 = builder.memory.append_history("old conversation about Rust")
builder.memory.append_history("recent question about Docker")
builder.memory.append_history("recent question about K8s")
builder.memory.set_last_dream_cursor(c2)
prompt = builder.build_system_prompt()
assert "# Recent History" in prompt
assert "old conversation about Python" not in prompt
assert "old conversation about Rust" not in prompt
assert "recent question about Docker" in prompt
assert "recent question about K8s" in prompt
def test_execution_rules_in_system_prompt(tmp_path) -> None:
"""New execution rules should appear in the system prompt."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt()
assert "Act, don't narrate" in prompt
assert "Read before you write" in prompt
assert "verify the result" in prompt
def test_channel_format_hint_telegram(tmp_path) -> None:
"""Telegram channel should get messaging-app format hint."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt(channel="telegram")
assert "Format Hint" in prompt
assert "messaging app" in prompt
def test_channel_format_hint_whatsapp(tmp_path) -> None:
"""WhatsApp should get plain-text format hint."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt(channel="whatsapp")
assert "Format Hint" in prompt
assert "plain text only" in prompt
def test_channel_format_hint_absent_for_unknown(tmp_path) -> None:
"""Unknown or None channel should not inject a format hint."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
prompt = builder.build_system_prompt(channel=None)
assert "Format Hint" not in prompt
prompt2 = builder.build_system_prompt(channel="feishu")
assert "Format Hint" not in prompt2
def test_build_messages_passes_channel_to_system_prompt(tmp_path) -> None:
"""build_messages should pass channel through to build_system_prompt."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
messages = builder.build_messages(
history=[], current_message="hi",
channel="telegram", chat_id="123",
)
system = messages[0]["content"]
assert "Format Hint" in system
assert "messaging app" in system
def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path) -> None: def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path) -> None:
workspace = _make_workspace(tmp_path) workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace) builder = ContextBuilder(workspace)
-28
View File
@@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
from nanobot.agent.memory import Dream, MemoryStore from nanobot.agent.memory import Dream, MemoryStore
from nanobot.agent.runner import AgentRunResult from nanobot.agent.runner import AgentRunResult
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
@pytest.fixture @pytest.fixture
@@ -96,30 +95,3 @@ class TestDreamRun:
entries = store.read_unprocessed_history(since_cursor=0) entries = store.read_unprocessed_history(since_cursor=0)
assert all(e["cursor"] > 0 for e in entries) assert all(e["cursor"] > 0 for e in entries)
async def test_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store):
"""Dream should point skill creation guidance at the builtin skill-creator template."""
store.append_history("Repeated workflow one")
store.append_history("Repeated workflow two")
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKILL] test-skill: test description")
mock_runner.run = AsyncMock(return_value=_make_run_result())
await dream.run()
spec = mock_runner.run.call_args[0][0]
system_prompt = spec.initial_messages[0]["content"]
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
assert expected in system_prompt
async def test_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store):
"""Dream skill creation should allow skills/<name>/SKILL.md relative to workspace root."""
write_tool = dream._tools.get("write_file")
assert write_tool is not None
result = await write_tool.execute(
path="skills/test-skill/SKILL.md",
content="---\nname: test-skill\ndescription: Test\n---\n",
)
assert "Successfully wrote" in result
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
+11 -16
View File
@@ -184,22 +184,17 @@ def test_stale_extra_content_in_tool_calls_survives_sanitize() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider() provider = OpenAICompatProvider()
messages = [ messages = [{
{"role": "user", "content": "hi"}, "role": "assistant",
{ "content": None,
"role": "assistant", "tool_calls": [{
"content": None, "id": "call_1",
"tool_calls": [{ "type": "function",
"id": "call_1", "function": {"name": "fn", "arguments": "{}"},
"type": "function", "extra_content": GEMINI_EXTRA,
"function": {"name": "fn", "arguments": "{}"}, }],
"extra_content": GEMINI_EXTRA, }]
}],
},
{"role": "tool", "content": "ok", "tool_call_id": "call_1"},
{"role": "user", "content": "thanks"},
]
sanitized = provider._sanitize_messages(messages) sanitized = provider._sanitize_messages(messages)
assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA assert sanitized[0]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
-29
View File
@@ -232,35 +232,6 @@ async def test_composite_empty_hooks_no_ops():
assert hook.finalize_content(ctx, "test") == "test" assert hook.finalize_content(ctx, "test") == "test"
@pytest.mark.asyncio
async def test_composite_supports_legacy_hook_init_without_super():
calls: list[str] = []
class LegacyHook(AgentHook):
def __init__(self, label: str) -> None:
self.label = label
async def before_iteration(self, context: AgentHookContext) -> None:
calls.append(self.label)
hook = CompositeHook([LegacyHook("legacy")])
await hook.before_iteration(_ctx())
assert calls == ["legacy"]
@pytest.mark.asyncio
async def test_composite_can_wrap_another_composite():
calls: list[str] = []
class Inner(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None:
calls.append("inner")
hook = CompositeHook([CompositeHook([Inner()])])
await hook.before_iteration(_ctx())
assert calls == ["inner"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Integration: AgentLoop with extra hooks # Integration: AgentLoop with extra hooks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-403
View File
@@ -1,13 +1,5 @@
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import Session from nanobot.session.manager import Session
@@ -19,12 +11,6 @@ def _mk_loop() -> AgentLoop:
return loop return loop
def _make_full_loop(tmp_path: Path) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop = _mk_loop() loop = _mk_loop()
session = Session(key="test:runtime-only") session = Session(key="test:runtime-only")
@@ -214,392 +200,3 @@ def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
assert session.messages[0]["role"] == "assistant" assert session.messages[0]["role"] == "assistant"
assert session.messages[1]["tool_call_id"] == "call_done" assert session.messages[1]["tool_call_id"] == "call_done"
assert session.messages[2]["tool_call_id"] == "call_pending" assert session.messages[2]["tool_call_id"] == "call_pending"
@pytest.mark.asyncio
async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="persist me")
with pytest.raises(RuntimeError, match="boom"):
await loop._process_message(msg)
loop.sessions.invalidate("feishu:c1")
persisted = loop.sessions.get_or_create("feishu:c1")
assert [m["role"] for m in persisted.messages] == ["user"]
assert persisted.messages[0]["content"] == "persist me"
assert persisted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True
assert persisted.updated_at >= persisted.created_at
@pytest.mark.asyncio
async def test_process_message_does_not_duplicate_early_persisted_user_message(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop._run_agent_loop = AsyncMock(return_value=(
"done",
None,
[
{"role": "system", "content": "system"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "done"},
],
"stop",
False,
)) # type: ignore[method-assign]
result = await loop._process_message(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c2", content="hello")
)
assert result is not None
assert result.content == "done"
session = loop.sessions.get_or_create("feishu:c2")
assert [
{k: v for k, v in m.items() if k in {"role", "content"}}
for m in session.messages
] == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "done"},
]
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
@pytest.mark.asyncio
async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop.provider.chat_with_retry = AsyncMock(return_value=MagicMock()) # unused because _run_agent_loop is stubbed
session = loop.sessions.get_or_create("feishu:c3")
session.add_message("user", "old question")
session.metadata[AgentLoop._PENDING_USER_TURN_KEY] = True
loop.sessions.save(session)
loop._run_agent_loop = AsyncMock(return_value=(
"new answer",
None,
[
{"role": "system", "content": "system"},
{"role": "user", "content": "old question"},
{"role": "assistant", "content": "Error: Task interrupted before a response was generated."},
{"role": "user", "content": "new question"},
{"role": "assistant", "content": "new answer"},
],
"stop",
False,
)) # type: ignore[method-assign]
result = await loop._process_message(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c3", content="new question")
)
assert result is not None
assert result.content == "new answer"
session = loop.sessions.get_or_create("feishu:c3")
assert [
{k: v for k, v in m.items() if k in {"role", "content"}}
for m in session.messages
] == [
{"role": "user", "content": "old question"},
{"role": "assistant", "content": "Error: Task interrupted before a response was generated."},
{"role": "user", "content": "new question"},
{"role": "assistant", "content": "new answer"},
]
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
def _cross_channel_messages(
source_channel: str = "websocket",
source_chat_id: str = "ws-uuid-123",
target_channel: str = "feishu",
target_chat_id: str = "ou_abc123",
) -> list[dict]:
"""Build a message list with a cross-channel message tool call."""
return [
{"role": "user", "content": "send report to feishu"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_x1",
"type": "function",
"function": {
"name": "message",
"arguments": json.dumps({
"content": "Report: audit complete",
"channel": target_channel,
"chat_id": target_chat_id,
}),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_x1",
"name": "message",
"content": f"Message sent to {target_channel}:{target_chat_id}",
},
{"role": "assistant", "content": "Done, sent to feishu."},
]
def test_cross_channel_message_persisted_in_target_session(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
source_key = "websocket:ws-uuid-123"
target_key = "feishu:ou_abc123"
# Pre-create the target session (simulate an existing feishu conversation)
target_session = loop.sessions.get_or_create(target_key)
target_session.add_message("user", "hello from feishu")
loop.sessions.save(target_session)
source_session = loop.sessions.get_or_create(source_key)
msgs = _cross_channel_messages()
loop._save_turn(source_session, msgs, skip=1) # skip user message
# Source session has its own messages
source_session = loop.sessions.get_or_create(source_key)
assert len(source_session.messages) >= 2 # assistant + tool + final
# Target session now has the cross-channel message appended
loop.sessions.invalidate(target_key)
target = loop.sessions.get_or_create(target_key)
cross_msg = [m for m in target.messages if m.get("_cross_channel")]
assert len(cross_msg) == 1
assert cross_msg[0]["content"] == "Report: audit complete"
assert cross_msg[0]["role"] == "assistant"
def test_cross_channel_same_session_not_duplicated(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
key = "feishu:ou_same"
session = loop.sessions.get_or_create(key)
# message tool call targeting the same session — should NOT create a duplicate
msgs = [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_s1",
"type": "function",
"function": {
"name": "message",
"arguments": json.dumps({
"content": "same channel msg",
"channel": "feishu",
"chat_id": "ou_same",
}),
},
}
],
},
{"role": "tool", "tool_call_id": "call_s1", "name": "message", "content": "ok"},
]
loop._save_turn(session, msgs, skip=1)
# No _cross_channel entries should exist
assert all(not m.get("_cross_channel") for m in session.messages)
def test_cross_channel_target_session_not_exist_creates_session(tmp_path: Path) -> None:
"""When the target session does not exist yet, get_or_create will create it
and the cross-channel message should still be persisted."""
loop = _make_full_loop(tmp_path)
source_session = loop.sessions.get_or_create("websocket:ws-xyz")
msgs = _cross_channel_messages(
target_channel="feishu", target_chat_id="ou_nonexistent"
)
loop._save_turn(source_session, msgs, skip=1)
# Target session is now auto-created with the cross-channel message
target = loop.sessions.get_or_create("feishu:ou_nonexistent")
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
assert len(cross_msgs) == 1
assert cross_msgs[0]["content"] == "Report: audit complete"
def test_cross_channel_persists_media_attachments(tmp_path: Path) -> None:
"""When the message tool call includes media, the cross-channel entry
should preserve the media paths so the target session has full context."""
loop = _make_full_loop(tmp_path)
source_key = "websocket:ws-media"
target_key = "telegram:tg_user1"
target_session = loop.sessions.get_or_create(target_key)
target_session.add_message("user", "waiting for report")
loop.sessions.save(target_session)
source_session = loop.sessions.get_or_create(source_key)
msgs = [
{"role": "user", "content": "send chart to telegram"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_m1",
"type": "function",
"function": {
"name": "message",
"arguments": json.dumps({
"content": "Here is the chart",
"channel": "telegram",
"chat_id": "tg_user1",
"media": ["/tmp/chart.png", "/tmp/data.csv"],
}),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_m1",
"name": "message",
"content": "Message sent to telegram:tg_user1",
},
]
loop._save_turn(source_session, msgs, skip=1)
loop.sessions.invalidate(target_key)
target = loop.sessions.get_or_create(target_key)
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
assert len(cross_msgs) == 1
assert cross_msgs[0]["content"] == "Here is the chart"
assert cross_msgs[0]["_media"] == ["/tmp/chart.png", "/tmp/data.csv"]
def test_cross_channel_records_source_session(tmp_path: Path) -> None:
"""Cross-channel entries should include _source_session for traceability."""
loop = _make_full_loop(tmp_path)
source_key = "cron:heartbeat"
target_key = "feishu:ou_trace"
target_session = loop.sessions.get_or_create(target_key)
target_session.add_message("user", "hi")
loop.sessions.save(target_session)
source_session = loop.sessions.get_or_create(source_key)
msgs = _cross_channel_messages(
source_channel="cron", source_chat_id="heartbeat",
target_channel="feishu", target_chat_id="ou_trace",
)
loop._save_turn(source_session, msgs, skip=1)
loop.sessions.invalidate(target_key)
target = loop.sessions.get_or_create(target_key)
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
assert len(cross_msgs) == 1
assert cross_msgs[0]["_source_session"] == "cron:heartbeat"
def test_cross_channel_media_only_no_content(tmp_path: Path) -> None:
"""A message with media but empty content should still be persisted."""
loop = _make_full_loop(tmp_path)
target_key = "discord:ch_img"
target_session = loop.sessions.get_or_create(target_key)
loop.sessions.save(target_session)
source_session = loop.sessions.get_or_create("websocket:ws-img")
msgs = [
{"role": "user", "content": "send image"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_img",
"type": "function",
"function": {
"name": "message",
"arguments": json.dumps({
"content": "",
"channel": "discord",
"chat_id": "ch_img",
"media": ["/tmp/photo.jpg"],
}),
},
}
],
},
{"role": "tool", "tool_call_id": "call_img", "name": "message", "content": "ok"},
]
loop._save_turn(source_session, msgs, skip=1)
loop.sessions.invalidate(target_key)
target = loop.sessions.get_or_create(target_key)
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
assert len(cross_msgs) == 1
assert cross_msgs[0]["_media"] == ["/tmp/photo.jpg"]
def test_cross_channel_non_message_tools_ignored(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
source_session = loop.sessions.get_or_create("websocket:ws-abc")
target_session = loop.sessions.get_or_create("feishu:ou_tgt")
target_session.add_message("user", "hi")
loop.sessions.save(target_session)
msgs = [
{"role": "user", "content": "do stuff"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_e1",
"type": "function",
"function": {
"name": "exec",
"arguments": json.dumps({"command": "echo hi"}),
},
}
],
},
{"role": "tool", "tool_call_id": "call_e1", "name": "exec", "content": "hi"},
]
loop._save_turn(source_session, msgs, skip=1)
# exec tool should NOT produce cross-channel entries
target = loop.sessions.get_or_create("feishu:ou_tgt")
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
assert len(cross_msgs) == 0
def test_cross_channel_get_history_annotates_provenance(tmp_path: Path) -> None:
"""get_history() should prefix cross-channel messages with source info
so the LLM knows where the message came from."""
loop = _make_full_loop(tmp_path)
target_key = "feishu:ou_hist"
target_session = loop.sessions.get_or_create(target_key)
target_session.add_message("user", "hello")
# Simulate a cross-channel entry as _persist_cross_channel_calls would create
target_session.messages.append({
"role": "assistant",
"content": "Daily report ready",
"_cross_channel": True,
"_source_session": "cron:daily-report",
})
loop.sessions.save(target_session)
loop.sessions.invalidate(target_key)
target = loop.sessions.get_or_create(target_key)
history = target.get_history()
# Find the annotated message
annotated = [m for m in history if "cron:daily-report" in m.get("content", "")]
assert len(annotated) == 1
assert annotated[0]["content"] == "[Sent from cron:daily-report] Daily report ready"
# Internal metadata keys should NOT leak into the history output
assert "_cross_channel" not in annotated[0]
assert "_source_session" not in annotated[0]
-44
View File
@@ -1,44 +0,0 @@
"""Tests for MCP connection lifecycle in AgentLoop."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation.max_tokens = 4096
return AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
mcp_servers=mcp_servers or {"test": object()},
)
@pytest.mark.asyncio
async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch: pytest.MonkeyPatch):
loop = _make_loop(tmp_path)
attempts = 0
async def _fake_connect(_servers, _registry):
nonlocal attempts
attempts += 1
return {}
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
await loop._connect_mcp()
await loop._connect_mcp()
assert attempts == 2
assert loop._mcp_connected is False
assert loop._mcp_stacks == {}
+19 -1021
View File
File diff suppressed because it is too large Load Diff
-60
View File
@@ -250,63 +250,3 @@ def test_list_skills_openclaw_metadata_parsed_for_requirements(
assert entries == [ assert entries == [
{"name": "openclaw_skill", "path": str(skill_path), "source": "workspace"}, {"name": "openclaw_skill", "path": str(skill_path), "source": "workspace"},
] ]
def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
_write_skill(ws_skills, "alpha", body="# Alpha")
beta_path = _write_skill(ws_skills, "beta", body="# Beta")
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"})
entries = loader.list_skills(filter_unavailable=False)
assert len(entries) == 1
assert entries[0]["name"] == "beta"
assert entries[0]["path"] == str(beta_path)
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
_write_skill(ws_skills, "alpha", body="# Alpha")
_write_skill(ws_skills, "beta", body="# Beta")
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills=set())
entries = loader.list_skills(filter_unavailable=False)
assert len(entries) == 2
def test_disabled_skills_excluded_from_build_skills_summary(tmp_path: Path) -> None:
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
_write_skill(ws_skills, "alpha", body="# Alpha")
_write_skill(ws_skills, "beta", body="# Beta")
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"})
summary = loader.build_skills_summary()
assert "alpha" not in summary
assert "beta" in summary
def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None:
workspace = tmp_path / "ws"
ws_skills = workspace / "skills"
ws_skills.mkdir(parents=True)
_write_skill(ws_skills, "alpha", metadata_json={"always": True}, body="# Alpha")
_write_skill(ws_skills, "beta", metadata_json={"always": True}, body="# Beta")
builtin = tmp_path / "builtin"
builtin.mkdir()
loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"})
always = loader.get_always_skills()
assert "alpha" not in always
assert "beta" in always
-16
View File
@@ -72,22 +72,6 @@ class TestToolHintKnownTools:
result = _hint([_tc("exec", {"command": cmd})]) result = _hint([_tc("exec", {"command": cmd})])
assert "\u2026/" in result assert "\u2026/" in result
def test_exec_abbreviates_quoted_linux_paths_with_spaces(self):
"""Quoted Unix paths with spaces should still be folded."""
cmd = 'cd "/home/user/My Documents/project" && pytest tests/'
result = _hint([_tc("exec", {"command": cmd})])
assert "\u2026/" in result
assert '"/home/user/My Documents/project"' not in result
assert '"' in result
def test_exec_abbreviates_quoted_windows_paths_with_spaces(self):
"""Quoted Windows paths with spaces should still be folded."""
cmd = 'cd "C:/Program Files/Git/project" && git status'
result = _hint([_tc("exec", {"command": cmd})])
assert "\u2026/" in result
assert '"C:/Program Files/Git/project"' not in result
assert '"' in result
def test_exec_short_command_unchanged(self): def test_exec_short_command_unchanged(self):
result = _hint([_tc("exec", {"command": "npm install typescript"})]) result = _hint([_tc("exec", {"command": "npm install typescript"})])
assert result == "$ npm install typescript" assert result == "$ npm install typescript"
-502
View File
@@ -1,502 +0,0 @@
"""Tests for unified_session feature.
Covers:
- AgentLoop._dispatch() rewrites session_key to "unified:default" when enabled
- Existing session_key_override is respected (not overwritten)
- Feature is off by default (no behavior change for existing users)
- Config schema serialises unified_session as camelCase "unifiedSession"
- onboard-generated config.json contains "unifiedSession" key
- /new command correctly clears the shared session in unified mode
- /new is NOT a priority command (goes through _dispatch, key rewrite applies)
- Context window consolidation is unaffected by unified_session
"""
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.command.builtin import cmd_new, register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.config.schema import AgentDefaults, Config
from nanobot.session.manager import Session, SessionManager
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_loop(tmp_path: Path, unified_session: bool = False) -> AgentLoop:
"""Create a minimal AgentLoop for dispatch-level tests."""
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
with patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr, \
patch("nanobot.agent.loop.Dream"):
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
unified_session=unified_session,
)
return loop
def _make_msg(channel: str = "telegram", chat_id: str = "111",
session_key_override: str | None = None) -> InboundMessage:
return InboundMessage(
channel=channel,
chat_id=chat_id,
sender_id="user1",
content="hello",
session_key_override=session_key_override,
)
# ---------------------------------------------------------------------------
# TestUnifiedSessionDispatch — core behaviour
# ---------------------------------------------------------------------------
class TestUnifiedSessionDispatch:
"""AgentLoop._dispatch() session key rewriting logic."""
@pytest.mark.asyncio
async def test_unified_session_rewrites_key_to_unified_default(self, tmp_path: Path):
"""When unified_session=True, all messages use 'unified:default' as session key."""
loop = _make_loop(tmp_path, unified_session=True)
captured: list[str] = []
async def fake_process(msg, **kwargs):
captured.append(msg.session_key)
return None
loop._process_message = fake_process # type: ignore[method-assign]
msg = _make_msg(channel="telegram", chat_id="111")
await loop._dispatch(msg)
assert captured == ["unified:default"]
@pytest.mark.asyncio
async def test_unified_session_different_channels_share_same_key(self, tmp_path: Path):
"""Messages from different channels all resolve to the same session key."""
loop = _make_loop(tmp_path, unified_session=True)
captured: list[str] = []
async def fake_process(msg, **kwargs):
captured.append(msg.session_key)
return None
loop._process_message = fake_process # type: ignore[method-assign]
await loop._dispatch(_make_msg(channel="telegram", chat_id="111"))
await loop._dispatch(_make_msg(channel="discord", chat_id="222"))
await loop._dispatch(_make_msg(channel="cli", chat_id="direct"))
assert captured == ["unified:default", "unified:default", "unified:default"]
@pytest.mark.asyncio
async def test_unified_session_disabled_preserves_original_key(self, tmp_path: Path):
"""When unified_session=False (default), session key is channel:chat_id as usual."""
loop = _make_loop(tmp_path, unified_session=False)
captured: list[str] = []
async def fake_process(msg, **kwargs):
captured.append(msg.session_key)
return None
loop._process_message = fake_process # type: ignore[method-assign]
msg = _make_msg(channel="telegram", chat_id="999")
await loop._dispatch(msg)
assert captured == ["telegram:999"]
@pytest.mark.asyncio
async def test_unified_session_respects_existing_override(self, tmp_path: Path):
"""If session_key_override is already set (e.g. Telegram thread), it is NOT overwritten."""
loop = _make_loop(tmp_path, unified_session=True)
captured: list[str] = []
async def fake_process(msg, **kwargs):
captured.append(msg.session_key)
return None
loop._process_message = fake_process # type: ignore[method-assign]
msg = _make_msg(channel="telegram", chat_id="111", session_key_override="telegram:thread:42")
await loop._dispatch(msg)
assert captured == ["telegram:thread:42"]
def test_unified_session_default_is_false(self, tmp_path: Path):
"""unified_session defaults to False — no behavior change for existing users."""
loop = _make_loop(tmp_path)
assert loop._unified_session is False
# ---------------------------------------------------------------------------
# TestUnifiedSessionConfig — schema & serialisation
# ---------------------------------------------------------------------------
class TestUnifiedSessionConfig:
"""Config schema and onboard serialisation for unified_session."""
def test_agent_defaults_unified_session_default_is_false(self):
"""AgentDefaults.unified_session defaults to False."""
defaults = AgentDefaults()
assert defaults.unified_session is False
def test_agent_defaults_unified_session_can_be_enabled(self):
"""AgentDefaults.unified_session can be set to True."""
defaults = AgentDefaults(unified_session=True)
assert defaults.unified_session is True
def test_config_serialises_unified_session_as_camel_case(self):
"""model_dump(by_alias=True) outputs 'unifiedSession' (camelCase) for JSON."""
config = Config()
data = config.model_dump(mode="json", by_alias=True)
agents_defaults = data["agents"]["defaults"]
assert "unifiedSession" in agents_defaults
assert agents_defaults["unifiedSession"] is False
def test_config_parses_unified_session_from_camel_case(self):
"""Config can be loaded from JSON with camelCase 'unifiedSession'."""
raw = {"agents": {"defaults": {"unifiedSession": True}}}
config = Config.model_validate(raw)
assert config.agents.defaults.unified_session is True
def test_config_parses_unified_session_from_snake_case(self):
"""Config also accepts snake_case 'unified_session' (populate_by_name=True)."""
raw = {"agents": {"defaults": {"unified_session": True}}}
config = Config.model_validate(raw)
assert config.agents.defaults.unified_session is True
def test_onboard_generated_config_contains_unified_session(self, tmp_path: Path):
"""save_config() writes 'unifiedSession' into config.json (simulates nanobot onboard)."""
from nanobot.config.loader import save_config
config = Config()
config_path = tmp_path / "config.json"
save_config(config, config_path)
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
agents_defaults = data["agents"]["defaults"]
assert "unifiedSession" in agents_defaults, (
"onboard-generated config.json must contain 'unifiedSession' key"
)
assert agents_defaults["unifiedSession"] is False
# ---------------------------------------------------------------------------
# TestCmdNewUnifiedSession — /new command behaviour in unified mode
# ---------------------------------------------------------------------------
class TestCmdNewUnifiedSession:
"""/new command routing and session-clear behaviour in unified mode."""
def test_new_is_not_a_priority_command(self):
"""/new must NOT be in the priority table — it must go through _dispatch()
so the unified session key rewrite applies before cmd_new runs."""
router = CommandRouter()
register_builtin_commands(router)
assert router.is_priority("/new") is False
def test_new_is_an_exact_command(self):
"""/new must be registered as an exact command."""
router = CommandRouter()
register_builtin_commands(router)
assert "/new" in router._exact
@pytest.mark.asyncio
async def test_cmd_new_clears_unified_session(self, tmp_path: Path):
"""cmd_new called with key='unified:default' clears the shared session."""
sessions = SessionManager(tmp_path)
# Pre-populate the shared session with some messages
shared = sessions.get_or_create("unified:default")
shared.add_message("user", "hello from telegram")
shared.add_message("assistant", "hi there")
sessions.save(shared)
assert len(sessions.get_or_create("unified:default").messages) == 2
# _schedule_background is a *sync* method that schedules a coroutine via
# asyncio.create_task(). Mirror that exactly so the coroutine is consumed
# and no RuntimeWarning is emitted.
loop = SimpleNamespace(
sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
)
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
msg = InboundMessage(
channel="telegram", sender_id="user1", chat_id="111", content="/new",
session_key_override="unified:default", # as _dispatch() would set it
)
ctx = CommandContext(msg=msg, session=None, key="unified:default", raw="/new", loop=loop)
result = await cmd_new(ctx)
assert "New session started" in result.content
# Invalidate cache and reload from disk to confirm persistence
sessions.invalidate("unified:default")
reloaded = sessions.get_or_create("unified:default")
assert reloaded.messages == []
@pytest.mark.asyncio
async def test_cmd_new_in_unified_mode_does_not_affect_other_sessions(self, tmp_path: Path):
"""Clearing unified:default must not touch other sessions on disk."""
sessions = SessionManager(tmp_path)
other = sessions.get_or_create("discord:999")
other.add_message("user", "discord message")
sessions.save(other)
shared = sessions.get_or_create("unified:default")
shared.add_message("user", "shared message")
sessions.save(shared)
loop = SimpleNamespace(
sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
)
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
msg = InboundMessage(
channel="telegram", sender_id="user1", chat_id="111", content="/new",
session_key_override="unified:default",
)
ctx = CommandContext(msg=msg, session=None, key="unified:default", raw="/new", loop=loop)
await cmd_new(ctx)
sessions.invalidate("unified:default")
sessions.invalidate("discord:999")
assert sessions.get_or_create("unified:default").messages == []
assert len(sessions.get_or_create("discord:999").messages) == 1
# ---------------------------------------------------------------------------
# TestConsolidationUnaffectedByUnifiedSession — consolidation is key-agnostic
# ---------------------------------------------------------------------------
class TestConsolidationUnaffectedByUnifiedSession:
"""maybe_consolidate_by_tokens() behaviour is identical regardless of session key."""
@pytest.mark.asyncio
async def test_consolidation_skips_empty_session_for_unified_key(self):
"""Empty unified:default session → consolidation exits immediately, archive not called."""
from nanobot.agent.memory import Consolidator, MemoryStore
store = MagicMock(spec=MemoryStore)
mock_provider = MagicMock()
mock_provider.chat_with_retry = AsyncMock(return_value=MagicMock(content="summary"))
# Use spec= so MagicMock doesn't auto-generate AsyncMock for non-async methods,
# which would leave unawaited coroutines and trigger RuntimeWarning.
sessions = MagicMock(spec=SessionManager)
consolidator = 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,
)
consolidator.archive = AsyncMock()
session = Session(key="unified:default")
session.messages = []
await consolidator.maybe_consolidate_by_tokens(session)
consolidator.archive.assert_not_called()
@pytest.mark.asyncio
async def test_consolidation_behaviour_identical_for_any_key(self):
"""archive call count is the same for 'telegram:123' and 'unified:default'
under identical token conditions."""
from nanobot.agent.memory import Consolidator, MemoryStore
archive_calls: dict[str, int] = {}
for key in ("telegram:123", "unified:default"):
store = MagicMock(spec=MemoryStore)
mock_provider = MagicMock()
mock_provider.chat_with_retry = AsyncMock(return_value=MagicMock(content="summary"))
sessions = MagicMock(spec=SessionManager)
consolidator = 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,
)
session = Session(key=key)
session.messages = [] # empty → exits immediately for both keys
consolidator.archive = AsyncMock()
await consolidator.maybe_consolidate_by_tokens(session)
archive_calls[key] = consolidator.archive.call_count
assert archive_calls["telegram:123"] == archive_calls["unified:default"] == 0
@pytest.mark.asyncio
async def test_consolidation_triggers_when_over_budget_unified_key(self):
"""When tokens exceed budget, consolidation attempts to find a boundary —
behaviour is identical to any other session key."""
from nanobot.agent.memory import Consolidator, MemoryStore
store = MagicMock(spec=MemoryStore)
mock_provider = MagicMock()
sessions = MagicMock(spec=SessionManager)
consolidator = 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,
)
session = Session(key="unified:default")
session.messages = [{"role": "user", "content": "msg"}]
# Simulate over-budget: estimated > budget
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(950, "tiktoken"))
# No valid boundary found → returns gracefully without archiving
consolidator.pick_consolidation_boundary = MagicMock(return_value=None)
consolidator.archive = AsyncMock()
await consolidator.maybe_consolidate_by_tokens(session)
# estimate was called (consolidation was attempted)
consolidator.estimate_session_prompt_tokens.assert_called_once_with(session)
# but archive was not called (no valid boundary)
consolidator.archive.assert_not_called()
# ---------------------------------------------------------------------------
# TestStopCommandWithUnifiedSession — /stop command integration
# ---------------------------------------------------------------------------
class TestStopCommandWithUnifiedSession:
"""Verify /stop command works correctly with unified session enabled."""
@pytest.mark.asyncio
async def test_active_tasks_use_effective_key_in_unified_mode(self, tmp_path: Path):
"""When unified_session=True, tasks are stored under UNIFIED_SESSION_KEY."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
loop = _make_loop(tmp_path, unified_session=True)
# Create a message from telegram channel
msg = _make_msg(channel="telegram", chat_id="123456")
# Mock _dispatch to complete immediately
async def fake_dispatch(m):
pass
loop._dispatch = fake_dispatch # type: ignore[method-assign]
# Simulate the task creation flow (from _run loop)
effective_key = UNIFIED_SESSION_KEY if loop._unified_session and not msg.session_key_override else msg.session_key
task = asyncio.create_task(loop._dispatch(msg))
loop._active_tasks.setdefault(effective_key, []).append(task)
# Wait for task to complete
await task
# Verify the task is stored under UNIFIED_SESSION_KEY, not the original channel:chat_id
assert UNIFIED_SESSION_KEY in loop._active_tasks
assert "telegram:123456" not in loop._active_tasks
@pytest.mark.asyncio
async def test_stop_command_finds_task_in_unified_mode(self, tmp_path: Path):
"""cmd_stop can cancel tasks when unified_session=True."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.command.builtin import cmd_stop
loop = _make_loop(tmp_path, unified_session=True)
# Create a long-running task stored under UNIFIED_SESSION_KEY
async def long_running():
await asyncio.sleep(10) # Will be cancelled
task = asyncio.create_task(long_running())
loop._active_tasks[UNIFIED_SESSION_KEY] = [task]
# Create a message that would have session_key=UNIFIED_SESSION_KEY after dispatch
msg = InboundMessage(
channel="telegram",
chat_id="123456",
sender_id="user1",
content="/stop",
session_key_override=UNIFIED_SESSION_KEY, # Simulate post-dispatch state
)
ctx = CommandContext(msg=msg, session=None, key=UNIFIED_SESSION_KEY, raw="/stop", loop=loop)
# Execute /stop
result = await cmd_stop(ctx)
# Verify task was cancelled
assert task.cancelled() or task.done()
assert "Stopped 1 task" in result.content
@pytest.mark.asyncio
async def test_stop_command_cross_channel_in_unified_mode(self, tmp_path: Path):
"""In unified mode, /stop from one channel cancels tasks from another channel."""
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.command.builtin import cmd_stop
loop = _make_loop(tmp_path, unified_session=True)
# Create tasks from different channels, all stored under UNIFIED_SESSION_KEY
async def long_running():
await asyncio.sleep(10)
task1 = asyncio.create_task(long_running())
task2 = asyncio.create_task(long_running())
loop._active_tasks[UNIFIED_SESSION_KEY] = [task1, task2]
# /stop from discord should cancel tasks started from telegram
msg = InboundMessage(
channel="discord",
chat_id="789012",
sender_id="user2",
content="/stop",
session_key_override=UNIFIED_SESSION_KEY,
)
ctx = CommandContext(msg=msg, session=None, key=UNIFIED_SESSION_KEY, raw="/stop", loop=loop)
result = await cmd_stop(ctx)
# Both tasks should be cancelled
assert "Stopped 2 task" in result.content
-232
View File
@@ -1,10 +1,6 @@
import asyncio import asyncio
import zipfile
from io import BytesIO
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest import pytest
# Check optional dingtalk dependencies before running tests # Check optional dingtalk dependencies before running tests
@@ -54,21 +50,6 @@ class _FakeHttp:
return self._next_response() return self._next_response()
class _NetworkErrorHttp:
"""HTTP client stub that raises httpx.TransportError on every request."""
def __init__(self) -> None:
self.calls: list[dict] = []
async def post(self, url: str, json=None, headers=None, **kwargs):
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
raise httpx.ConnectError("Connection refused")
async def get(self, url: str, **kwargs):
self.calls.append({"method": "GET", "url": url})
raise httpx.ConnectError("Connection refused")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None: async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]) config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"])
@@ -240,216 +221,3 @@ async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
assert "messageFiles/download" in channel._http.calls[0]["url"] assert "messageFiles/download" in channel._http.calls[0]["url"]
assert channel._http.calls[0]["json"]["downloadCode"] == "code123" assert channel._http.calls[0]["json"]["downloadCode"] == "code123"
assert channel._http.calls[1]["method"] == "GET" assert channel._http.calls[1]["method"] == "GET"
def test_normalize_upload_payload_zips_html_attachment() -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
data, filename, content_type = channel._normalize_upload_payload(
"report.html",
b"<html><body>Hello</body></html>",
"text/html",
)
assert filename == "report.zip"
assert content_type == "application/zip"
archive = zipfile.ZipFile(BytesIO(data))
assert archive.namelist() == ["report.html"]
assert archive.read("report.html") == b"<html><body>Hello</body></html>"
@pytest.mark.asyncio
async def test_send_media_ref_zips_html_before_upload(tmp_path, monkeypatch) -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
html_path = tmp_path / "report.html"
html_path.write_text("<html><body>Hello</body></html>", encoding="utf-8")
captured: dict[str, object] = {}
async def fake_upload_media(*, token, data, media_type, filename, content_type):
captured.update(
{
"token": token,
"data": data,
"media_type": media_type,
"filename": filename,
"content_type": content_type,
}
)
return "media-123"
async def fake_send_batch_message(token, chat_id, msg_key, msg_param):
captured.update(
{
"sent_token": token,
"chat_id": chat_id,
"msg_key": msg_key,
"msg_param": msg_param,
}
)
return True
monkeypatch.setattr(channel, "_upload_media", fake_upload_media)
monkeypatch.setattr(channel, "_send_batch_message", fake_send_batch_message)
ok = await channel._send_media_ref("token-123", "user-1", str(html_path))
assert ok is True
assert captured["media_type"] == "file"
assert captured["filename"] == "report.zip"
assert captured["content_type"] == "application/zip"
assert captured["msg_key"] == "sampleFile"
assert captured["msg_param"] == {
"mediaId": "media-123",
"fileName": "report.zip",
"fileType": "zip",
}
archive = zipfile.ZipFile(BytesIO(captured["data"]))
assert archive.namelist() == ["report.html"]
# ── Exception handling tests ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_send_batch_message_propagates_transport_error() -> None:
"""Network/transport errors must re-raise so callers can retry."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
channel._http = _NetworkErrorHttp()
with pytest.raises(httpx.ConnectError, match="Connection refused"):
await channel._send_batch_message(
"token",
"user123",
"sampleMarkdown",
{"text": "hello", "title": "Nanobot Reply"},
)
# The POST was attempted exactly once
assert len(channel._http.calls) == 1
assert channel._http.calls[0]["method"] == "POST"
@pytest.mark.asyncio
async def test_send_batch_message_returns_false_on_api_error() -> None:
"""DingTalk API-level errors (non-200 status, errcode != 0) should return False."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
# Non-200 status code → API error → return False
channel._http = _FakeHttp(responses=[_FakeResponse(400, {"errcode": 400})])
result = await channel._send_batch_message(
"token", "user123", "sampleMarkdown", {"text": "hello"}
)
assert result is False
# 200 with non-zero errcode → API error → return False
channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 100})])
result = await channel._send_batch_message(
"token", "user123", "sampleMarkdown", {"text": "hello"}
)
assert result is False
# 200 with errcode=0 → success → return True
channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 0})])
result = await channel._send_batch_message(
"token", "user123", "sampleMarkdown", {"text": "hello"}
)
assert result is True
@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_transport_error() -> None:
"""When the first send fails with a transport error, _send_media_ref must
re-raise immediately instead of trying download+upload+fallback."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
channel._http = _NetworkErrorHttp()
# An image URL triggers the sampleImageMsg path first
with pytest.raises(httpx.ConnectError, match="Connection refused"):
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
# Only one POST should have been attempted — no download/upload/fallback
assert len(channel._http.calls) == 1
assert channel._http.calls[0]["method"] == "POST"
@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_download_transport_error() -> None:
"""When the image URL send returns an API error (False) but the download
for the fallback hits a transport error, it must re-raise rather than
silently returning False."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
# First POST (sampleImageMsg) returns API error → False, then GET (download) raises transport error
class _MixedHttp:
def __init__(self) -> None:
self.calls: list[dict] = []
async def post(self, url, json=None, headers=None, **kwargs):
self.calls.append({"method": "POST", "url": url})
# API-level failure: 200 with errcode != 0
return _FakeResponse(200, {"errcode": 100})
async def get(self, url, **kwargs):
self.calls.append({"method": "GET", "url": url})
raise httpx.ConnectError("Connection refused")
channel._http = _MixedHttp()
with pytest.raises(httpx.ConnectError, match="Connection refused"):
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
# Should have attempted POST (image URL) and GET (download), but NOT upload
assert len(channel._http.calls) == 2
assert channel._http.calls[0]["method"] == "POST"
assert channel._http.calls[1]["method"] == "GET"
@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_upload_transport_error() -> None:
"""When download succeeds but upload hits a transport error, must re-raise."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
image_bytes = b"\xff\xd8\xff\xe0" + b"\x00" * 100 # minimal JPEG-ish data
class _UploadFailsHttp:
def __init__(self) -> None:
self.calls: list[dict] = []
async def post(self, url, json=None, headers=None, files=None, **kwargs):
self.calls.append({"method": "POST", "url": url})
# If it's the upload endpoint, raise transport error
if "media/upload" in url:
raise httpx.ConnectError("Connection refused")
# Otherwise (sampleImageMsg), return API error to trigger fallback
return _FakeResponse(200, {"errcode": 100})
async def get(self, url, **kwargs):
self.calls.append({"method": "GET", "url": url})
resp = _FakeResponse(200)
resp.content = image_bytes
resp.headers = {"content-type": "image/jpeg"}
return resp
channel._http = _UploadFailsHttp()
with pytest.raises(httpx.ConnectError, match="Connection refused"):
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
# POST (image URL), GET (download), POST (upload) attempted — no further sends
methods = [c["method"] for c in channel._http.calls]
assert methods == ["POST", "GET", "POST"]
+10 -300
View File
@@ -5,17 +5,11 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
discord = pytest.importorskip("discord") discord = pytest.importorskip("discord")
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.discord import ( from nanobot.channels.discord import DiscordBotClient, DiscordChannel, DiscordConfig
MAX_MESSAGE_LEN,
DiscordBotClient,
DiscordChannel,
DiscordConfig,
)
from nanobot.command.builtin import build_help_text from nanobot.command.builtin import build_help_text
@@ -24,11 +18,9 @@ class _FakeDiscordClient:
instances: list["_FakeDiscordClient"] = [] instances: list["_FakeDiscordClient"] = []
start_error: Exception | None = None start_error: Exception | None = None
def __init__(self, owner, *, intents, proxy=None, proxy_auth=None) -> None: def __init__(self, owner, *, intents) -> None:
self.owner = owner self.owner = owner
self.intents = intents self.intents = intents
self.proxy = proxy
self.proxy_auth = proxy_auth
self.closed = False self.closed = False
self.ready = True self.ready = True
self.channels: dict[int, object] = {} self.channels: dict[int, object] = {}
@@ -61,9 +53,7 @@ class _FakeDiscordClient:
class _FakeAttachment: class _FakeAttachment:
# Attachment double that can simulate successful or failing save() calls. # Attachment double that can simulate successful or failing save() calls.
def __init__( def __init__(self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False) -> None:
self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False
) -> None:
self.id = attachment_id self.id = attachment_id
self.filename = filename self.filename = filename
self.size = size self.size = size
@@ -81,25 +71,11 @@ class _FakePartialMessage:
self.id = message_id self.id = message_id
class _FakeSentMessage:
# Sent-message double supporting edit() for streaming tests.
def __init__(self, channel, content: str) -> None:
self.channel = channel
self.content = content
self.edits: list[dict] = []
async def edit(self, **kwargs) -> None:
self.edits.append(dict(kwargs))
if "content" in kwargs:
self.content = kwargs["content"]
class _FakeChannel: class _FakeChannel:
# Channel double that records outbound payloads and typing activity. # Channel double that records outbound payloads and typing activity.
def __init__(self, channel_id: int = 123) -> None: def __init__(self, channel_id: int = 123) -> None:
self.id = channel_id self.id = channel_id
self.sent_payloads: list[dict] = [] self.sent_payloads: list[dict] = []
self.sent_messages: list[_FakeSentMessage] = []
self.trigger_typing_calls = 0 self.trigger_typing_calls = 0
self.typing_enter_hook = None self.typing_enter_hook = None
@@ -109,9 +85,6 @@ class _FakeChannel:
payload["file_name"] = payload["file"].filename payload["file_name"] = payload["file"].filename
del payload["file"] del payload["file"]
self.sent_payloads.append(payload) self.sent_payloads.append(payload)
message = _FakeSentMessage(self, payload.get("content", ""))
self.sent_messages.append(message)
return message
def get_partial_message(self, message_id: int) -> _FakePartialMessage: def get_partial_message(self, message_id: int) -> _FakePartialMessage:
return _FakePartialMessage(message_id) return _FakePartialMessage(message_id)
@@ -221,7 +194,7 @@ async def test_start_handles_client_construction_failure(monkeypatch) -> None:
MessageBus(), MessageBus(),
) )
def _boom(owner, *, intents, proxy=None, proxy_auth=None): def _boom(owner, *, intents):
raise RuntimeError("bad client") raise RuntimeError("bad client")
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom) monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
@@ -454,60 +427,6 @@ async def test_send_fetches_channel_when_not_cached() -> None:
assert target.sent_payloads == [{"content": "hello"}] assert target.sent_payloads == [{"content": "hello"}]
def test_supports_streaming_enabled_by_default() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
assert channel.supports_streaming is True
@pytest.mark.asyncio
async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = _FakeDiscordClient(owner, intents=None)
owner._client = client
owner._running = True
target = _FakeChannel(channel_id=123)
client.channels[123] = target
times = iter([1.0, 3.0, 5.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
await owner.send_delta("123", "hel", {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "lo", {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
assert target.sent_payloads[0] == {"content": "hel"}
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}]
assert owner._stream_bufs == {}
@pytest.mark.asyncio
async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None:
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = _FakeDiscordClient(owner, intents=None)
owner._client = client
owner._running = True
target = _FakeChannel(channel_id=123)
client.channels[123] = target
prefix = "a" * (MAX_MESSAGE_LEN - 100)
suffix = "b" * 150
full_text = prefix + suffix
chunks = DiscordBotClient._build_chunks(full_text, [], False)
assert len(chunks) == 2
times = iter([1.0, 3.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
await owner.send_delta("123", prefix, {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", suffix, {"_stream_delta": True, "_stream_id": "s1"})
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}]
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}]
assert owner._stream_bufs == {}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_slash_new_forwards_when_user_is_allowlisted() -> None: async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus()) channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus())
@@ -524,7 +443,9 @@ async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
assert new_cmd is not None assert new_cmd is not None
await new_cmd.callback(interaction) await new_cmd.callback(interaction)
assert interaction.response.messages == [{"content": "Processing /new...", "ephemeral": True}] assert interaction.response.messages == [
{"content": "Processing /new...", "ephemeral": True}
]
assert len(handled) == 1 assert len(handled) == 1
assert handled[0]["content"] == "/new" assert handled[0]["content"] == "/new"
assert handled[0]["sender_id"] == "123" assert handled[0]["sender_id"] == "123"
@@ -598,7 +519,9 @@ async def test_slash_help_returns_ephemeral_help_text() -> None:
assert help_cmd is not None assert help_cmd is not None
await help_cmd.callback(interaction) await help_cmd.callback(interaction)
assert interaction.response.messages == [{"content": build_help_text(), "ephemeral": True}] assert interaction.response.messages == [
{"content": build_help_text(), "ephemeral": True}
]
assert handled == [] assert handled == []
@@ -733,13 +656,11 @@ async def test_start_typing_uses_typing_context_when_trigger_typing_missing() ->
def typing(self): def typing(self):
async def _waiter(): async def _waiter():
await release.wait() await release.wait()
# Hold the loop so task remains active until explicitly stopped. # Hold the loop so task remains active until explicitly stopped.
class _Ctx(_TypingCtx): class _Ctx(_TypingCtx):
async def __aenter__(self): async def __aenter__(self):
await super().__aenter__() await super().__aenter__()
await _waiter() await _waiter()
return _Ctx() return _Ctx()
typing_channel = _NoTriggerChannel(channel_id=123) typing_channel = _NoTriggerChannel(channel_id=123)
@@ -753,214 +674,3 @@ async def test_start_typing_uses_typing_context_when_trigger_typing_missing() ->
await asyncio.sleep(0) await asyncio.sleep(0)
assert channel._typing_tasks == {} assert channel._typing_tasks == {}
def test_config_accepts_proxy_fields() -> None:
config = DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
proxy_username="user",
proxy_password="pass",
)
assert config.proxy == "http://127.0.0.1:7890"
assert config.proxy_username == "user"
assert config.proxy_password == "pass"
def test_config_proxy_defaults_to_none() -> None:
config = DiscordConfig(enabled=True, token="token", allow_from=["*"])
assert config.proxy is None
assert config.proxy_username is None
assert config.proxy_password is None
@pytest.mark.asyncio
async def test_start_passes_proxy_to_client(monkeypatch) -> None:
_FakeDiscordClient.instances.clear()
channel = DiscordChannel(
DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
await channel.start()
assert channel.is_running is False
assert len(_FakeDiscordClient.instances) == 1
assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890"
assert _FakeDiscordClient.instances[0].proxy_auth is None
@pytest.mark.asyncio
async def test_start_passes_proxy_auth_when_credentials_provided(monkeypatch) -> None:
aiohttp = pytest.importorskip("aiohttp")
_FakeDiscordClient.instances.clear()
channel = DiscordChannel(
DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
proxy_username="user",
proxy_password="pass",
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
await channel.start()
assert channel.is_running is False
assert len(_FakeDiscordClient.instances) == 1
assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890"
assert _FakeDiscordClient.instances[0].proxy_auth is not None
assert isinstance(_FakeDiscordClient.instances[0].proxy_auth, aiohttp.BasicAuth)
assert _FakeDiscordClient.instances[0].proxy_auth.login == "user"
assert _FakeDiscordClient.instances[0].proxy_auth.password == "pass"
@pytest.mark.asyncio
async def test_start_no_proxy_auth_when_only_username(monkeypatch) -> None:
_FakeDiscordClient.instances.clear()
channel = DiscordChannel(
DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
proxy_username="user",
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
await channel.start()
assert channel.is_running is False
assert _FakeDiscordClient.instances[0].proxy_auth is None
@pytest.mark.asyncio
async def test_start_no_proxy_auth_when_only_password(monkeypatch) -> None:
_FakeDiscordClient.instances.clear()
channel = DiscordChannel(
DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
proxy_password="pass",
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
await channel.start()
assert channel.is_running is False
assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890"
assert _FakeDiscordClient.instances[0].proxy_auth is None
# ---------------------------------------------------------------------------
# Tests for the send() exception propagation fix
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_re_raises_network_error() -> None:
"""Network errors during send must propagate so ChannelManager can retry."""
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = _FakeDiscordClient(channel, intents=None)
channel._client = client
channel._running = True
async def _failing_send_outbound(msg: OutboundMessage) -> None:
raise ConnectionError("network unreachable")
client.send_outbound = _failing_send_outbound # type: ignore[method-assign]
with pytest.raises(ConnectionError, match="network unreachable"):
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
@pytest.mark.asyncio
async def test_send_re_raises_generic_exception() -> None:
"""Any exception from send_outbound must propagate, not be swallowed."""
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = _FakeDiscordClient(channel, intents=None)
channel._client = client
channel._running = True
async def _failing_send_outbound(msg: OutboundMessage) -> None:
raise RuntimeError("discord API failure")
client.send_outbound = _failing_send_outbound # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="discord API failure"):
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
@pytest.mark.asyncio
async def test_send_still_stops_typing_on_error() -> None:
"""Typing cleanup must still run in the finally block even when send raises."""
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = _FakeDiscordClient(channel, intents=None)
channel._client = client
channel._running = True
# Start a typing task so we can verify it gets cleaned up
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 asyncio.wait_for(start.wait(), timeout=1.0)
async def _failing_send_outbound(msg: OutboundMessage) -> None:
raise ConnectionError("timeout")
client.send_outbound = _failing_send_outbound # type: ignore[method-assign]
with pytest.raises(ConnectionError, match="timeout"):
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
release.set()
await asyncio.sleep(0)
# Typing should have been cleaned up by the finally block
assert channel._typing_tasks == {}
@pytest.mark.asyncio
async def test_send_succeeds_normally() -> None:
"""Successful sends should work without raising."""
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = _FakeDiscordClient(channel, intents=None)
channel._client = client
channel._running = True
sent_messages: list[OutboundMessage] = []
async def _capture_send_outbound(msg: OutboundMessage) -> None:
sent_messages.append(msg)
client.send_outbound = _capture_send_outbound # type: ignore[method-assign]
msg = OutboundMessage(channel="discord", chat_id="123", content="hello world")
await channel.send(msg)
assert len(sent_messages) == 1
assert sent_messages[0].content == "hello world"
assert sent_messages[0].chat_id == "123"
-221
View File
@@ -1,7 +1,6 @@
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -15,8 +14,6 @@ except ImportError:
if not QQ_AVAILABLE: if not QQ_AVAILABLE:
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True) pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
import aiohttp
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.qq import QQChannel, QQConfig from nanobot.channels.qq import QQChannel, QQConfig
@@ -173,221 +170,3 @@ async def test_read_media_bytes_missing_file() -> None:
data, filename = await channel._read_media_bytes("/nonexistent/path/image.png") data, filename = await channel._read_media_bytes("/nonexistent/path/image.png")
assert data is None assert data is None
assert filename is None assert filename is None
# -------------------------------------------------------
# Tests for _send_media exception handling
# -------------------------------------------------------
def _make_channel_with_local_file(suffix: str = ".png", content: bytes = b"\x89PNG\r\n"):
"""Create a QQChannel with a fake client and a temp file for media."""
channel = QQChannel(
QQConfig(app_id="app", secret="secret", allow_from=["*"]),
MessageBus(),
)
channel._client = _FakeClient()
channel._chat_type_cache["user1"] = "c2c"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp.write(content)
tmp.close()
return channel, tmp.name
@pytest.mark.asyncio
async def test_send_media_network_error_propagates() -> None:
"""aiohttp.ClientError (network/transport) should re-raise, not return False."""
channel, tmp_path = _make_channel_with_local_file()
# Make the base64 upload raise a network error
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=aiohttp.ServerDisconnectedError("connection lost"),
)
with pytest.raises(aiohttp.ServerDisconnectedError):
await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
@pytest.mark.asyncio
async def test_send_media_client_connector_error_propagates() -> None:
"""aiohttp.ClientConnectorError (DNS/connection refused) should re-raise."""
channel, tmp_path = _make_channel_with_local_file()
from aiohttp.client_reqrep import ConnectionKey
conn_key = ConnectionKey("api.qq.com", 443, True, None, None, None, None)
connector_error = aiohttp.ClientConnectorError(
connection_key=conn_key,
os_error=OSError("Connection refused"),
)
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=connector_error,
)
with pytest.raises(aiohttp.ClientConnectorError):
await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
@pytest.mark.asyncio
async def test_send_media_oserror_propagates() -> None:
"""OSError (low-level I/O) should re-raise for retry."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=OSError("Network is unreachable"),
)
with pytest.raises(OSError):
await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
@pytest.mark.asyncio
async def test_send_media_api_error_returns_false() -> None:
"""API-level errors (botpy RuntimeError subclasses) should return False, not raise."""
channel, tmp_path = _make_channel_with_local_file()
# Simulate a botpy API error (e.g. ServerError is a RuntimeError subclass)
from botpy.errors import ServerError
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=ServerError("internal server error"),
)
result = await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
assert result is False
@pytest.mark.asyncio
async def test_send_media_generic_runtime_error_returns_false() -> None:
"""Generic RuntimeError (not network) should return False."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=RuntimeError("some API error"),
)
result = await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
assert result is False
@pytest.mark.asyncio
async def test_send_media_value_error_returns_false() -> None:
"""ValueError (bad API response data) should return False."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=ValueError("bad response data"),
)
result = await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
assert result is False
@pytest.mark.asyncio
async def test_send_media_timeout_error_propagates() -> None:
"""asyncio.TimeoutError inherits from Exception but not ClientError/OSError.
However, aiohttp.ServerTimeoutError IS a ClientError subclass, so that propagates.
For a plain TimeoutError (which is also OSError in Python 3.11+), it should propagate."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=aiohttp.ServerTimeoutError("request timed out"),
)
with pytest.raises(aiohttp.ServerTimeoutError):
await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
@pytest.mark.asyncio
async def test_send_fallback_text_on_api_error() -> None:
"""When _send_media returns False (API error), send() should emit fallback text."""
channel, tmp_path = _make_channel_with_local_file()
from botpy.errors import ServerError
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=ServerError("internal server error"),
)
await channel.send(
OutboundMessage(
channel="qq",
chat_id="user1",
content="",
media=[tmp_path],
metadata={"message_id": "msg1"},
)
)
# Should have sent a fallback text message
assert len(channel._client.api.c2c_calls) == 1
fallback_content = channel._client.api.c2c_calls[0]["content"]
assert "Attachment send failed" in fallback_content
@pytest.mark.asyncio
async def test_send_propagates_network_error_no_fallback() -> None:
"""When _send_media raises a network error, send() should NOT silently fallback."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=aiohttp.ServerDisconnectedError("connection lost"),
)
with pytest.raises(aiohttp.ServerDisconnectedError):
await channel.send(
OutboundMessage(
channel="qq",
chat_id="user1",
content="hello",
media=[tmp_path],
metadata={"message_id": "msg1"},
)
)
# No fallback text should have been sent
assert len(channel._client.api.c2c_calls) == 0
-281
View File
@@ -135,7 +135,6 @@ def _make_telegram_update(
entities=None, entities=None,
caption_entities=None, caption_entities=None,
reply_to_message=None, reply_to_message=None,
location=None,
): ):
user = SimpleNamespace(id=12345, username="alice", first_name="Alice") user = SimpleNamespace(id=12345, username="alice", first_name="Alice")
message = SimpleNamespace( message = SimpleNamespace(
@@ -150,7 +149,6 @@ def _make_telegram_update(
voice=None, voice=None,
audio=None, audio=None,
document=None, document=None,
location=location,
media_group_id=None, media_group_id=None,
message_thread_id=None, message_thread_id=None,
message_id=1, message_id=1,
@@ -387,84 +385,6 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
assert "123" not in channel._stream_bufs assert "123" not in channel._stream_bufs
@pytest.mark.asyncio
async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> None:
"""TimedOut during HTML edit should propagate, never fall back to plain text."""
from telegram.error import TimedOut
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
# _call_with_retry retries TimedOut up to 3 times, so the mock will be called
# multiple times but all calls must be with parse_mode="HTML" (no plain fallback).
channel._app.bot.edit_message_text = AsyncMock(side_effect=TimedOut("network timeout"))
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(TimedOut, match="network timeout"):
await channel.send_delta("123", "", {"_stream_end": True})
# Every call to edit_message_text must have used parse_mode="HTML" —
# no plain-text fallback call should have been made.
for call in channel._app.bot.edit_message_text.call_args_list:
assert call.kwargs.get("parse_mode") == "HTML"
# Buffer should still be present (not cleaned up on error)
assert "123" in channel._stream_bufs
@pytest.mark.asyncio
async def test_send_delta_stream_end_does_not_fallback_on_network_error() -> None:
"""NetworkError during HTML edit should propagate, never fall back to plain text."""
from telegram.error import NetworkError
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock(side_effect=NetworkError("connection reset"))
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
with pytest.raises(NetworkError, match="connection reset"):
await channel.send_delta("123", "", {"_stream_end": True})
# Every call to edit_message_text must have used parse_mode="HTML" —
# no plain-text fallback call should have been made.
for call in channel._app.bot.edit_message_text.call_args_list:
assert call.kwargs.get("parse_mode") == "HTML"
# Buffer should still be present (not cleaned up on error)
assert "123" in channel._stream_bufs
@pytest.mark.asyncio
async def test_send_delta_stream_end_falls_back_on_bad_request() -> None:
"""BadRequest (HTML parse error) should still trigger plain-text fallback."""
from telegram.error import BadRequest
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
# First call (HTML) raises BadRequest, second call (plain) succeeds
channel._app.bot.edit_message_text = AsyncMock(
side_effect=[BadRequest("Can't parse entities"), None]
)
channel._stream_bufs["123"] = _StreamBuf(text="hello <bad>", message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True})
# edit_message_text should have been called twice: once for HTML, once for plain fallback
assert channel._app.bot.edit_message_text.call_count == 2
# Second call should not use parse_mode="HTML"
second_call_kwargs = channel._app.bot.edit_message_text.call_args_list[1].kwargs
assert "parse_mode" not in second_call_kwargs or second_call_kwargs.get("parse_mode") is None
# Buffer should be cleaned up on success
assert "123" not in channel._stream_bufs
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delta_stream_end_splits_oversized_reply() -> None: async def test_send_delta_stream_end_splits_oversized_reply() -> None:
"""Final streamed reply exceeding Telegram limit is split into chunks.""" """Final streamed reply exceeding Telegram limit is split into chunks."""
@@ -1192,204 +1112,3 @@ async def test_on_help_includes_restart_command() -> None:
assert "/dream" in help_text assert "/dream" in help_text
assert "/dream-log" in help_text assert "/dream-log" in help_text
assert "/dream-restore" in help_text assert "/dream-restore" in help_text
@pytest.mark.asyncio
async def test_on_message_location_content() -> None:
"""Location messages are forwarded as [location: lat, lon] content."""
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
handled = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle
channel._start_typing = lambda _chat_id: None
location = SimpleNamespace(latitude=48.8566, longitude=2.3522)
update = _make_telegram_update(location=location)
await channel._on_message(update, None)
assert len(handled) == 1
assert handled[0]["content"] == "[location: 48.8566, 2.3522]"
@pytest.mark.asyncio
async def test_on_message_location_with_text() -> None:
"""Location messages with accompanying text include both in content."""
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
handled = []
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle
channel._start_typing = lambda _chat_id: None
location = SimpleNamespace(latitude=51.5074, longitude=-0.1278)
update = _make_telegram_update(text="meet me here", location=location)
await channel._on_message(update, None)
assert len(handled) == 1
assert "meet me here" in handled[0]["content"]
assert "[location: 51.5074, -0.1278]" in handled[0]["content"]
# ---------------------------------------------------------------------------
# Tests for retry amplification fix (issue #3050)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_text_does_not_fallback_on_network_timeout() -> None:
"""TimedOut should propagate immediately, NOT trigger plain-text fallback.
Before the fix, _send_text caught ALL exceptions (including TimedOut)
and retried as plain text, doubling connection demand during pool
exhaustion see issue #3050.
"""
from telegram.error import TimedOut
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
call_count = 0
async def always_timeout(**kwargs):
nonlocal call_count
call_count += 1
raise TimedOut()
channel._app.bot.send_message = always_timeout
import nanobot.channels.telegram as tg_mod
orig_delay = tg_mod._SEND_RETRY_BASE_DELAY
tg_mod._SEND_RETRY_BASE_DELAY = 0.01
try:
with pytest.raises(TimedOut):
await channel._send_text(123, "hello", None, {})
finally:
tg_mod._SEND_RETRY_BASE_DELAY = orig_delay
# With the fix: only _call_with_retry's 3 HTML attempts (no plain fallback).
# Before the fix: 3 HTML + 3 plain = 6 attempts.
assert call_count == 3, (
f"Expected 3 calls (HTML retries only), got {call_count} "
"(plain-text fallback should not trigger on TimedOut)"
)
@pytest.mark.asyncio
async def test_send_text_does_not_fallback_on_network_error() -> None:
"""NetworkError should propagate immediately, NOT trigger plain-text fallback."""
from telegram.error import NetworkError
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
call_count = 0
async def always_network_error(**kwargs):
nonlocal call_count
call_count += 1
raise NetworkError("Connection reset")
channel._app.bot.send_message = always_network_error
import nanobot.channels.telegram as tg_mod
orig_delay = tg_mod._SEND_RETRY_BASE_DELAY
tg_mod._SEND_RETRY_BASE_DELAY = 0.01
try:
with pytest.raises(NetworkError):
await channel._send_text(123, "hello", None, {})
finally:
tg_mod._SEND_RETRY_BASE_DELAY = orig_delay
# _call_with_retry does NOT retry NetworkError (only TimedOut/RetryAfter),
# so it raises after 1 attempt. The fix prevents plain-text fallback.
# Before the fix: 1 HTML + 1 plain = 2. After the fix: 1 HTML only.
assert call_count == 1, (
f"Expected 1 call (HTML only, no plain fallback), got {call_count}"
)
@pytest.mark.asyncio
async def test_send_text_falls_back_on_bad_request() -> None:
"""BadRequest (HTML parse error) should still trigger plain-text fallback."""
from telegram.error import BadRequest
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
original_send = channel._app.bot.send_message
html_call_count = 0
async def html_fails(**kwargs):
nonlocal html_call_count
if kwargs.get("parse_mode") == "HTML":
html_call_count += 1
raise BadRequest("Can't parse entities")
return await original_send(**kwargs)
channel._app.bot.send_message = html_fails
import nanobot.channels.telegram as tg_mod
orig_delay = tg_mod._SEND_RETRY_BASE_DELAY
tg_mod._SEND_RETRY_BASE_DELAY = 0.01
try:
await channel._send_text(123, "hello **world**", None, {})
finally:
tg_mod._SEND_RETRY_BASE_DELAY = orig_delay
# HTML attempt failed with BadRequest → fallback to plain text succeeds.
assert html_call_count == 1, f"Expected 1 HTML attempt, got {html_call_count}"
assert len(channel._app.bot.sent_messages) == 1
# Plain text send should NOT have parse_mode
assert channel._app.bot.sent_messages[0].get("parse_mode") is None
@pytest.mark.asyncio
async def test_send_text_bad_request_plain_fallback_exhausted() -> None:
"""When both HTML and plain-text fallback fail with BadRequest, the error propagates."""
from telegram.error import BadRequest
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
call_count = 0
async def always_bad_request(**kwargs):
nonlocal call_count
call_count += 1
raise BadRequest("Bad request")
channel._app.bot.send_message = always_bad_request
import nanobot.channels.telegram as tg_mod
orig_delay = tg_mod._SEND_RETRY_BASE_DELAY
tg_mod._SEND_RETRY_BASE_DELAY = 0.01
try:
with pytest.raises(BadRequest):
await channel._send_text(123, "hello", None, {})
finally:
tg_mod._SEND_RETRY_BASE_DELAY = orig_delay
# _call_with_retry does NOT retry BadRequest (only TimedOut/RetryAfter),
# so HTML fails after 1 attempt → fallback to plain also fails after 1 attempt.
# Before the fix: 2 total. After the fix: still 2 (BadRequest SHOULD fallback).
assert call_count == 2, f"Expected 2 calls (1 HTML + 1 plain), got {call_count}"
@@ -8,7 +8,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
-182
View File
@@ -1003,185 +1003,3 @@ async def test_download_media_item_non_image_requires_aes_key_even_with_full_url
assert saved_path is None assert saved_path is None
channel._client.get.assert_not_awaited() channel._client.get.assert_not_awaited()
# ---------------------------------------------------------------------------
# Tests for media-send error classification (network vs non-network errors)
# ---------------------------------------------------------------------------
def _make_outbound_msg(chat_id: str = "wx-user", content: str = "", media: list | None = None):
"""Build a minimal OutboundMessage-like object for send() tests."""
from nanobot.bus.events import OutboundMessage
return OutboundMessage(
channel="weixin",
chat_id=chat_id,
content=content,
media=media or [],
metadata={},
)
@pytest.mark.asyncio
async def test_send_media_timeout_error_propagates_without_text_fallback() -> None:
"""httpx.TimeoutException during media send must re-raise immediately,
NOT fall back to _send_text (which would also fail during network issues)."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-1"
channel._send_media_file = AsyncMock(side_effect=httpx.TimeoutException("timed out"))
channel._send_text = AsyncMock()
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])
with pytest.raises(httpx.TimeoutException, match="timed out"):
await channel.send(msg)
# _send_text must NOT have been called as a fallback
channel._send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_media_transport_error_propagates_without_text_fallback() -> None:
"""httpx.TransportError during media send must re-raise immediately."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-1"
channel._send_media_file = AsyncMock(
side_effect=httpx.TransportError("connection reset")
)
channel._send_text = AsyncMock()
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])
with pytest.raises(httpx.TransportError, match="connection reset"):
await channel.send(msg)
channel._send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_media_5xx_http_status_error_propagates_without_text_fallback() -> None:
"""httpx.HTTPStatusError with a 5xx status must re-raise immediately."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-1"
fake_response = httpx.Response(
status_code=503,
request=httpx.Request("POST", "https://example.test/upload"),
)
channel._send_media_file = AsyncMock(
side_effect=httpx.HTTPStatusError(
"Service Unavailable", request=fake_response.request, response=fake_response
)
)
channel._send_text = AsyncMock()
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])
with pytest.raises(httpx.HTTPStatusError, match="Service Unavailable"):
await channel.send(msg)
channel._send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_media_4xx_http_status_error_falls_back_to_text() -> None:
"""httpx.HTTPStatusError with a 4xx status should fall back to text, not re-raise."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-1"
fake_response = httpx.Response(
status_code=400,
request=httpx.Request("POST", "https://example.test/upload"),
)
channel._send_media_file = AsyncMock(
side_effect=httpx.HTTPStatusError(
"Bad Request", request=fake_response.request, response=fake_response
)
)
channel._send_text = AsyncMock()
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])
# Should NOT raise — 4xx is a client error, non-retryable
await channel.send(msg)
# _send_text should have been called with the fallback message
channel._send_text.assert_awaited_once_with(
"wx-user", "[Failed to send: photo.jpg]", "ctx-1"
)
@pytest.mark.asyncio
async def test_send_media_file_not_found_falls_back_to_text() -> None:
"""FileNotFoundError (a non-network error) should fall back to text."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-1"
channel._send_media_file = AsyncMock(
side_effect=FileNotFoundError("Media file not found: /tmp/missing.jpg")
)
channel._send_text = AsyncMock()
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/missing.jpg"])
# Should NOT raise
await channel.send(msg)
channel._send_text.assert_awaited_once_with(
"wx-user", "[Failed to send: missing.jpg]", "ctx-1"
)
@pytest.mark.asyncio
async def test_send_media_value_error_falls_back_to_text() -> None:
"""ValueError (e.g. unsupported format) should fall back to text."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-1"
channel._send_media_file = AsyncMock(
side_effect=ValueError("Unsupported media format")
)
channel._send_text = AsyncMock()
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/file.xyz"])
# Should NOT raise
await channel.send(msg)
channel._send_text.assert_awaited_once_with(
"wx-user", "[Failed to send: file.xyz]", "ctx-1"
)
@pytest.mark.asyncio
async def test_send_media_network_error_does_not_double_api_calls() -> None:
"""During network issues, media send should make exactly 1 API call attempt,
not 2 (media + text fallback). Verify total call count."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-1"
channel._send_media_file = AsyncMock(
side_effect=httpx.ConnectError("connection refused")
)
channel._send_text = AsyncMock()
msg = _make_outbound_msg(chat_id="wx-user", content="hello", media=["/tmp/img.png"])
with pytest.raises(httpx.ConnectError):
await channel.send(msg)
# _send_media_file called once, _send_text never called
channel._send_media_file.assert_awaited_once()
channel._send_text.assert_not_awaited()
+2 -2
View File
@@ -148,7 +148,7 @@ class TestRestartCommand:
assert response is not None assert response is not None
assert "Model: test-model" in response.content assert "Model: test-model" in response.content
assert "Tokens: 0 in / 0 out" in response.content assert "Tokens: 0 in / 0 out" in response.content
assert "Context: 20k/65k (31%)" in response.content assert "Context: 20k/64k (31%)" in response.content
assert "Session: 3 messages" in response.content assert "Session: 3 messages" in response.content
assert "Uptime: 2m 5s" in response.content assert "Uptime: 2m 5s" in response.content
assert response.metadata == {"render_as": "text"} assert response.metadata == {"render_as": "text"}
@@ -186,7 +186,7 @@ class TestRestartCommand:
assert response is not None assert response is not None
assert "Tokens: 1200 in / 34 out" in response.content assert "Tokens: 1200 in / 34 out" in response.content
assert "Context: 1k/65k (1%)" in response.content assert "Context: 1k/64k (1%)" in response.content
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_direct_preserves_render_metadata(self): async def test_process_direct_preserves_render_metadata(self):
-408
View File
@@ -1,6 +1,5 @@
import asyncio import asyncio
import json import json
import time
import pytest import pytest
@@ -115,41 +114,6 @@ async def test_run_history_persisted_to_disk(tmp_path) -> None:
assert loaded.state.run_history[0].status == "ok" assert loaded.state.run_history[0].status == "ok"
@pytest.mark.asyncio
async def test_run_job_disabled_does_not_flip_running_state(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
job = service.add_job(
name="disabled",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
service.enable_job(job.id, enabled=False)
result = await service.run_job(job.id)
assert result is False
assert service._running is False
@pytest.mark.asyncio
async def test_run_job_preserves_running_service_state(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
service._running = True
job = service.add_job(
name="manual",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
result = await service.run_job(job.id, force=True)
assert result is True
assert service._running is True
service.stop()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_running_service_honors_external_disable(tmp_path) -> None: async def test_running_service_honors_external_disable(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json" store_path = tmp_path / "cron" / "jobs.json"
@@ -192,375 +156,3 @@ def test_remove_job_refuses_system_jobs(tmp_path) -> None:
assert result == "protected" assert result == "protected"
assert service.get_job("dream") is not None assert service.get_job("dream") is not None
@pytest.mark.asyncio
async def test_start_server_not_jobs(tmp_path):
store_path = tmp_path / "cron" / "jobs.json"
called = []
async def on_job(job):
called.append(job.name)
service = CronService(store_path, on_job=on_job, max_sleep_ms=1000)
await service.start()
assert len(service.list_jobs()) == 0
service2 = CronService(tmp_path / "cron" / "jobs.json")
service2.add_job(
name="hist",
schedule=CronSchedule(kind="every", every_ms=500),
message="hello",
)
assert len(service.list_jobs()) == 1
await asyncio.sleep(2)
assert len(called) != 0
service.stop()
@pytest.mark.asyncio
async def test_subsecond_job_not_delayed_to_one_second(tmp_path):
store_path = tmp_path / "cron" / "jobs.json"
called = []
async def on_job(job):
called.append(job.name)
service = CronService(store_path, on_job=on_job, max_sleep_ms=5000)
service.add_job(
name="fast",
schedule=CronSchedule(kind="every", every_ms=100),
message="hello",
)
await service.start()
try:
await asyncio.sleep(0.35)
assert called
finally:
service.stop()
@pytest.mark.asyncio
async def test_running_service_picks_up_external_add(tmp_path):
"""A running service should detect and execute a job added by another instance."""
store_path = tmp_path / "cron" / "jobs.json"
called: list[str] = []
async def on_job(job):
called.append(job.name)
service = CronService(store_path, on_job=on_job)
service.add_job(
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=150),
message="tick",
)
await service.start()
try:
await asyncio.sleep(0.05)
external = CronService(store_path)
external.add_job(
name="external",
schedule=CronSchedule(kind="every", every_ms=150),
message="ping",
)
await asyncio.sleep(2)
assert "external" in called
finally:
service.stop()
@pytest.mark.asyncio
async def test_add_job_during_jobs_exec(tmp_path):
store_path = tmp_path / "cron" / "jobs.json"
run_once = True
async def on_job(job):
nonlocal run_once
if run_once:
service2 = CronService(store_path, on_job=lambda x: asyncio.sleep(0))
service2.add_job(
name="test",
schedule=CronSchedule(kind="every", every_ms=150),
message="tick",
)
run_once = False
service = CronService(store_path, on_job=on_job)
service.add_job(
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=150),
message="tick",
)
assert len(service.list_jobs()) == 1
await service.start()
try:
await asyncio.sleep(3)
jobs = service.list_jobs()
assert len(jobs) == 2
assert "test" in [j.name for j in jobs]
finally:
service.stop()
@pytest.mark.asyncio
async def test_external_update_preserves_run_history_records(tmp_path):
store_path = tmp_path / "cron" / "jobs.json"
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
job = service.add_job(
name="history",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
await service.run_job(job.id, force=True)
external = CronService(store_path)
updated = external.enable_job(job.id, enabled=False)
assert updated is not None
fresh = CronService(store_path)
loaded = fresh.get_job(job.id)
assert loaded is not None
assert loaded.state.run_history
assert loaded.state.run_history[0].status == "ok"
fresh._running = True
fresh._save_store()
# ── timer race regression tests ──
@pytest.mark.asyncio
async def test_timer_execution_is_not_rolled_back_by_list_jobs_reload(tmp_path):
"""list_jobs() during _on_timer should not replace the active store and re-run the same due job."""
store_path = tmp_path / "cron" / "jobs.json"
calls: list[str] = []
async def on_job(job):
calls.append(job.id)
# Simulate frontend polling list_jobs while the timer callback is mid-execution.
service.list_jobs(include_disabled=True)
await asyncio.sleep(0)
service = CronService(store_path, on_job=on_job)
service._running = True
service._load_store()
service._arm_timer = lambda: None
job = service.add_job(
name="race",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
job.state.next_run_at_ms = max(1, int(time.time() * 1000) - 1_000)
service._save_store()
await service._on_timer()
await service._on_timer()
assert calls == [job.id]
loaded = service.get_job(job.id)
assert loaded is not None
assert loaded.state.last_run_at_ms is not None
assert loaded.state.next_run_at_ms is not None
assert loaded.state.next_run_at_ms > loaded.state.last_run_at_ms
# ── update_job tests ──
def test_update_job_changes_name(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="old name",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
result = service.update_job(job.id, name="new name")
assert isinstance(result, CronJob)
assert result.name == "new name"
assert result.payload.message == "hello"
def test_update_job_changes_schedule(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="sched",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
old_next = job.state.next_run_at_ms
new_sched = CronSchedule(kind="every", every_ms=120_000)
result = service.update_job(job.id, schedule=new_sched)
assert isinstance(result, CronJob)
assert result.schedule.every_ms == 120_000
assert result.state.next_run_at_ms != old_next
def test_update_job_changes_message(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="msg",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="old message",
)
result = service.update_job(job.id, message="new message")
assert isinstance(result, CronJob)
assert result.payload.message == "new message"
def test_update_job_changes_cron_expression(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="cron-job",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
message="hello",
)
result = service.update_job(
job.id,
schedule=CronSchedule(kind="cron", expr="0 18 * * *", tz="UTC"),
)
assert isinstance(result, CronJob)
assert result.schedule.expr == "0 18 * * *"
assert result.state.next_run_at_ms is not None
def test_update_job_not_found(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
result = service.update_job("nonexistent", name="x")
assert result == "not_found"
def test_update_job_rejects_system_job(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
service.register_system_job(CronJob(
id="dream",
name="dream",
schedule=CronSchedule(kind="cron", expr="0 */2 * * *", tz="UTC"),
payload=CronPayload(kind="system_event"),
))
result = service.update_job("dream", name="hacked")
assert result == "protected"
assert service.get_job("dream").name == "dream"
def test_update_job_validates_schedule(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="validate",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
with pytest.raises(ValueError, match="unknown timezone"):
service.update_job(
job.id,
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="Bad/Zone"),
)
@pytest.mark.asyncio
async def test_update_job_preserves_run_history(tmp_path) -> None:
import asyncio
store_path = tmp_path / "cron" / "jobs.json"
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
job = service.add_job(
name="hist",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
await service.run_job(job.id)
result = service.update_job(job.id, name="renamed")
assert isinstance(result, CronJob)
assert len(result.state.run_history) == 1
assert result.state.run_history[0].status == "ok"
def test_update_job_offline_writes_action(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="offline",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
)
service.update_job(job.id, name="updated-offline")
action_path = tmp_path / "cron" / "action.jsonl"
assert action_path.exists()
lines = [l for l in action_path.read_text().strip().split("\n") if l]
last = json.loads(lines[-1])
assert last["action"] == "update"
assert last["params"]["name"] == "updated-offline"
def test_update_job_sentinel_channel_and_to(tmp_path) -> None:
"""Passing None clears channel/to; omitting leaves them unchanged."""
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="sentinel",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
channel="telegram",
to="user123",
)
assert job.payload.channel == "telegram"
assert job.payload.to == "user123"
result = service.update_job(job.id, name="renamed")
assert isinstance(result, CronJob)
assert result.payload.channel == "telegram"
assert result.payload.to == "user123"
result = service.update_job(job.id, channel=None, to=None)
assert isinstance(result, CronJob)
assert result.payload.channel is None
assert result.payload.to is None
@pytest.mark.asyncio
async def test_list_jobs_during_on_job_does_not_cause_stale_reload(tmp_path) -> None:
"""Regression: if the bot calls list_jobs (which reloads from disk) during
on_job execution, the in-memory next_run_at_ms update must not be lost.
Previously this caused an infinite re-trigger loop."""
store_path = tmp_path / "cron" / "jobs.json"
execution_count = 0
async def on_job_that_lists(job):
nonlocal execution_count
execution_count += 1
# Simulate the bot calling cron(action=list) mid-execution
service.list_jobs()
service = CronService(store_path, on_job=on_job_that_lists, max_sleep_ms=100)
await service.start()
# Add two jobs scheduled in the past so they're immediately due
now_ms = int(time.time() * 1000)
for name in ("job-a", "job-b"):
service.add_job(
name=name,
schedule=CronSchedule(kind="every", every_ms=3_600_000),
message="test",
)
# Force next_run to the past so _on_timer picks them up
for job in service._store.jobs:
job.state.next_run_at_ms = now_ms - 1000
service._save_store()
service._arm_timer()
# Let the timer fire once
await asyncio.sleep(0.3)
service.stop()
# Each job should have run exactly once, not looped
assert execution_count == 2
# Verify next_run_at_ms was persisted correctly (in the future)
raw = json.loads(store_path.read_text())
for j in raw["jobs"]:
next_run = j["state"]["nextRunAtMs"]
assert next_run is not None
assert next_run > now_ms, f"Job '{j['name']}' next_run should be in the future"
+3 -9
View File
@@ -2,12 +2,9 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import pytest
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
from tests.test_openai_api import pytest_plugins
def _make_tool(tmp_path) -> CronTool: def _make_tool(tmp_path) -> CronTool:
@@ -218,10 +215,8 @@ def test_list_at_job_shows_iso_timestamp(tmp_path) -> None:
assert "Asia/Shanghai" in result assert "Asia/Shanghai" in result
@pytest.mark.asyncio def test_list_shows_last_run_state(tmp_path) -> None:
async def test_list_shows_last_run_state(tmp_path) -> None:
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
tool._cron._running = True
job = tool._cron.add_job( job = tool._cron.add_job(
name="Stateful job", name="Stateful job",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"), schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
@@ -237,10 +232,9 @@ async def test_list_shows_last_run_state(tmp_path) -> None:
assert "ok" in result assert "ok" in result
assert "(UTC)" in result assert "(UTC)" in result
@pytest.mark.asyncio
async def test_list_shows_error_message(tmp_path) -> None: def test_list_shows_error_message(tmp_path) -> None:
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
tool._cron._running = True
job = tool._cron.add_job( job = tool._cron.add_job(
name="Failed job", name="Failed job",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"), schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
@@ -1,65 +0,0 @@
"""Tests for Anthropic provider thinking / reasoning_effort modes."""
from __future__ import annotations
from unittest.mock import patch
from nanobot.providers.anthropic_provider import AnthropicProvider
def _make_provider(model: str = "claude-sonnet-4-6") -> AnthropicProvider:
with patch("anthropic.AsyncAnthropic"):
return AnthropicProvider(api_key="sk-test", default_model=model)
def _build(provider: AnthropicProvider, reasoning_effort: str | None, **overrides):
defaults = dict(
messages=[{"role": "user", "content": "hello"}],
tools=None,
model=None,
max_tokens=4096,
temperature=0.7,
reasoning_effort=reasoning_effort,
tool_choice=None,
supports_caching=False,
)
defaults.update(overrides)
return provider._build_kwargs(**defaults)
def test_adaptive_sets_type_adaptive() -> None:
kw = _build(_make_provider(), "adaptive")
assert kw["thinking"] == {"type": "adaptive"}
def test_adaptive_forces_temperature_one() -> None:
kw = _build(_make_provider(), "adaptive")
assert kw["temperature"] == 1.0
def test_adaptive_does_not_inflate_max_tokens() -> None:
kw = _build(_make_provider(), "adaptive", max_tokens=2048)
assert kw["max_tokens"] == 2048
def test_adaptive_no_budget_tokens() -> None:
kw = _build(_make_provider(), "adaptive")
assert "budget_tokens" not in kw["thinking"]
def test_high_uses_enabled_with_budget() -> None:
kw = _build(_make_provider(), "high", max_tokens=4096)
assert kw["thinking"]["type"] == "enabled"
assert kw["thinking"]["budget_tokens"] == max(8192, 4096)
assert kw["max_tokens"] >= kw["thinking"]["budget_tokens"] + 4096
def test_low_uses_small_budget() -> None:
kw = _build(_make_provider(), "low")
assert kw["thinking"] == {"type": "enabled", "budget_tokens": 1024}
def test_none_does_not_enable_thinking() -> None:
kw = _build(_make_provider(), None)
assert "thinking" not in kw
assert kw["temperature"] == 0.7
-18
View File
@@ -4,7 +4,6 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import find_by_name
def test_custom_provider_parse_handles_empty_choices() -> None: def test_custom_provider_parse_handles_empty_choices() -> None:
@@ -54,20 +53,3 @@ def test_custom_provider_parse_chunks_accepts_plain_text_chunks() -> None:
assert result.finish_reason == "stop" assert result.finish_reason == "stop"
assert result.content == "hello world" assert result.content == "hello world"
def test_local_provider_502_error_includes_reachability_hint() -> None:
spec = find_by_name("ollama")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(api_base="http://localhost:11434/v1", spec=spec)
result = provider._handle_error(
Exception("Error code: 502"),
spec=spec,
api_base="http://localhost:11434/v1",
)
assert result.finish_reason == "error"
assert "local model endpoint" in result.content
assert "http://localhost:11434/v1" in result.content
assert "proxy/tunnel" in result.content
@@ -1,197 +0,0 @@
"""Tests for LLMProvider._enforce_role_alternation."""
from nanobot.providers.base import LLMProvider
class TestEnforceRoleAlternation:
"""Verify trailing-assistant removal and consecutive same-role merging."""
def test_empty_messages(self):
assert LLMProvider._enforce_role_alternation([]) == []
def test_no_change_needed(self):
msgs = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "Bye"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 4
assert result[-1]["role"] == "user"
def test_trailing_assistant_removed(self):
msgs = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
def test_multiple_trailing_assistants_removed(self):
msgs = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "A"},
{"role": "assistant", "content": "B"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 1
assert result[0]["role"] == "user"
def test_consecutive_user_messages_merged(self):
msgs = [
{"role": "user", "content": "Hello"},
{"role": "user", "content": "How are you?"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 1
assert "Hello" in result[0]["content"]
assert "How are you?" in result[0]["content"]
def test_consecutive_assistant_messages_merged(self):
msgs = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
{"role": "assistant", "content": "How can I help?"},
{"role": "user", "content": "Thanks"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 3
assert "Hello!" in result[1]["content"]
assert "How can I help?" in result[1]["content"]
def test_system_messages_not_merged(self):
msgs = [
{"role": "system", "content": "System A"},
{"role": "system", "content": "System B"},
{"role": "user", "content": "Hi"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 3
assert result[0]["content"] == "System A"
assert result[1]["content"] == "System B"
def test_tool_messages_not_merged(self):
msgs = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]},
{"role": "tool", "content": "result1", "tool_call_id": "1"},
{"role": "tool", "content": "result2", "tool_call_id": "2"},
{"role": "user", "content": "Next"},
]
result = LLMProvider._enforce_role_alternation(msgs)
tool_msgs = [m for m in result if m["role"] == "tool"]
assert len(tool_msgs) == 2
def test_consecutive_assistant_keeps_later_tool_call_message(self):
msgs = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Previous reply"},
{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]},
{"role": "tool", "content": "result1", "tool_call_id": "1"},
{"role": "user", "content": "Next"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert result[1]["role"] == "assistant"
assert result[1]["tool_calls"] == [{"id": "1"}]
assert result[1]["content"] is None
assert result[2]["role"] == "tool"
def test_consecutive_assistant_does_not_overwrite_existing_tool_call_message(self):
msgs = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]},
{"role": "assistant", "content": "Later plain assistant"},
{"role": "tool", "content": "result1", "tool_call_id": "1"},
{"role": "user", "content": "Next"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert result[1]["role"] == "assistant"
assert result[1]["tool_calls"] == [{"id": "1"}]
assert result[1]["content"] is None
assert result[2]["role"] == "tool"
def test_non_string_content_uses_latest(self):
msgs = [
{"role": "user", "content": [{"type": "text", "text": "A"}]},
{"role": "user", "content": "B"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 1
assert result[0]["content"] == "B"
def test_original_messages_not_mutated(self):
msgs = [
{"role": "user", "content": "Hello"},
{"role": "user", "content": "World"},
]
original_first = dict(msgs[0])
LLMProvider._enforce_role_alternation(msgs)
assert msgs[0] == original_first
assert len(msgs) == 2
def test_trailing_assistant_recovered_as_user_when_only_system_remains(self):
"""Subagent result injected as assistant message must not be silently dropped.
When build_messages(current_role="assistant") produces [system, assistant],
_enforce_role_alternation would drop the assistant, leaving only [system].
Most providers (e.g. Zhipu/GLM error 1214) reject such requests.
The trailing assistant should be recovered as a user message instead.
"""
msgs = [
{"role": "system", "content": "You are helpful."},
{"role": "assistant", "content": "Subagent completed successfully."},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 2
assert result[0]["role"] == "system"
assert result[1]["role"] == "user"
assert "Subagent completed successfully." in result[1]["content"]
def test_trailing_assistant_not_recovered_when_user_message_present(self):
"""Recovery should NOT happen when a user message already exists."""
msgs = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 2
assert result[-1]["role"] == "user"
def test_trailing_assistant_recovered_with_tool_result_preceding(self):
"""When only [system, tool, assistant] remains, recovery is not needed
because tool messages are valid non-system content."""
msgs = [
{"role": "system", "content": "You are helpful."},
{"role": "tool", "content": "result", "tool_call_id": "1"},
{"role": "assistant", "content": "Done."},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 2
assert result[-1]["role"] == "tool"
def test_only_assistant_messages(self):
msgs = [
{"role": "assistant", "content": "A"},
{"role": "assistant", "content": "B"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert result == []
def test_realistic_conversation(self):
msgs = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "4"},
{"role": "user", "content": "And 3+3?"},
{"role": "user", "content": "(please be quick)"},
{"role": "assistant", "content": "6"},
]
result = LLMProvider._enforce_role_alternation(msgs)
assert len(result) == 4
assert result[2]["role"] == "assistant"
assert result[3]["role"] == "user"
assert "And 3+3?" in result[3]["content"]
assert "(please be quick)" in result[3]["content"]
+5 -305
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import asyncio import asyncio
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -54,57 +54,6 @@ def _fake_tool_call_response() -> SimpleNamespace:
return SimpleNamespace(choices=[choice], usage=usage) return SimpleNamespace(choices=[choice], usage=usage)
def _fake_responses_response(content: str = "ok") -> MagicMock:
"""Build a minimal Responses API response object."""
resp = MagicMock()
resp.model_dump.return_value = {
"output": [{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
}],
"status": "completed",
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
return resp
def _fake_responses_stream(text: str = "ok"):
async def _stream():
yield SimpleNamespace(type="response.output_text.delta", delta=text)
yield SimpleNamespace(
type="response.completed",
response=SimpleNamespace(
status="completed",
usage=SimpleNamespace(input_tokens=10, output_tokens=5, total_tokens=15),
output=[],
),
)
return _stream()
def _fake_chat_stream(text: str = "ok"):
async def _stream():
yield SimpleNamespace(
choices=[SimpleNamespace(finish_reason=None, delta=SimpleNamespace(content=text, reasoning_content=None, tool_calls=None))],
usage=None,
)
yield SimpleNamespace(
choices=[SimpleNamespace(finish_reason="stop", delta=SimpleNamespace(content=None, reasoning_content=None, tool_calls=None))],
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
)
return _stream()
class _FakeResponsesError(Exception):
def __init__(self, status_code: int, text: str):
super().__init__(text)
self.status_code = status_code
self.response = SimpleNamespace(status_code=status_code, text=text, headers={})
class _StalledStream: class _StalledStream:
def __aiter__(self): def __aiter__(self):
return self return self
@@ -277,224 +226,6 @@ def test_openai_model_passthrough() -> None:
assert provider.get_default_model() == "gpt-4o" assert provider.get_default_model() == "gpt-4o"
@pytest.mark.asyncio
async def test_direct_openai_gpt5_uses_responses_api() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response())
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-5-chat",
spec=spec,
)
result = await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="gpt-5-chat",
)
assert result.content == "from responses"
mock_responses.assert_awaited_once()
mock_chat.assert_not_awaited()
call_kwargs = mock_responses.call_args.kwargs
assert call_kwargs["model"] == "gpt-5-chat"
assert call_kwargs["max_output_tokens"] == 4096
assert "input" in call_kwargs
assert "messages" not in call_kwargs
@pytest.mark.asyncio
async def test_direct_openai_reasoning_prefers_responses_api() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response())
mock_responses = AsyncMock(return_value=_fake_responses_response("reasoned"))
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-4o",
spec=spec,
)
await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="gpt-4o",
reasoning_effort="medium",
)
mock_responses.assert_awaited_once()
mock_chat.assert_not_awaited()
call_kwargs = mock_responses.call_args.kwargs
assert call_kwargs["reasoning"] == {"effort": "medium"}
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
@pytest.mark.asyncio
async def test_direct_openai_gpt4o_stays_on_chat_completions() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response())
mock_responses = AsyncMock(return_value=_fake_responses_response())
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-4o",
spec=spec,
)
await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="gpt-4o",
)
mock_chat.assert_awaited_once()
mock_responses.assert_not_awaited()
@pytest.mark.asyncio
async def test_openrouter_gpt5_stays_on_chat_completions() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response())
mock_responses = AsyncMock(return_value=_fake_responses_response())
spec = find_by_name("openrouter")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-or-test-key",
api_base="https://openrouter.ai/api/v1",
default_model="openai/gpt-5",
spec=spec,
)
await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="openai/gpt-5",
)
mock_chat.assert_awaited_once()
mock_responses.assert_not_awaited()
@pytest.mark.asyncio
async def test_direct_openai_streaming_gpt5_uses_responses_api() -> None:
mock_chat = AsyncMock(return_value=_StalledStream())
mock_responses = AsyncMock(return_value=_fake_responses_stream("hi"))
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-5-chat",
spec=spec,
)
result = await provider.chat_stream(
messages=[{"role": "user", "content": "hello"}],
model="gpt-5-chat",
)
assert result.content == "hi"
assert result.finish_reason == "stop"
mock_responses.assert_awaited_once()
mock_chat.assert_not_awaited()
@pytest.mark.asyncio
async def test_direct_openai_responses_404_falls_back_to_chat_completions() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
mock_responses = AsyncMock(side_effect=_FakeResponsesError(404, "Responses endpoint not supported"))
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-5-chat",
spec=spec,
)
result = await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="gpt-5-chat",
)
assert result.content == "from chat"
mock_responses.assert_awaited_once()
mock_chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_direct_openai_stream_responses_unsupported_param_falls_back() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_stream("fallback stream"))
mock_responses = AsyncMock(
side_effect=_FakeResponsesError(400, "Unknown parameter: max_output_tokens for Responses API")
)
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-5-chat",
spec=spec,
)
result = await provider.chat_stream(
messages=[{"role": "user", "content": "hello"}],
model="gpt-5-chat",
)
assert result.content == "fallback stream"
mock_responses.assert_awaited_once()
mock_chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_direct_openai_responses_rate_limit_does_not_fallback() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
mock_responses = AsyncMock(side_effect=_FakeResponsesError(429, "rate limit"))
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-5-chat",
spec=spec,
)
result = await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="gpt-5-chat",
)
assert result.finish_reason == "error"
mock_responses.assert_awaited_once()
mock_chat.assert_not_awaited()
def test_openai_compat_supports_temperature_matches_reasoning_model_rules() -> None: def test_openai_compat_supports_temperature_matches_reasoning_model_rules() -> None:
assert OpenAICompatProvider._supports_temperature("gpt-4o") is True assert OpenAICompatProvider._supports_temperature("gpt-4o") is True
assert OpenAICompatProvider._supports_temperature("gpt-5-chat") is False assert OpenAICompatProvider._supports_temperature("gpt-5-chat") is False
@@ -532,7 +263,6 @@ def test_openai_compat_preserves_message_level_reasoning_fields() -> None:
provider = OpenAICompatProvider() provider = OpenAICompatProvider()
sanitized = provider._sanitize_messages([ sanitized = provider._sanitize_messages([
{"role": "user", "content": "hi"},
{ {
"role": "assistant", "role": "assistant",
"content": "done", "content": "done",
@@ -546,42 +276,12 @@ def test_openai_compat_preserves_message_level_reasoning_fields() -> None:
"extra_content": {"google": {"thought_signature": "sig"}}, "extra_content": {"google": {"thought_signature": "sig"}},
} }
], ],
}, }
{"role": "user", "content": "thanks"},
]) ])
assert sanitized[1]["content"] is None assert sanitized[0]["reasoning_content"] == "hidden"
assert sanitized[1]["reasoning_content"] == "hidden" assert sanitized[0]["extra_content"] == {"debug": True}
assert sanitized[1]["extra_content"] == {"debug": True} assert sanitized[0]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}}
assert sanitized[1]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}}
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
sanitized = provider._sanitize_messages([
{"role": "user", "content": "不错"},
{"role": "assistant", "content": "对,破 4 万指日可待"},
{
"role": "assistant",
"content": "<think>我再查一下</think>",
"tool_calls": [
{
"id": "call_function_akxp3wqzn7ph_1",
"type": "function",
"function": {"name": "exec", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_function_akxp3wqzn7ph_1", "name": "exec", "content": "ok"},
{"role": "user", "content": "多少star了呢"},
])
assert sanitized[1]["role"] == "assistant"
assert sanitized[1]["content"] is None
assert sanitized[1]["tool_calls"][0]["id"] == "3ec83c30d"
assert sanitized[2]["tool_call_id"] == "3ec83c30d"
@pytest.mark.asyncio @pytest.mark.asyncio
+3 -22
View File
@@ -1,5 +1,4 @@
import asyncio import asyncio
import copy
import pytest import pytest
@@ -153,7 +152,7 @@ async def test_non_transient_error_with_images_retries_without_images() -> None:
LLMResponse(content="ok, no image"), LLMResponse(content="ok, no image"),
]) ])
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG)) response = await provider.chat_with_retry(messages=_IMAGE_MSG)
assert response.content == "ok, no image" assert response.content == "ok, no image"
assert provider.calls == 2 assert provider.calls == 2
@@ -165,24 +164,6 @@ async def test_non_transient_error_with_images_retries_without_images() -> None:
assert any("[image: /media/test.png]" in (b.get("text") or "") for b in content) assert any("[image: /media/test.png]" in (b.get("text") or "") for b in content)
@pytest.mark.asyncio
async def test_successful_image_retry_mutates_original_messages_in_place() -> None:
"""Successful no-image retry should update the caller's message history."""
provider = ScriptedProvider([
LLMResponse(content="model does not support images", finish_reason="error"),
LLMResponse(content="ok, no image"),
])
messages = copy.deepcopy(_IMAGE_MSG)
response = await provider.chat_with_retry(messages=messages)
assert response.content == "ok, no image"
content = messages[0]["content"]
assert isinstance(content, list)
assert all(block.get("type") != "image_url" for block in content)
assert any("[image: /media/test.png]" in (block.get("text") or "") for block in content)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_non_transient_error_without_images_no_retry() -> None: async def test_non_transient_error_without_images_no_retry() -> None:
"""Non-transient errors without image content are returned immediately.""" """Non-transient errors without image content are returned immediately."""
@@ -206,7 +187,7 @@ async def test_image_fallback_returns_error_on_second_failure() -> None:
LLMResponse(content="still failing", finish_reason="error"), LLMResponse(content="still failing", finish_reason="error"),
]) ])
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG)) response = await provider.chat_with_retry(messages=_IMAGE_MSG)
assert provider.calls == 2 assert provider.calls == 2
assert response.content == "still failing" assert response.content == "still failing"
@@ -221,7 +202,7 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
LLMResponse(content="ok"), LLMResponse(content="ok"),
]) ])
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG_NO_META)) response = await provider.chat_with_retry(messages=_IMAGE_MSG_NO_META)
assert response.content == "ok" assert response.content == "ok"
assert provider.calls == 2 assert provider.calls == 2
-246
View File
@@ -1,246 +0,0 @@
"""Tests for StepFun Plan API reasoning field fallback in OpenAICompatProvider.
StepFun Plan API returns response content in the 'reasoning' field when
the model is in thinking mode and 'content' is empty. This test module
verifies the fallback logic for all code paths.
"""
from types import SimpleNamespace
from unittest.mock import patch
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
# ── _parse: dict branch ─────────────────────────────────────────────────────
def test_parse_dict_stepfun_reasoning_fallback() -> None:
"""When content is None and reasoning exists, content falls back to reasoning."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {
"content": None,
"reasoning": "Let me think... The answer is 42.",
},
"finish_reason": "stop",
}],
}
result = provider._parse(response)
assert result.content == "Let me think... The answer is 42."
# reasoning_content should also be populated from reasoning
assert result.reasoning_content == "Let me think... The answer is 42."
def test_parse_dict_stepfun_reasoning_priority() -> None:
"""reasoning_content field takes priority over reasoning when both present."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {
"content": None,
"reasoning": "informal thinking",
"reasoning_content": "formal reasoning content",
},
"finish_reason": "stop",
}],
}
result = provider._parse(response)
assert result.content == "informal thinking"
# reasoning_content uses the dedicated field, not reasoning
assert result.reasoning_content == "formal reasoning content"
# ── _parse: SDK object branch ───────────────────────────────────────────────
def _make_sdk_message(content, reasoning=None, reasoning_content=None):
"""Create a mock SDK message object."""
msg = SimpleNamespace(content=content, tool_calls=None)
if reasoning is not None:
msg.reasoning = reasoning
if reasoning_content is not None:
msg.reasoning_content = reasoning_content
return msg
def test_parse_sdk_stepfun_reasoning_fallback() -> None:
"""SDK branch: content falls back to msg.reasoning when content is None."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
msg = _make_sdk_message(content=None, reasoning="After analysis: result is 4.")
choice = SimpleNamespace(finish_reason="stop", message=msg)
response = SimpleNamespace(choices=[choice], usage=None)
result = provider._parse(response)
assert result.content == "After analysis: result is 4."
assert result.reasoning_content == "After analysis: result is 4."
def test_parse_sdk_stepfun_reasoning_priority() -> None:
"""reasoning_content field takes priority over reasoning in SDK branch."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
msg = _make_sdk_message(
content=None,
reasoning="thinking process",
reasoning_content="formal reasoning"
)
choice = SimpleNamespace(finish_reason="stop", message=msg)
response = SimpleNamespace(choices=[choice], usage=None)
result = provider._parse(response)
assert result.content == "thinking process"
assert result.reasoning_content == "formal reasoning"
# ── _parse_chunks: streaming dict branch ────────────────────────────────────
def test_parse_chunks_dict_stepfun_reasoning_fallback() -> None:
"""Streaming dict: reasoning field used when reasoning_content is absent."""
chunks = [
{
"choices": [{
"finish_reason": None,
"delta": {"content": None, "reasoning": "Thinking step 1... "},
}],
},
{
"choices": [{
"finish_reason": None,
"delta": {"content": None, "reasoning": "step 2."},
}],
},
{
"choices": [{
"finish_reason": "stop",
"delta": {"content": "final answer"},
}],
},
]
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.content == "final answer"
assert result.reasoning_content == "Thinking step 1... step 2."
# ── Regression: normal models unaffected ────────────────────────────────────
def test_parse_dict_normal_model_with_reasoning_content_unaffected() -> None:
"""Models that use reasoning_content (e.g. DeepSeek-R1) are not affected."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {
"content": "The answer is 42.",
"reasoning_content": "Let me think step by step...",
},
"finish_reason": "stop",
}],
}
result = provider._parse(response)
assert result.content == "The answer is 42."
assert result.reasoning_content == "Let me think step by step..."
def test_parse_dict_standard_model_no_reasoning_unaffected() -> None:
"""Standard models (no reasoning fields at all) work exactly as before."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {"content": "Hello!"},
"finish_reason": "stop",
}],
}
result = provider._parse(response)
assert result.content == "Hello!"
assert result.reasoning_content is None
def test_parse_chunks_dict_reasoning_precedence() -> None:
"""reasoning_content takes precedence over reasoning in dict chunks."""
chunks = [
{
"choices": [{
"finish_reason": None,
"delta": {
"content": None,
"reasoning_content": "formal: ",
"reasoning": "informal: ",
},
}],
},
{
"choices": [{
"finish_reason": "stop",
"delta": {"content": "result"},
}],
},
]
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.reasoning_content == "formal: "
# ── _parse_chunks: streaming SDK-object branch ─────────────────────────────
def _make_sdk_chunk(reasoning_content=None, reasoning=None, content=None, finish=None):
"""Create a mock SDK chunk object."""
delta = SimpleNamespace(
content=content,
reasoning_content=reasoning_content,
reasoning=reasoning,
tool_calls=None,
)
choice = SimpleNamespace(finish_reason=finish, delta=delta)
return SimpleNamespace(choices=[choice], usage=None)
def test_parse_chunks_sdk_stepfun_reasoning_fallback() -> None:
"""SDK streaming: reasoning field used when reasoning_content is None."""
chunks = [
_make_sdk_chunk(reasoning="Thinking... ", content=None, finish=None),
_make_sdk_chunk(reasoning=None, content="answer", finish="stop"),
]
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.content == "answer"
assert result.reasoning_content == "Thinking... "
def test_parse_chunks_sdk_reasoning_precedence() -> None:
"""reasoning_content takes precedence over reasoning in SDK chunks."""
chunks = [
_make_sdk_chunk(reasoning_content="formal: ", reasoning="informal: ", content=None),
_make_sdk_chunk(reasoning_content=None, reasoning=None, content="result", finish="stop"),
]
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.reasoning_content == "formal: "
+528
View File
@@ -0,0 +1,528 @@
import json
import pytest
# Check optional msteams dependencies before running tests
try:
from nanobot.channels import msteams
MSTEAMS_AVAILABLE = getattr(msteams, "MSTEAMS_AVAILABLE", False)
except ImportError:
MSTEAMS_AVAILABLE = False
if not MSTEAMS_AVAILABLE:
pytest.skip("MSTeams dependencies not installed (PyJWT, cryptography). Run: pip install nanobot-ai[msteams]", allow_module_level=True)
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
import nanobot.channels.msteams as msteams_module
from nanobot.bus.events import OutboundMessage
from nanobot.channels.msteams import ConversationRef, MSTeamsChannel, MSTeamsConfig
class DummyBus:
def __init__(self):
self.inbound = []
async def publish_inbound(self, msg):
self.inbound.append(msg)
class FakeResponse:
def __init__(self, payload=None, *, should_raise=False):
self._payload = payload or {}
self._should_raise = should_raise
def raise_for_status(self):
if self._should_raise:
raise RuntimeError("boom")
return None
def json(self):
return self._payload
class FakeHttpClient:
def __init__(self, payload=None, *, should_raise=False):
self.payload = payload or {"access_token": "tok", "expires_in": 3600}
self.should_raise = should_raise
self.calls = []
async def post(self, url, **kwargs):
self.calls.append((url, kwargs))
return FakeResponse(self.payload, should_raise=self.should_raise)
@pytest.fixture
def make_channel(tmp_path, monkeypatch):
monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
def _make_channel(**config_overrides):
config = {
"enabled": True,
"appId": "app-id",
"appPassword": "secret",
"tenantId": "tenant-id",
"allowFrom": ["*"],
}
config.update(config_overrides)
return MSTeamsChannel(config, DummyBus())
return _make_channel
@pytest.mark.asyncio
async def test_handle_activity_personal_message_publishes_and_stores_ref(make_channel, tmp_path):
ch = make_channel()
activity = {
"type": "message",
"id": "activity-1",
"text": "Hello from Teams",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-123",
"conversationType": "personal",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
"channelData": {
"tenant": {"id": "tenant-id"},
},
}
await ch._handle_activity(activity)
assert len(ch.bus.inbound) == 1
msg = ch.bus.inbound[0]
assert msg.channel == "msteams"
assert msg.sender_id == "aad-user-1"
assert msg.chat_id == "conv-123"
assert msg.content == "Hello from Teams"
assert msg.metadata["msteams"]["conversation_id"] == "conv-123"
assert "conv-123" in ch._conversation_refs
saved = json.loads((tmp_path / "state" / "msteams_conversations.json").read_text(encoding="utf-8"))
assert saved["conv-123"]["conversation_id"] == "conv-123"
assert saved["conv-123"]["tenant_id"] == "tenant-id"
@pytest.mark.asyncio
async def test_handle_activity_ignores_group_messages(make_channel):
ch = make_channel()
activity = {
"type": "message",
"id": "activity-2",
"text": "Hello group",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-group",
"conversationType": "channel",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
}
await ch._handle_activity(activity)
assert ch.bus.inbound == []
assert ch._conversation_refs == {}
@pytest.mark.asyncio
async def test_handle_activity_mention_only_uses_default_response(make_channel):
ch = make_channel()
activity = {
"type": "message",
"id": "activity-3",
"text": "<at>Nanobot</at>",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-empty",
"conversationType": "personal",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
}
await ch._handle_activity(activity)
assert len(ch.bus.inbound) == 1
assert ch.bus.inbound[0].content == "Hi — what can I help with?"
assert "conv-empty" in ch._conversation_refs
@pytest.mark.asyncio
async def test_handle_activity_mention_only_ignores_when_response_disabled(make_channel):
ch = make_channel(mentionOnlyResponse=" ")
activity = {
"type": "message",
"id": "activity-4",
"text": "<at>Nanobot</at>",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"conversation": {
"id": "conv-empty-disabled",
"conversationType": "personal",
},
"from": {
"id": "29:user-id",
"aadObjectId": "aad-user-1",
"name": "Bob",
},
"recipient": {
"id": "28:bot-id",
"name": "nanobot",
},
}
await ch._handle_activity(activity)
assert ch.bus.inbound == []
assert ch._conversation_refs == {}
def test_strip_possible_bot_mention_removes_generic_at_tags(make_channel):
ch = make_channel()
assert ch._strip_possible_bot_mention("<at>Nanobot</at> hello") == "hello"
assert ch._strip_possible_bot_mention("hi <at>Some Bot</at> there") == "hi there"
def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel):
ch = make_channel()
activity = {
"text": "<at>Nanobot</at> normal inline message",
"channelData": {},
}
assert ch._sanitize_inbound_text(activity) == "normal inline message"
def test_sanitize_inbound_text_normalizes_fwdioc_wrapper_without_reply_metadata(make_channel):
ch = make_channel()
activity = {
"text": "FWDIOC-BOT \r\nQuoted prior message\r\n\r\nThis is a reply with quote test",
"channelData": {},
}
assert ch._sanitize_inbound_text(activity) == (
"User is replying to: Quoted prior message\n"
"User reply: This is a reply with quote test"
)
def test_sanitize_inbound_text_structures_reply_quote_prefix(make_channel):
ch = make_channel()
activity = {
"text": "Replying to Bob Smith\nactual reply text",
"replyToId": "parent-activity",
"channelData": {"messageType": "reply"},
}
assert ch._sanitize_inbound_text(activity) == "User is replying to: Bob Smith\nUser reply: actual reply text"
def test_sanitize_inbound_text_structures_live_fwdioc_quote_shape(make_channel):
ch = make_channel()
activity = {
"text": "FWDIOC-BOT Got it. Ill watch for the exact text reply with quote test and then inspect that turn specifically. Reply with quote test",
"replyToId": "parent-activity",
"channelData": {"messageType": "reply"},
}
assert ch._sanitize_inbound_text(activity) == (
"User is replying to: Got it. Ill watch for the exact text reply with quote test and then inspect that turn specifically.\n"
"User reply: Reply with quote test"
)
def test_sanitize_inbound_text_structures_multiline_fwdioc_quote_shape(make_channel):
ch = make_channel()
activity = {
"text": (
"FWDIOC-BOT\r\n"
"Understood — then the restart already happened, and the new Teams quote normalization should now be live. "
"Next best step: • send one more real reply-with-quote message in Teams • I&rsquo…\r\n"
"\r\n"
"This is a reply with quote"
),
"replyToId": "parent-activity",
"channelData": {"messageType": "reply"},
}
assert ch._sanitize_inbound_text(activity) == (
"User is replying to: Understood — then the restart already happened, and the new Teams quote normalization should now be live. "
"Next best step: • send one more real reply-with-quote message in Teams • I’…\n"
"User reply: This is a reply with quote"
)
def test_sanitize_inbound_text_structures_exact_live_crlf_fwdioc_shape(make_channel):
ch = make_channel()
activity = {
"text": (
"FWDIOC-BOT \r\n"
"Please send one real reply-with-quote message in Teams. That single test should be enough now: "
"• Ill check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\r\n"
"\r\n"
"This is a reply with quote test"
),
"replyToId": "parent-activity",
"channelData": {"messageType": "reply"},
}
assert ch._sanitize_inbound_text(activity) == (
"User is replying to: Please send one real reply-with-quote message in Teams. That single test should be enough now: "
"• Ill check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\n"
"User reply: This is a reply with quote test"
)
@pytest.mark.asyncio
async def test_get_access_token_uses_configured_tenant(make_channel):
ch = make_channel(tenantId="tenant-123")
fake_http = FakeHttpClient()
ch._http = fake_http
token = await ch._get_access_token()
assert token == "tok"
assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0]
assert url == "https://login.microsoftonline.com/tenant-123/oauth2/v2.0/token"
assert kwargs["data"]["client_id"] == "app-id"
assert kwargs["data"]["client_secret"] == "secret"
assert kwargs["data"]["scope"] == "https://api.botframework.com/.default"
@pytest.mark.asyncio
async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel):
ch = make_channel(replyInThread=True)
fake_http = FakeHttpClient()
ch._http = fake_http
ch._token = "tok"
ch._token_expires_at = 9999999999
ch._conversation_refs["conv-123"] = ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="conv-123",
activity_id="activity-1",
)
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0]
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities/activity-1"
assert kwargs["headers"]["Authorization"] == "Bearer tok"
assert kwargs["json"]["text"] == "Reply text"
assert kwargs["json"]["replyToId"] == "activity-1"
@pytest.mark.asyncio
async def test_send_posts_to_conversation_when_thread_reply_disabled(make_channel):
ch = make_channel(replyInThread=False)
fake_http = FakeHttpClient()
ch._http = fake_http
ch._token = "tok"
ch._token_expires_at = 9999999999
ch._conversation_refs["conv-123"] = ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="conv-123",
activity_id="activity-1",
)
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0]
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
assert kwargs["headers"]["Authorization"] == "Bearer tok"
assert kwargs["json"]["text"] == "Reply text"
assert "replyToId" not in kwargs["json"]
@pytest.mark.asyncio
async def test_send_posts_to_conversation_when_thread_reply_enabled_but_no_activity_id(make_channel):
ch = make_channel(replyInThread=True)
fake_http = FakeHttpClient()
ch._http = fake_http
ch._token = "tok"
ch._token_expires_at = 9999999999
ch._conversation_refs["conv-123"] = ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="conv-123",
activity_id=None,
)
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0]
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
assert kwargs["headers"]["Authorization"] == "Bearer tok"
assert kwargs["json"]["text"] == "Reply text"
assert "replyToId" not in kwargs["json"]
@pytest.mark.asyncio
async def test_send_raises_when_conversation_ref_missing(make_channel):
ch = make_channel()
ch._http = FakeHttpClient()
with pytest.raises(RuntimeError, match="conversation ref not found"):
await ch.send(OutboundMessage(channel="msteams", chat_id="missing", content="Reply text"))
@pytest.mark.asyncio
async def test_send_raises_delivery_failures_for_retry(make_channel):
ch = make_channel()
ch._http = FakeHttpClient(should_raise=True)
ch._token = "tok"
ch._token_expires_at = 9999999999
ch._conversation_refs["conv-123"] = ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="conv-123",
activity_id="activity-1",
)
with pytest.raises(RuntimeError, match="boom"):
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
def _make_test_rsa_jwk(kid: str = "test-kid"):
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(public_key))
jwk["kid"] = kid
jwk["use"] = "sig"
jwk["kty"] = "RSA"
jwk["alg"] = "RS256"
return private_key, jwk
@pytest.mark.asyncio
async def test_validate_inbound_auth_accepts_observed_botframework_shape(make_channel):
ch = make_channel(validateInboundAuth=True)
private_key, jwk = _make_test_rsa_jwk()
ch._botframework_jwks = {"keys": [jwk]}
ch._botframework_jwks_expires_at = 9999999999
service_url = "https://smba.trafficmanager.net/amer/tenant/"
token = jwt.encode(
{
"iss": "https://api.botframework.com",
"aud": "app-id",
"serviceurl": service_url,
"nbf": 1700000000,
"exp": 4100000000,
},
private_key,
algorithm="RS256",
headers={"kid": jwk["kid"]},
)
await ch._validate_inbound_auth(
f"Bearer {token}",
{"serviceUrl": service_url},
)
@pytest.mark.asyncio
async def test_validate_inbound_auth_rejects_service_url_mismatch(make_channel):
ch = make_channel(validateInboundAuth=True)
private_key, jwk = _make_test_rsa_jwk()
ch._botframework_jwks = {"keys": [jwk]}
ch._botframework_jwks_expires_at = 9999999999
token = jwt.encode(
{
"iss": "https://api.botframework.com",
"aud": "app-id",
"serviceurl": "https://smba.trafficmanager.net/amer/tenant-a/",
"nbf": 1700000000,
"exp": 4100000000,
},
private_key,
algorithm="RS256",
headers={"kid": jwk["kid"]},
)
with pytest.raises(ValueError, match="serviceUrl claim mismatch"):
await ch._validate_inbound_auth(
f"Bearer {token}",
{"serviceUrl": "https://smba.trafficmanager.net/amer/tenant-b/"},
)
@pytest.mark.asyncio
async def test_validate_inbound_auth_rejects_missing_bearer_token(make_channel):
ch = make_channel(validateInboundAuth=True)
with pytest.raises(ValueError, match="missing bearer token"):
await ch._validate_inbound_auth("", {"serviceUrl": "https://smba.trafficmanager.net/amer/tenant/"})
@pytest.mark.asyncio
async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypatch):
ch = make_channel()
errors = []
monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False)
monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args)))
await ch.start()
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"]
def test_msteams_default_config_includes_restart_notify_fields():
cfg = MSTeamsChannel.default_config()
assert cfg["restartNotifyEnabled"] is False
assert "restartNotifyPreMessage" in cfg
assert "restartNotifyPostMessage" in cfg
def test_msteams_config_accepts_restart_notify_aliases():
cfg = MSTeamsConfig.model_validate(
{
"restartNotifyEnabled": True,
"restartNotifyPreMessage": "Restarting now.",
"restartNotifyPostMessage": "Back online.",
}
)
assert cfg.restart_notify_enabled is True
assert cfg.restart_notify_pre_message == "Restarting now."
assert cfg.restart_notify_post_message == "Back online."
-41
View File
@@ -1,41 +0,0 @@
from __future__ import annotations
import subprocess
import sys
import textwrap
from pathlib import Path
import tomllib
def test_source_checkout_import_uses_pyproject_version_without_metadata() -> None:
repo_root = Path(__file__).resolve().parents[1]
expected = tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8"))["project"][
"version"
]
script = textwrap.dedent(
f"""
import sys
import types
sys.path.insert(0, {str(repo_root)!r})
fake = types.ModuleType("nanobot.nanobot")
fake.Nanobot = object
fake.RunResult = object
sys.modules["nanobot.nanobot"] = fake
import nanobot
print(nanobot.__version__)
"""
)
proc = subprocess.run(
[sys.executable, "-S", "-c", script],
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip() == expected
-31
View File
@@ -1,31 +0,0 @@
import inspect
from types import SimpleNamespace
def test_sanitize_persisted_blocks_truncate_text_shadowing_regression() -> None:
"""Regression: avoid bool param shadowing imported truncate_text.
Buggy behavior (historical):
- loop.py imports `truncate_text` from helpers
- `_sanitize_persisted_blocks(..., truncate_text: bool=...)` uses same name
- when called with `truncate_text=True`, function body executes `truncate_text(text, ...)`
which resolves to bool and raises `TypeError: 'bool' object is not callable`.
This test asserts the fixed API exists and truncation works without raising.
"""
from nanobot.agent.loop import AgentLoop
sig = inspect.signature(AgentLoop._sanitize_persisted_blocks)
assert "should_truncate_text" in sig.parameters
assert "truncate_text" not in sig.parameters
dummy = SimpleNamespace(max_tool_result_chars=5)
content = [{"type": "text", "text": "0123456789"}]
out = AgentLoop._sanitize_persisted_blocks(dummy, content, should_truncate_text=True)
assert isinstance(out, list)
assert out and out[0]["type"] == "text"
assert isinstance(out[0]["text"], str)
assert out[0]["text"] != content[0]["text"]
-423
View File
@@ -1,423 +0,0 @@
"""Tests for advanced EditFileTool enhancements inspired by claude-code:
- Delete-line newline cleanup
- Smart quote normalization (curly straight)
- Quote style preservation in replacements
- Indentation preservation when fallback match is trimmed
- Trailing whitespace stripping for new_text
- File size protection
- Stale detection with content-equality fallback
"""
import os
import time
import pytest
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, _find_match
from nanobot.agent.tools import file_state
@pytest.fixture(autouse=True)
def _clear_file_state():
file_state.clear()
yield
file_state.clear()
# ---------------------------------------------------------------------------
# Delete-line newline cleanup
# ---------------------------------------------------------------------------
class TestDeleteLineCleanup:
"""When new_text='' and deleting a line, trailing newline should be consumed."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_delete_line_consumes_trailing_newline(self, tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("line1\nline2\nline3\n", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="line2", new_text="")
assert "Successfully" in result
content = f.read_text()
# Should not leave a blank line where line2 was
assert content == "line1\nline3\n"
@pytest.mark.asyncio
async def test_delete_line_with_explicit_newline_in_old_text(self, tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("line1\nline2\nline3\n", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="line2\n", new_text="")
assert "Successfully" in result
assert f.read_text() == "line1\nline3\n"
@pytest.mark.asyncio
async def test_delete_preserves_content_when_not_trailing_newline(self, tool, tmp_path):
"""Deleting a word mid-line should not consume extra characters."""
f = tmp_path / "a.py"
f.write_text("hello world here\n", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="world ", new_text="")
assert "Successfully" in result
assert f.read_text() == "hello here\n"
# ---------------------------------------------------------------------------
# Smart quote normalization
# ---------------------------------------------------------------------------
class TestSmartQuoteNormalization:
"""_find_match should handle curly ↔ straight quote fallback."""
def test_curly_double_quotes_match_straight(self):
content = 'She said \u201chello\u201d to him'
old_text = 'She said "hello" to him'
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
# Returned match should be the ORIGINAL content with curly quotes
assert "\u201c" in match
def test_curly_single_quotes_match_straight(self):
content = "it\u2019s a test"
old_text = "it's a test"
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
assert "\u2019" in match
def test_straight_matches_curly_in_old_text(self):
content = 'x = "hello"'
old_text = 'x = \u201chello\u201d'
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
def test_exact_match_still_preferred_over_quote_normalization(self):
content = 'x = "hello"'
old_text = 'x = "hello"'
match, count = _find_match(content, old_text)
assert match == old_text
assert count == 1
class TestQuoteStylePreservation:
"""When quote-normalized matching occurs, replacement should preserve actual quote style."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_replacement_preserves_curly_double_quotes(self, tool, tmp_path):
f = tmp_path / "quotes.txt"
f.write_text('message = “hello”\n', encoding="utf-8")
result = await tool.execute(
path=str(f),
old_text='message = "hello"',
new_text='message = "goodbye"',
)
assert "Successfully" in result
assert f.read_text(encoding="utf-8") == 'message = “goodbye”\n'
@pytest.mark.asyncio
async def test_replacement_preserves_curly_apostrophe(self, tool, tmp_path):
f = tmp_path / "apostrophe.txt"
f.write_text("its fine\n", encoding="utf-8")
result = await tool.execute(
path=str(f),
old_text="it's fine",
new_text="it's better",
)
assert "Successfully" in result
assert f.read_text(encoding="utf-8") == "its better\n"
# ---------------------------------------------------------------------------
# Indentation preservation
# ---------------------------------------------------------------------------
class TestIndentationPreservation:
"""Replacement should keep outer indentation when trim fallback matched."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_trim_fallback_preserves_outer_indentation(self, tool, tmp_path):
f = tmp_path / "indent.py"
f.write_text(
"if True:\n"
" def foo():\n"
" pass\n",
encoding="utf-8",
)
result = await tool.execute(
path=str(f),
old_text="def foo():\n pass",
new_text="def bar():\n return 1",
)
assert "Successfully" in result
assert f.read_text(encoding="utf-8") == (
"if True:\n"
" def bar():\n"
" return 1\n"
)
# ---------------------------------------------------------------------------
# Failure diagnostics
# ---------------------------------------------------------------------------
class TestEditDiagnostics:
"""Failure paths should offer actionable hints."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_ambiguous_match_reports_candidate_lines(self, tool, tmp_path):
f = tmp_path / "dup.py"
f.write_text("aaa\nbbb\naaa\nbbb\n", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="aaa\nbbb", new_text="xxx")
assert "appears 2 times" in result.lower()
assert "line 1" in result.lower()
assert "line 3" in result.lower()
assert "replace_all=true" in result
@pytest.mark.asyncio
async def test_not_found_reports_whitespace_hint(self, tool, tmp_path):
f = tmp_path / "space.py"
f.write_text("value = 1\n", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="value = 1", new_text="value = 2")
assert "Error" in result
assert "whitespace" in result.lower()
@pytest.mark.asyncio
async def test_not_found_reports_case_hint(self, tool, tmp_path):
f = tmp_path / "case.py"
f.write_text("HelloWorld\n", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="helloworld", new_text="goodbye")
assert "Error" in result
assert "letter case differs" in result.lower()
# ---------------------------------------------------------------------------
# Advanced fallback replacement behavior
# ---------------------------------------------------------------------------
class TestAdvancedReplaceAll:
"""replace_all should work correctly for fallback-based matches too."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_replace_all_preserves_each_match_indentation(self, tool, tmp_path):
f = tmp_path / "indent_multi.py"
f.write_text(
"if a:\n"
" def foo():\n"
" pass\n"
"if b:\n"
" def foo():\n"
" pass\n",
encoding="utf-8",
)
result = await tool.execute(
path=str(f),
old_text="def foo():\n pass",
new_text="def bar():\n return 1",
replace_all=True,
)
assert "Successfully" in result
assert f.read_text(encoding="utf-8") == (
"if a:\n"
" def bar():\n"
" return 1\n"
"if b:\n"
" def bar():\n"
" return 1\n"
)
@pytest.mark.asyncio
async def test_trim_and_quote_fallback_match_succeeds(self, tool, tmp_path):
f = tmp_path / "quote_indent.py"
f.write_text(" message = “hello”\n", encoding="utf-8")
result = await tool.execute(
path=str(f),
old_text='message = "hello"',
new_text='message = "goodbye"',
)
assert "Successfully" in result
assert f.read_text(encoding="utf-8") == " message = “goodbye”\n"
# ---------------------------------------------------------------------------
# Advanced fallback replacement behavior
# ---------------------------------------------------------------------------
class TestAdvancedReplaceAll:
"""replace_all should work correctly for fallback-based matches too."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_replace_all_preserves_each_match_indentation(self, tool, tmp_path):
f = tmp_path / "indent_multi.py"
f.write_text(
"if a:\n"
" def foo():\n"
" pass\n"
"if b:\n"
" def foo():\n"
" pass\n",
encoding="utf-8",
)
result = await tool.execute(
path=str(f),
old_text="def foo():\n pass",
new_text="def bar():\n return 1",
replace_all=True,
)
assert "Successfully" in result
assert f.read_text(encoding="utf-8") == (
"if a:\n"
" def bar():\n"
" return 1\n"
"if b:\n"
" def bar():\n"
" return 1\n"
)
@pytest.mark.asyncio
async def test_trim_and_quote_fallback_match_succeeds(self, tool, tmp_path):
f = tmp_path / "quote_indent.py"
f.write_text(" message = “hello”\n", encoding="utf-8")
result = await tool.execute(
path=str(f),
old_text='message = "hello"',
new_text='message = "goodbye"',
)
assert "Successfully" in result
assert f.read_text(encoding="utf-8") == " message = “goodbye”\n"
# ---------------------------------------------------------------------------
# Trailing whitespace stripping on new_text
# ---------------------------------------------------------------------------
class TestTrailingWhitespaceStrip:
"""new_text trailing whitespace should be stripped (except .md files)."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_strips_trailing_whitespace_from_new_text(self, tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("x = 1\n", encoding="utf-8")
result = await tool.execute(
path=str(f), old_text="x = 1", new_text="x = 2 \ny = 3 ",
)
assert "Successfully" in result
content = f.read_text()
assert "x = 2\ny = 3\n" == content
@pytest.mark.asyncio
async def test_preserves_trailing_whitespace_in_markdown(self, tool, tmp_path):
f = tmp_path / "doc.md"
f.write_text("# Title\n", encoding="utf-8")
# Markdown uses trailing double-space for line breaks
result = await tool.execute(
path=str(f), old_text="# Title", new_text="# Title \nSubtitle ",
)
assert "Successfully" in result
content = f.read_text()
# Trailing spaces should be preserved for markdown
assert "Title " in content
assert "Subtitle " in content
# ---------------------------------------------------------------------------
# File size protection
# ---------------------------------------------------------------------------
class TestFileSizeProtection:
"""Editing extremely large files should be rejected."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_rejects_file_over_size_limit(self, tool, tmp_path):
f = tmp_path / "huge.txt"
f.write_text("x", encoding="utf-8")
# Monkey-patch the file size check by creating a stat mock
original_stat = f.stat
class FakeStat:
def __init__(self, real_stat):
self._real = real_stat
def __getattr__(self, name):
return getattr(self._real, name)
@property
def st_size(self):
return 2 * 1024 * 1024 * 1024 # 2 GiB
import unittest.mock
with unittest.mock.patch.object(type(f), 'stat', return_value=FakeStat(f.stat())):
result = await tool.execute(path=str(f), old_text="x", new_text="y")
assert "Error" in result
assert "too large" in result.lower() or "size" in result.lower()
# ---------------------------------------------------------------------------
# Stale detection with content-equality fallback
# ---------------------------------------------------------------------------
class TestStaleDetectionContentFallback:
"""When mtime changed but file content is unchanged, edit should proceed without warning."""
@pytest.fixture()
def read_tool(self, tmp_path):
return ReadFileTool(workspace=tmp_path)
@pytest.fixture()
def edit_tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_mtime_bump_same_content_no_warning(self, read_tool, edit_tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("hello world", encoding="utf-8")
await read_tool.execute(path=str(f))
# Touch the file to bump mtime without changing content
time.sleep(0.05)
original_content = f.read_text()
f.write_text(original_content, encoding="utf-8")
result = await edit_tool.execute(path=str(f), old_text="world", new_text="earth")
assert "Successfully" in result
# Should NOT warn about modification since content is the same
assert "modified" not in result.lower()
-152
View File
@@ -1,152 +0,0 @@
"""Tests for EditFileTool enhancements: read-before-edit tracking, path suggestions,
.ipynb detection, and create-file semantics."""
import pytest
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools import file_state
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clear_file_state():
"""Reset global read-state between tests."""
file_state.clear()
yield
file_state.clear()
# ---------------------------------------------------------------------------
# Read-before-edit tracking
# ---------------------------------------------------------------------------
class TestEditReadTracking:
"""edit_file should warn when file hasn't been read first."""
@pytest.fixture()
def read_tool(self, tmp_path):
return ReadFileTool(workspace=tmp_path)
@pytest.fixture()
def edit_tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_edit_warns_if_file_not_read_first(self, edit_tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("hello world", encoding="utf-8")
result = await edit_tool.execute(path=str(f), old_text="world", new_text="earth")
# Should still succeed but include a warning
assert "Successfully" in result
assert "not been read" in result.lower() or "warning" in result.lower()
@pytest.mark.asyncio
async def test_edit_succeeds_cleanly_after_read(self, read_tool, edit_tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("hello world", encoding="utf-8")
await read_tool.execute(path=str(f))
result = await edit_tool.execute(path=str(f), old_text="world", new_text="earth")
assert "Successfully" in result
# No warning when file was read first
assert "not been read" not in result.lower()
assert f.read_text() == "hello earth"
@pytest.mark.asyncio
async def test_edit_warns_if_file_modified_since_read(self, read_tool, edit_tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("hello world", encoding="utf-8")
await read_tool.execute(path=str(f))
# External modification
f.write_text("hello universe", encoding="utf-8")
result = await edit_tool.execute(path=str(f), old_text="universe", new_text="earth")
assert "Successfully" in result
assert "modified" in result.lower() or "warning" in result.lower()
# ---------------------------------------------------------------------------
# Create-file semantics
# ---------------------------------------------------------------------------
class TestEditCreateFile:
"""edit_file with old_text='' creates new file if not exists."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_create_new_file_with_empty_old_text(self, tool, tmp_path):
f = tmp_path / "subdir" / "new.py"
result = await tool.execute(path=str(f), old_text="", new_text="print('hi')")
assert "created" in result.lower() or "Successfully" in result
assert f.exists()
assert f.read_text() == "print('hi')"
@pytest.mark.asyncio
async def test_create_fails_if_file_already_exists_and_not_empty(self, tool, tmp_path):
f = tmp_path / "existing.py"
f.write_text("existing content", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="", new_text="new content")
assert "Error" in result or "already exists" in result.lower()
# File should be unchanged
assert f.read_text() == "existing content"
@pytest.mark.asyncio
async def test_create_succeeds_if_file_exists_but_empty(self, tool, tmp_path):
f = tmp_path / "empty.py"
f.write_text("", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="", new_text="print('hi')")
assert "Successfully" in result
assert f.read_text() == "print('hi')"
# ---------------------------------------------------------------------------
# .ipynb detection
# ---------------------------------------------------------------------------
class TestEditIpynbDetection:
"""edit_file should refuse .ipynb and suggest notebook_edit."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_ipynb_rejected_with_suggestion(self, tool, tmp_path):
f = tmp_path / "analysis.ipynb"
f.write_text('{"cells": []}', encoding="utf-8")
result = await tool.execute(path=str(f), old_text="x", new_text="y")
assert "notebook" in result.lower()
# ---------------------------------------------------------------------------
# Path suggestion on not-found
# ---------------------------------------------------------------------------
class TestEditPathSuggestion:
"""edit_file should suggest similar paths on not-found."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_suggests_similar_filename(self, tool, tmp_path):
f = tmp_path / "config.py"
f.write_text("x = 1", encoding="utf-8")
# Typo: conifg.py
result = await tool.execute(
path=str(tmp_path / "conifg.py"), old_text="x = 1", new_text="x = 2",
)
assert "Error" in result
assert "config.py" in result
@pytest.mark.asyncio
async def test_shows_cwd_in_error(self, tool, tmp_path):
result = await tool.execute(
path=str(tmp_path / "nonexistent.py"), old_text="a", new_text="b",
)
assert "Error" in result
-31
View File
@@ -43,34 +43,3 @@ async def test_exec_path_append_preserves_system_path():
tool = ExecTool(path_append="/opt/custom/bin") tool = ExecTool(path_append="/opt/custom/bin")
result = await tool.execute(command="ls /") result = await tool.execute(command="ls /")
assert "Exit code: 0" in result assert "Exit code: 0" in result
@_UNIX_ONLY
@pytest.mark.asyncio
async def test_exec_allowed_env_keys_passthrough(monkeypatch):
"""Env vars listed in allowed_env_keys should be visible to commands."""
monkeypatch.setenv("MY_CUSTOM_VAR", "hello-from-config")
tool = ExecTool(allowed_env_keys=["MY_CUSTOM_VAR"])
result = await tool.execute(command="printenv MY_CUSTOM_VAR")
assert "hello-from-config" in result
@_UNIX_ONLY
@pytest.mark.asyncio
async def test_exec_allowed_env_keys_does_not_leak_others(monkeypatch):
"""Env vars NOT in allowed_env_keys should still be blocked."""
monkeypatch.setenv("MY_CUSTOM_VAR", "hello-from-config")
monkeypatch.setenv("MY_SECRET_VAR", "secret-value")
tool = ExecTool(allowed_env_keys=["MY_CUSTOM_VAR"])
result = await tool.execute(command="printenv MY_SECRET_VAR")
assert "secret-value" not in result
@_UNIX_ONLY
@pytest.mark.asyncio
async def test_exec_allowed_env_keys_missing_var_ignored(monkeypatch):
"""If an allowed key is not set in the parent process, it should be silently skipped."""
monkeypatch.delenv("NONEXISTENT_VAR_12345", raising=False)
tool = ExecTool(allowed_env_keys=["NONEXISTENT_VAR_12345"])
result = await tool.execute(command="printenv NONEXISTENT_VAR_12345")
assert "Exit code: 1" in result
+1 -11
View File
@@ -5,18 +5,12 @@ strategy, and sandbox behaviour per platform — without actually running
platform-specific binaries (all subprocess calls are mocked). platform-specific binaries (all subprocess calls are mocked).
""" """
import sys
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
_WINDOWS_ENV_KEYS = {
"APPDATA", "LOCALAPPDATA", "ProgramData",
"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _build_env # _build_env
@@ -27,10 +21,7 @@ class TestBuildEnvUnix:
def test_expected_keys(self): def test_expected_keys(self):
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False): with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
env = ExecTool()._build_env() env = ExecTool()._build_env()
expected = {"HOME", "LANG", "TERM"} assert set(env) == {"HOME", "LANG", "TERM"}
assert expected <= set(env)
if sys.platform != "win32":
assert set(env) == expected
def test_home_from_environ(self, monkeypatch): def test_home_from_environ(self, monkeypatch):
monkeypatch.setenv("HOME", "/Users/dev") monkeypatch.setenv("HOME", "/Users/dev")
@@ -54,7 +45,6 @@ class TestBuildEnvWindows:
_EXPECTED_KEYS = { _EXPECTED_KEYS = {
"SYSTEMROOT", "COMSPEC", "USERPROFILE", "HOMEDRIVE", "SYSTEMROOT", "COMSPEC", "USERPROFILE", "HOMEDRIVE",
"HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH", "HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH",
*_WINDOWS_ENV_KEYS,
} }
def test_expected_keys(self): def test_expected_keys(self):
-115
View File
@@ -67,118 +67,3 @@ async def test_exec_blocks_chained_internal_url():
command="echo start && curl http://169.254.169.254/latest/meta-data/ && echo done" command="echo start && curl http://169.254.169.254/latest/meta-data/ && echo done"
) )
assert "Error" in result assert "Error" in result
# --- #2989: block writes to nanobot internal state files -----------------
@pytest.mark.parametrize(
"command",
[
"cat foo >> history.jsonl",
"echo '{}' > history.jsonl",
"echo '{}' > memory/history.jsonl",
"echo '{}' > ./workspace/memory/history.jsonl",
"tee -a history.jsonl < foo",
"tee history.jsonl",
"cp /tmp/fake.jsonl history.jsonl",
"mv backup.jsonl memory/history.jsonl",
"dd if=/dev/zero of=memory/history.jsonl",
"sed -i 's/old/new/' history.jsonl",
"echo x > .dream_cursor",
"cp /tmp/x memory/.dream_cursor",
],
)
def test_exec_blocks_writes_to_history_jsonl(command):
"""Direct writes to history.jsonl / .dream_cursor must be blocked (#2989)."""
tool = ExecTool()
result = tool._guard_command(command, "/tmp")
assert result is not None
assert "dangerous pattern" in result.lower()
@pytest.mark.parametrize(
"command",
[
"cat history.jsonl",
"wc -l history.jsonl",
"tail -n 5 history.jsonl",
"grep foo history.jsonl",
"cp history.jsonl /tmp/history.backup",
"ls memory/",
"echo history.jsonl",
],
)
def test_exec_allows_reads_of_history_jsonl(command):
"""Read-only access to history.jsonl must still be allowed."""
tool = ExecTool()
result = tool._guard_command(command, "/tmp")
assert result is None
# --- #2826: working_dir must not escape the configured workspace ---------
@pytest.mark.asyncio
async def test_exec_blocks_working_dir_outside_workspace(tmp_path):
"""An LLM-supplied working_dir outside the workspace must be rejected."""
workspace = tmp_path / "workspace"
workspace.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
result = await tool.execute(command="rm calendar.ics", working_dir="/etc")
assert "outside the configured workspace" in result
@pytest.mark.asyncio
async def test_exec_blocks_absolute_rm_via_hijacked_working_dir(tmp_path):
"""Regression for #2826: `rm /abs/path` via working_dir hijack."""
workspace = tmp_path / "workspace"
workspace.mkdir()
victim_dir = tmp_path / "outside"
victim_dir.mkdir()
victim = victim_dir / "file.ics"
victim.write_text("data")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
result = await tool.execute(
command=f"rm {victim}",
working_dir=str(victim_dir),
)
assert "outside the configured workspace" in result
assert victim.exists(), "victim file must not have been deleted"
@pytest.mark.asyncio
async def test_exec_allows_working_dir_within_workspace(tmp_path):
"""A working_dir that is a subdirectory of the workspace is fine."""
workspace = tmp_path / "workspace"
subdir = workspace / "project"
subdir.mkdir(parents=True)
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5)
result = await tool.execute(command="echo ok", working_dir=str(subdir))
assert "ok" in result
assert "outside the configured workspace" not in result
@pytest.mark.asyncio
async def test_exec_allows_working_dir_equal_to_workspace(tmp_path):
"""Passing working_dir equal to the workspace root must be allowed."""
workspace = tmp_path / "workspace"
workspace.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5)
result = await tool.execute(command="echo ok", working_dir=str(workspace))
assert "ok" in result
assert "outside the configured workspace" not in result
@pytest.mark.asyncio
async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path):
"""Without restrict_to_workspace, the LLM may still choose any working_dir."""
workspace = tmp_path / "workspace"
workspace.mkdir()
other = tmp_path / "other"
other.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=False, timeout=5)
result = await tool.execute(command="echo ok", working_dir=str(other))
assert "ok" in result
assert "outside the configured workspace" not in result
+47 -365
View File
@@ -7,12 +7,7 @@ from types import ModuleType, SimpleNamespace
import pytest import pytest
from nanobot.agent.tools.mcp import ( from nanobot.agent.tools.mcp import MCPToolWrapper, connect_mcp_servers
MCPResourceWrapper,
MCPPromptWrapper,
MCPToolWrapper,
connect_mcp_servers,
)
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import MCPServerConfig from nanobot.config.schema import MCPServerConfig
@@ -22,16 +17,6 @@ class _FakeTextContent:
self.text = text self.text = text
class _FakeTextResourceContents:
def __init__(self, text: str) -> None:
self.text = text
class _FakeBlobResourceContents:
def __init__(self, blob: bytes) -> None:
self.blob = blob
@pytest.fixture @pytest.fixture
def fake_mcp_runtime() -> dict[str, object | None]: def fake_mcp_runtime() -> dict[str, object | None]:
return {"session": None} return {"session": None}
@@ -42,11 +27,7 @@ def _fake_mcp_module(
monkeypatch: pytest.MonkeyPatch, fake_mcp_runtime: dict[str, object | None] monkeypatch: pytest.MonkeyPatch, fake_mcp_runtime: dict[str, object | None]
) -> None: ) -> None:
mod = ModuleType("mcp") mod = ModuleType("mcp")
mod.types = SimpleNamespace( mod.types = SimpleNamespace(TextContent=_FakeTextContent)
TextContent=_FakeTextContent,
TextResourceContents=_FakeTextResourceContents,
BlobResourceContents=_FakeBlobResourceContents,
)
class _FakeStdioServerParameters: class _FakeStdioServerParameters:
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None: def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
@@ -93,18 +74,6 @@ def _fake_mcp_module(
monkeypatch.setitem(sys.modules, "mcp.client.sse", sse_mod) monkeypatch.setitem(sys.modules, "mcp.client.sse", sse_mod)
monkeypatch.setitem(sys.modules, "mcp.client.streamable_http", streamable_http_mod) monkeypatch.setitem(sys.modules, "mcp.client.streamable_http", streamable_http_mod)
shared_mod = ModuleType("mcp.shared")
exc_mod = ModuleType("mcp.shared.exceptions")
class _FakeMcpError(Exception):
def __init__(self, code: int = -1, message: str = "error"):
self.error = SimpleNamespace(code=code, message=message)
super().__init__(message)
exc_mod.McpError = _FakeMcpError
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper: def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
tool_def = SimpleNamespace( tool_def = SimpleNamespace(
@@ -271,11 +240,15 @@ async def test_connect_mcp_servers_enabled_tools_supports_raw_names(
) -> None: ) -> None:
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"]) fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
registry = ToolRegistry() registry = ToolRegistry()
stacks = await connect_mcp_servers( stack = AsyncExitStack()
{"test": MCPServerConfig(command="fake", enabled_tools=["demo"])}, await stack.__aenter__()
registry, try:
) await connect_mcp_servers(
for stack in stacks.values(): {"test": MCPServerConfig(command="fake", enabled_tools=["demo"])},
registry,
stack,
)
finally:
await stack.aclose() await stack.aclose()
assert registry.tool_names == ["mcp_test_demo"] assert registry.tool_names == ["mcp_test_demo"]
@@ -287,11 +260,15 @@ async def test_connect_mcp_servers_enabled_tools_defaults_to_all(
) -> None: ) -> None:
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"]) fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
registry = ToolRegistry() registry = ToolRegistry()
stacks = await connect_mcp_servers( stack = AsyncExitStack()
{"test": MCPServerConfig(command="fake")}, await stack.__aenter__()
registry, try:
) await connect_mcp_servers(
for stack in stacks.values(): {"test": MCPServerConfig(command="fake")},
registry,
stack,
)
finally:
await stack.aclose() await stack.aclose()
assert registry.tool_names == ["mcp_test_demo", "mcp_test_other"] assert registry.tool_names == ["mcp_test_demo", "mcp_test_other"]
@@ -303,11 +280,15 @@ async def test_connect_mcp_servers_enabled_tools_supports_wrapped_names(
) -> None: ) -> None:
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"]) fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
registry = ToolRegistry() registry = ToolRegistry()
stacks = await connect_mcp_servers( stack = AsyncExitStack()
{"test": MCPServerConfig(command="fake", enabled_tools=["mcp_test_demo"])}, await stack.__aenter__()
registry, try:
) await connect_mcp_servers(
for stack in stacks.values(): {"test": MCPServerConfig(command="fake", enabled_tools=["mcp_test_demo"])},
registry,
stack,
)
finally:
await stack.aclose() await stack.aclose()
assert registry.tool_names == ["mcp_test_demo"] assert registry.tool_names == ["mcp_test_demo"]
@@ -319,11 +300,15 @@ async def test_connect_mcp_servers_enabled_tools_empty_list_registers_none(
) -> None: ) -> None:
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"]) fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
registry = ToolRegistry() registry = ToolRegistry()
stacks = await connect_mcp_servers( stack = AsyncExitStack()
{"test": MCPServerConfig(command="fake", enabled_tools=[])}, await stack.__aenter__()
registry, try:
) await connect_mcp_servers(
for stack in stacks.values(): {"test": MCPServerConfig(command="fake", enabled_tools=[])},
registry,
stack,
)
finally:
await stack.aclose() await stack.aclose()
assert registry.tool_names == [] assert registry.tool_names == []
@@ -342,11 +327,15 @@ async def test_connect_mcp_servers_enabled_tools_warns_on_unknown_entries(
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.warning", _warning) monkeypatch.setattr("nanobot.agent.tools.mcp.logger.warning", _warning)
stacks = await connect_mcp_servers( stack = AsyncExitStack()
{"test": MCPServerConfig(command="fake", enabled_tools=["unknown"])}, await stack.__aenter__()
registry, try:
) await connect_mcp_servers(
for stack in stacks.values(): {"test": MCPServerConfig(command="fake", enabled_tools=["unknown"])},
registry,
stack,
)
finally:
await stack.aclose() await stack.aclose()
assert registry.tool_names == [] assert registry.tool_names == []
@@ -354,310 +343,3 @@ async def test_connect_mcp_servers_enabled_tools_warns_on_unknown_entries(
assert "enabledTools entries not found: unknown" in warnings[-1] assert "enabledTools entries not found: unknown" in warnings[-1]
assert "Available raw names: demo" in warnings[-1] assert "Available raw names: demo" in warnings[-1]
assert "Available wrapped names: mcp_test_demo" in warnings[-1] assert "Available wrapped names: mcp_test_demo" in warnings[-1]
@pytest.mark.asyncio
async def test_connect_mcp_servers_logs_stdio_pollution_hint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
messages: list[str] = []
def _error(message: str, *args: object) -> None:
messages.append(message.format(*args))
@asynccontextmanager
async def _broken_stdio_client(_params: object):
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
yield # pragma: no cover
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.error", _error)
registry = ToolRegistry()
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)
assert stacks == {}
assert messages
assert "stdio protocol pollution" in messages[-1]
assert "stdout" in messages[-1]
assert "stderr" in messages[-1]
@pytest.mark.asyncio
async def test_connect_mcp_servers_one_failure_does_not_block_others(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sessions = {"good": _make_fake_session(["demo"])}
class _SelectiveClientSession:
def __init__(self, read: object, _write: object) -> None:
self._session = sessions[read]
async def __aenter__(self) -> object:
return self._session
async def __aexit__(self, exc_type, exc, tb) -> bool:
return False
@asynccontextmanager
async def _selective_stdio_client(params: object):
if params.command == "bad":
raise RuntimeError("boom")
yield params.command, object()
monkeypatch.setattr(sys.modules["mcp"], "ClientSession", _SelectiveClientSession)
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _selective_stdio_client)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{
"good": MCPServerConfig(command="good"),
"bad": MCPServerConfig(command="bad"),
},
registry,
)
for stack in stacks.values():
await stack.aclose()
assert registry.tool_names == ["mcp_good_demo"]
assert set(stacks) == {"good"}
# ---------------------------------------------------------------------------
# MCPResourceWrapper tests
# ---------------------------------------------------------------------------
def _make_resource_def(
name: str = "myres",
uri: str = "file:///tmp/data.txt",
description: str = "A test resource",
) -> SimpleNamespace:
return SimpleNamespace(name=name, uri=uri, description=description)
def _make_resource_wrapper(session: object, *, timeout: float = 0.1) -> MCPResourceWrapper:
return MCPResourceWrapper(session, "srv", _make_resource_def(), resource_timeout=timeout)
def test_resource_wrapper_properties() -> None:
wrapper = MCPResourceWrapper(None, "myserver", _make_resource_def())
assert wrapper.name == "mcp_myserver_resource_myres"
assert "[MCP Resource]" in wrapper.description
assert "A test resource" in wrapper.description
assert "file:///tmp/data.txt" in wrapper.description
assert wrapper.parameters == {"type": "object", "properties": {}, "required": []}
assert wrapper.read_only is True
@pytest.mark.asyncio
async def test_resource_wrapper_execute_returns_text() -> None:
async def read_resource(uri: str) -> object:
assert uri == "file:///tmp/data.txt"
return SimpleNamespace(
contents=[_FakeTextResourceContents("line1"), _FakeTextResourceContents("line2")]
)
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
result = await wrapper.execute()
assert result == "line1\nline2"
@pytest.mark.asyncio
async def test_resource_wrapper_execute_handles_blob() -> None:
async def read_resource(uri: str) -> object:
return SimpleNamespace(contents=[_FakeBlobResourceContents(b"\x00\x01\x02")])
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
result = await wrapper.execute()
assert "[Binary resource: 3 bytes]" in result
@pytest.mark.asyncio
async def test_resource_wrapper_execute_handles_timeout() -> None:
async def read_resource(uri: str) -> object:
await asyncio.sleep(1)
return SimpleNamespace(contents=[])
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource), timeout=0.01)
result = await wrapper.execute()
assert result == "(MCP resource read timed out after 0.01s)"
@pytest.mark.asyncio
async def test_resource_wrapper_execute_handles_error() -> None:
async def read_resource(uri: str) -> object:
raise RuntimeError("boom")
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
result = await wrapper.execute()
assert result == "(MCP resource read failed: RuntimeError)"
# ---------------------------------------------------------------------------
# MCPPromptWrapper tests
# ---------------------------------------------------------------------------
def _make_prompt_def(
name: str = "myprompt",
description: str = "A test prompt",
arguments: list | None = None,
) -> SimpleNamespace:
return SimpleNamespace(name=name, description=description, arguments=arguments)
def _make_prompt_wrapper(session: object, *, timeout: float = 0.1) -> MCPPromptWrapper:
return MCPPromptWrapper(session, "srv", _make_prompt_def(), prompt_timeout=timeout)
def test_prompt_wrapper_properties() -> None:
arg1 = SimpleNamespace(name="topic", required=True)
arg2 = SimpleNamespace(name="style", required=False)
wrapper = MCPPromptWrapper(None, "myserver", _make_prompt_def(arguments=[arg1, arg2]))
assert wrapper.name == "mcp_myserver_prompt_myprompt"
assert "[MCP Prompt]" in wrapper.description
assert "A test prompt" in wrapper.description
assert "workflow guide" in wrapper.description
assert wrapper.parameters["properties"]["topic"] == {"type": "string"}
assert wrapper.parameters["properties"]["style"] == {"type": "string"}
assert wrapper.parameters["required"] == ["topic"]
assert wrapper.read_only is True
def test_prompt_wrapper_no_arguments() -> None:
wrapper = MCPPromptWrapper(None, "myserver", _make_prompt_def())
assert wrapper.parameters == {"type": "object", "properties": {}, "required": []}
def test_prompt_wrapper_preserves_argument_descriptions() -> None:
arg = SimpleNamespace(name="topic", required=True, description="The subject to discuss")
wrapper = MCPPromptWrapper(None, "srv", _make_prompt_def(arguments=[arg]))
assert wrapper.parameters["properties"]["topic"] == {
"type": "string",
"description": "The subject to discuss",
}
@pytest.mark.asyncio
async def test_prompt_wrapper_execute_returns_text() -> None:
async def get_prompt(name: str, arguments: dict | None = None) -> object:
assert name == "myprompt"
msg1 = SimpleNamespace(
role="user",
content=[_FakeTextContent("You are an expert on {{topic}}.")],
)
msg2 = SimpleNamespace(
role="assistant",
content=[_FakeTextContent("Understood. Ask me anything.")],
)
return SimpleNamespace(messages=[msg1, msg2])
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
result = await wrapper.execute(topic="AI")
assert "You are an expert on {{topic}}." in result
assert "Understood. Ask me anything." in result
@pytest.mark.asyncio
async def test_prompt_wrapper_execute_handles_timeout() -> None:
async def get_prompt(name: str, arguments: dict | None = None) -> object:
await asyncio.sleep(1)
return SimpleNamespace(messages=[])
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt), timeout=0.01)
result = await wrapper.execute()
assert result == "(MCP prompt call timed out after 0.01s)"
@pytest.mark.asyncio
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
from mcp.shared.exceptions import McpError
async def get_prompt(name: str, arguments: dict | None = None) -> object:
raise McpError(code=42, message="invalid argument")
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
result = await wrapper.execute()
assert "invalid argument" in result
assert "code 42" in result
@pytest.mark.asyncio
async def test_prompt_wrapper_execute_handles_error() -> None:
async def get_prompt(name: str, arguments: dict | None = None) -> object:
raise RuntimeError("boom")
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
result = await wrapper.execute()
assert result == "(MCP prompt call failed: RuntimeError)"
# ---------------------------------------------------------------------------
# connect_mcp_servers: resources + prompts integration
# ---------------------------------------------------------------------------
def _make_fake_session_with_capabilities(
tool_names: list[str],
resource_names: list[str] | None = None,
prompt_names: list[str] | None = None,
) -> SimpleNamespace:
async def initialize() -> None:
return None
async def list_tools() -> SimpleNamespace:
return SimpleNamespace(tools=[_make_tool_def(name) for name in tool_names])
async def list_resources() -> SimpleNamespace:
resources = []
for rname in resource_names or []:
resources.append(
SimpleNamespace(
name=rname,
uri=f"file:///{rname}",
description=f"{rname} resource",
)
)
return SimpleNamespace(resources=resources)
async def list_prompts() -> SimpleNamespace:
prompts = []
for pname in prompt_names or []:
prompts.append(
SimpleNamespace(
name=pname,
description=f"{pname} prompt",
arguments=None,
)
)
return SimpleNamespace(prompts=prompts)
return SimpleNamespace(
initialize=initialize,
list_tools=list_tools,
list_resources=list_resources,
list_prompts=list_prompts,
)
@pytest.mark.asyncio
async def test_connect_registers_resources_and_prompts(
fake_mcp_runtime: dict[str, object | None],
) -> None:
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
tool_names=["tool_a"],
resource_names=["res_b"],
prompt_names=["prompt_c"],
)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"test": MCPServerConfig(command="fake")},
registry,
)
for stack in stacks.values():
await stack.aclose()
assert "mcp_test_tool_a" in registry.tool_names
assert "mcp_test_resource_res_b" in registry.tool_names
assert "mcp_test_prompt_prompt_c" in registry.tool_names
-37
View File
@@ -1,6 +1,5 @@
"""Test message tool suppress logic for final replies.""" """Test message tool suppress logic for final replies."""
import asyncio
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -87,42 +86,6 @@ class TestMessageToolSuppressLogic:
assert result is not None assert result is not None
assert "Hello" in result.content assert "Hello" in result.content
@pytest.mark.asyncio
async def test_injected_followup_with_message_tool_does_not_emit_empty_fallback(
self, tmp_path: Path
) -> None:
loop = _make_loop(tmp_path)
tool_call = ToolCallRequest(
id="call1", name="message",
arguments={"content": "Tool reply", "channel": "feishu", "chat_id": "chat123"},
)
calls = iter([
LLMResponse(content="First answer", tool_calls=[]),
LLMResponse(content="", tool_calls=[tool_call]),
LLMResponse(content="", tool_calls=[]),
LLMResponse(content="", tool_calls=[]),
LLMResponse(content="", tool_calls=[]),
])
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
sent: list[OutboundMessage] = []
mt = loop.tools.get("message")
if isinstance(mt, MessageTool):
mt.set_send_callback(AsyncMock(side_effect=lambda m: sent.append(m)))
pending_queue = asyncio.Queue()
await pending_queue.put(
InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="follow-up")
)
msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Start")
result = await loop._process_message(msg, pending_queue=pending_queue)
assert len(sent) == 1
assert sent[0].content == "Tool reply"
assert result is None
async def test_progress_hides_internal_reasoning(self, tmp_path: Path) -> None: async def test_progress_hides_internal_reasoning(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path) loop = _make_loop(tmp_path)
tool_call = ToolCallRequest(id="call1", name="read_file", arguments={"path": "foo.txt"}) tool_call = ToolCallRequest(id="call1", name="read_file", arguments={"path": "foo.txt"})
-147
View File
@@ -1,147 +0,0 @@
"""Tests for NotebookEditTool — Jupyter .ipynb editing."""
import json
import pytest
from nanobot.agent.tools.notebook import NotebookEditTool
def _make_notebook(cells: list[dict] | None = None, nbformat: int = 4, nbformat_minor: int = 5) -> dict:
"""Build a minimal valid .ipynb structure."""
return {
"nbformat": nbformat,
"nbformat_minor": nbformat_minor,
"metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}},
"cells": cells or [],
}
def _code_cell(source: str, cell_id: str | None = None) -> dict:
cell = {"cell_type": "code", "source": source, "metadata": {}, "outputs": [], "execution_count": None}
if cell_id:
cell["id"] = cell_id
return cell
def _md_cell(source: str, cell_id: str | None = None) -> dict:
cell = {"cell_type": "markdown", "source": source, "metadata": {}}
if cell_id:
cell["id"] = cell_id
return cell
def _write_nb(tmp_path, name: str, nb: dict) -> str:
p = tmp_path / name
p.write_text(json.dumps(nb), encoding="utf-8")
return str(p)
class TestNotebookEdit:
@pytest.fixture()
def tool(self, tmp_path):
return NotebookEditTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_replace_cell_content(self, tool, tmp_path):
nb = _make_notebook([_code_cell("print('hello')"), _code_cell("x = 1")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="print('world')")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["cells"][0]["source"] == "print('world')"
assert saved["cells"][1]["source"] == "x = 1"
@pytest.mark.asyncio
async def test_insert_cell_after_target(self, tool, tmp_path):
nb = _make_notebook([_code_cell("cell 0"), _code_cell("cell 1")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="inserted", edit_mode="insert")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert len(saved["cells"]) == 3
assert saved["cells"][0]["source"] == "cell 0"
assert saved["cells"][1]["source"] == "inserted"
assert saved["cells"][2]["source"] == "cell 1"
@pytest.mark.asyncio
async def test_delete_cell(self, tool, tmp_path):
nb = _make_notebook([_code_cell("A"), _code_cell("B"), _code_cell("C")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=1, edit_mode="delete")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert len(saved["cells"]) == 2
assert saved["cells"][0]["source"] == "A"
assert saved["cells"][1]["source"] == "C"
@pytest.mark.asyncio
async def test_create_new_notebook_from_scratch(self, tool, tmp_path):
path = str(tmp_path / "new.ipynb")
result = await tool.execute(path=path, cell_index=0, new_source="# Hello", edit_mode="insert", cell_type="markdown")
assert "Successfully" in result or "created" in result.lower()
saved = json.loads((tmp_path / "new.ipynb").read_text())
assert saved["nbformat"] == 4
assert len(saved["cells"]) == 1
assert saved["cells"][0]["cell_type"] == "markdown"
assert saved["cells"][0]["source"] == "# Hello"
@pytest.mark.asyncio
async def test_invalid_cell_index_error(self, tool, tmp_path):
nb = _make_notebook([_code_cell("only cell")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=5, new_source="x")
assert "Error" in result
@pytest.mark.asyncio
async def test_non_ipynb_rejected(self, tool, tmp_path):
f = tmp_path / "script.py"
f.write_text("pass")
result = await tool.execute(path=str(f), cell_index=0, new_source="x")
assert "Error" in result
assert ".ipynb" in result
@pytest.mark.asyncio
async def test_preserves_metadata_and_outputs(self, tool, tmp_path):
cell = _code_cell("old")
cell["outputs"] = [{"output_type": "stream", "text": "hello\n"}]
cell["execution_count"] = 42
nb = _make_notebook([cell])
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="new")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["metadata"]["kernelspec"]["language"] == "python"
@pytest.mark.asyncio
async def test_nbformat_45_generates_cell_id(self, tool, tmp_path):
nb = _make_notebook([], nbformat_minor=5)
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="x = 1", edit_mode="insert")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert "id" in saved["cells"][0]
assert len(saved["cells"][0]["id"]) > 0
@pytest.mark.asyncio
async def test_insert_with_cell_type_markdown(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="# Title", edit_mode="insert", cell_type="markdown")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["cells"][1]["cell_type"] == "markdown"
@pytest.mark.asyncio
async def test_invalid_edit_mode_rejected(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="x", edit_mode="replcae")
assert "Error" in result
assert "edit_mode" in result
@pytest.mark.asyncio
async def test_invalid_cell_type_rejected(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="x", cell_type="raw")
assert "Error" in result
assert "cell_type" in result
-180
View File
@@ -1,180 +0,0 @@
"""Tests for ReadFileTool enhancements: description fix, read dedup, PDF support, device blacklist."""
import pytest
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
from nanobot.agent.tools import file_state
@pytest.fixture(autouse=True)
def _clear_file_state():
file_state.clear()
yield
file_state.clear()
# ---------------------------------------------------------------------------
# Description fix
# ---------------------------------------------------------------------------
class TestReadDescriptionFix:
def test_description_mentions_image_support(self):
tool = ReadFileTool()
assert "image" in tool.description.lower()
def test_description_no_longer_says_cannot_read_images(self):
tool = ReadFileTool()
assert "cannot read binary files or images" not in tool.description.lower()
# ---------------------------------------------------------------------------
# Read deduplication
# ---------------------------------------------------------------------------
class TestReadDedup:
"""Same file + same offset/limit + unchanged mtime -> short stub."""
@pytest.fixture()
def tool(self, tmp_path):
return ReadFileTool(workspace=tmp_path)
@pytest.fixture()
def write_tool(self, tmp_path):
return WriteFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_second_read_returns_unchanged_stub(self, tool, tmp_path):
f = tmp_path / "data.txt"
f.write_text("\n".join(f"line {i}" for i in range(100)), encoding="utf-8")
first = await tool.execute(path=str(f))
assert "line 0" in first
second = await tool.execute(path=str(f))
assert "unchanged" in second.lower()
# Stub should not contain file content
assert "line 0" not in second
@pytest.mark.asyncio
async def test_read_after_external_modification_returns_full(self, tool, tmp_path):
f = tmp_path / "data.txt"
f.write_text("original", encoding="utf-8")
await tool.execute(path=str(f))
# Modify the file externally
f.write_text("modified content", encoding="utf-8")
second = await tool.execute(path=str(f))
assert "modified content" in second
@pytest.mark.asyncio
async def test_different_offset_returns_full(self, tool, tmp_path):
f = tmp_path / "data.txt"
f.write_text("\n".join(f"line {i}" for i in range(1, 21)), encoding="utf-8")
await tool.execute(path=str(f), offset=1, limit=5)
second = await tool.execute(path=str(f), offset=6, limit=5)
# Different offset → full read, not stub
assert "line 6" in second
@pytest.mark.asyncio
async def test_first_read_after_write_returns_full_content(self, tool, write_tool, tmp_path):
f = tmp_path / "fresh.txt"
result = await write_tool.execute(path=str(f), content="hello")
assert "Successfully" in result
read_result = await tool.execute(path=str(f))
assert "hello" in read_result
assert "unchanged" not in read_result.lower()
@pytest.mark.asyncio
async def test_dedup_does_not_apply_to_images(self, tool, tmp_path):
f = tmp_path / "img.png"
f.write_bytes(b"\x89PNG\r\n\x1a\nfake-png-data")
first = await tool.execute(path=str(f))
assert isinstance(first, list)
second = await tool.execute(path=str(f))
# Images should always return full content blocks, not a stub
assert isinstance(second, list)
# ---------------------------------------------------------------------------
# PDF support
# ---------------------------------------------------------------------------
class TestReadPdf:
@pytest.fixture()
def tool(self, tmp_path):
return ReadFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_pdf_returns_text_content(self, tool, tmp_path):
fitz = pytest.importorskip("fitz")
pdf_path = tmp_path / "test.pdf"
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), "Hello PDF World")
doc.save(str(pdf_path))
doc.close()
result = await tool.execute(path=str(pdf_path))
assert "Hello PDF World" in result
@pytest.mark.asyncio
async def test_pdf_pages_parameter(self, tool, tmp_path):
fitz = pytest.importorskip("fitz")
pdf_path = tmp_path / "multi.pdf"
doc = fitz.open()
for i in range(5):
page = doc.new_page()
page.insert_text((72, 72), f"Page {i + 1} content")
doc.save(str(pdf_path))
doc.close()
result = await tool.execute(path=str(pdf_path), pages="2-3")
assert "Page 2 content" in result
assert "Page 3 content" in result
assert "Page 1 content" not in result
@pytest.mark.asyncio
async def test_pdf_file_not_found_error(self, tool, tmp_path):
result = await tool.execute(path=str(tmp_path / "nope.pdf"))
assert "Error" in result
assert "not found" in result
# ---------------------------------------------------------------------------
# Device path blacklist
# ---------------------------------------------------------------------------
class TestReadDeviceBlacklist:
@pytest.fixture()
def tool(self):
return ReadFileTool()
@pytest.mark.asyncio
async def test_dev_random_blocked(self, tool):
result = await tool.execute(path="/dev/random")
assert "Error" in result
assert "blocked" in result.lower() or "device" in result.lower()
@pytest.mark.asyncio
async def test_dev_urandom_blocked(self, tool):
result = await tool.execute(path="/dev/urandom")
assert "Error" in result
@pytest.mark.asyncio
async def test_dev_zero_blocked(self, tool):
result = await tool.execute(path="/dev/zero")
assert "Error" in result
@pytest.mark.asyncio
async def test_proc_fd_blocked(self, tool):
result = await tool.execute(path="/proc/self/fd/0")
assert "Error" in result
@pytest.mark.asyncio
async def test_symlink_to_dev_zero_blocked(self, tmp_path):
tool = ReadFileTool(workspace=tmp_path)
link = tmp_path / "zero-link"
link.symlink_to("/dev/zero")
result = await tool.execute(path=str(link))
assert "Error" in result
assert "blocked" in result.lower() or "device" in result.lower()
+12 -26
View File
@@ -172,6 +172,15 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path:
(tmp_path / "src" / name).write_text("needle\n", encoding="utf-8") (tmp_path / "src" / name).write_text("needle\n", encoding="utf-8")
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path) tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
# Get the full (unpaginated) list to determine the expected ordering.
full_result = await tool.execute(
pattern="needle",
path="src",
head_limit=0,
)
all_files = full_result.splitlines()
result = await tool.execute( result = await tool.execute(
pattern="needle", pattern="needle",
path="src", path="src",
@@ -179,8 +188,9 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path:
offset=1, offset=1,
) )
lines = result.splitlines() lines = [l for l in result.splitlines() if l and not l.startswith("(pagination")]
assert lines[0] == "src/b.py" assert len(lines) == 1
assert lines[0] == all_files[1]
assert "pagination: limit=1, offset=1" in result assert "pagination: limit=1, offset=1" in result
@@ -323,27 +333,3 @@ async def test_subagent_registers_grep_and_glob(tmp_path: Path) -> None:
assert "grep" in captured["tool_names"] assert "grep" in captured["tool_names"]
assert "glob" in captured["tool_names"] assert "glob" in captured["tool_names"]
def test_subagent_prompt_respects_disabled_skills(tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
skills_dir = tmp_path / "skills"
(skills_dir / "alpha").mkdir(parents=True)
(skills_dir / "alpha" / "SKILL.md").write_text("# Alpha\n\nhidden\n", encoding="utf-8")
(skills_dir / "beta").mkdir(parents=True)
(skills_dir / "beta" / "SKILL.md").write_text("# Beta\n\nshown\n", encoding="utf-8")
mgr = SubagentManager(
provider=provider,
workspace=tmp_path,
bus=bus,
max_tool_result_chars=4096,
disabled_skills=["alpha"],
)
prompt = mgr._build_subagent_prompt()
assert "alpha" not in prompt
assert "beta" in prompt
-24
View File
@@ -47,27 +47,3 @@ def test_get_definitions_orders_builtins_then_mcp_tools() -> None:
"mcp_fs_list", "mcp_fs_list",
"mcp_git_status", "mcp_git_status",
] ]
def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("read_file"))
tool, params, error = registry.prepare_call("read_file", ["foo.txt"])
assert tool is None
assert params == ["foo.txt"]
assert error is not None
assert "must be a JSON object" in error
assert "Use named parameters" in error
def test_prepare_call_other_tools_keep_generic_object_validation() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("grep"))
tool, params, error = registry.prepare_call("grep", ["TODO"])
assert tool is not None
assert params == ["TODO"]
assert error == "Error: Invalid parameters for tool 'grep': parameters must be an object, got list"
+10 -10
View File
@@ -545,18 +545,18 @@ async def test_exec_always_returns_exit_code() -> None:
assert "hello" in result assert "hello" in result
async def test_exec_head_tail_truncation() -> None: async def test_exec_head_tail_truncation(tmp_path) -> None:
"""Long output should preserve both head and tail.""" """Long output should preserve both head and tail."""
tool = ExecTool() tool = ExecTool()
# Generate output that exceeds _MAX_OUTPUT (10_000 chars) # Generate output that exceeds _MAX_OUTPUT (10_000 chars).
# Use current interpreter (PATH may not have `python`). ExecTool uses # Use current interpreter (PATH may not have ``python``). Write the
# create_subprocess_shell: POSIX needs shlex.quote; Windows uses cmd.exe # script to a file to avoid shell-quoting issues on both POSIX and Windows.
# rules, so list2cmdline is appropriate there. script_file = tmp_path / "gen.py"
script = "print('A' * 6000 + '\\n' + 'B' * 6000)" script_file.write_text(
if sys.platform == "win32": "import sys;sys.stdout.write(chr(65)*6000);sys.stdout.write(chr(10));sys.stdout.write(chr(66)*6000)",
command = subprocess.list2cmdline([sys.executable, "-c", script]) encoding="utf-8",
else: )
command = f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}" command = f"{sys.executable} {script_file}"
result = await tool.execute(command=command) result = await tool.execute(command=command)
assert "chars truncated" in result assert "chars truncated" in result
# Head portion should start with As # Head portion should start with As
-38
View File
@@ -120,27 +120,6 @@ async def test_jina_search(monkeypatch):
assert "https://jina.ai" in result assert "https://jina.ai" in result
@pytest.mark.asyncio
async def test_kagi_search(monkeypatch):
async def mock_get(self, url, **kw):
assert "kagi.com/api/v0/search" in url
assert kw["headers"]["Authorization"] == "Bot kagi-key"
assert kw["params"] == {"q": "test", "limit": 2}
return _response(json={
"data": [
{"t": 0, "title": "Kagi Result", "url": "https://kagi.com", "snippet": "Premium search"},
{"t": 1, "list": ["ignored related search"]},
]
})
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
tool = _tool(provider="kagi", api_key="kagi-key")
result = await tool.execute(query="test", count=2)
assert "Kagi Result" in result
assert "https://kagi.com" in result
assert "ignored related search" not in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unknown_provider(): async def test_unknown_provider():
tool = _tool(provider="unknown") tool = _tool(provider="unknown")
@@ -210,23 +189,6 @@ async def test_jina_422_falls_back_to_duckduckgo(monkeypatch):
assert "DuckDuckGo fallback" in result assert "DuckDuckGo fallback" in result
@pytest.mark.asyncio
async def test_kagi_fallback_to_duckduckgo_when_no_key(monkeypatch):
class MockDDGS:
def __init__(self, **kw):
pass
def text(self, query, max_results=5):
return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}]
monkeypatch.setattr("ddgs.DDGS", MockDDGS)
monkeypatch.delenv("KAGI_API_KEY", raising=False)
tool = _tool(provider="kagi", api_key="")
result = await tool.execute(query="test")
assert "Fallback" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_jina_search_uses_path_encoded_query(monkeypatch): async def test_jina_search_uses_path_encoded_query(monkeypatch):
calls = {} calls = {}
-65
View File
@@ -1,65 +0,0 @@
import pytest
from nanobot.utils.helpers import strip_think
class TestStripThinkTag:
"""Test <thought>...</thought> block stripping (Gemma 4 and similar models)."""
def test_closed_tag(self):
assert strip_think("Hello <thought>reasoning</thought> World") == "Hello World"
def test_unclosed_trailing_tag(self):
assert strip_think("<thought>ongoing...") == ""
def test_multiline_tag(self):
assert strip_think("<thought>\nline1\nline2\n</thought>End") == "End"
def test_tag_with_nested_angle_brackets(self):
text = "<thought>a < 3 and b > 2</thought>result"
assert strip_think(text) == "result"
def test_multiple_tag_blocks(self):
text = "A<thought>x</thought>B<thought>y</thought>C"
assert strip_think(text) == "ABC"
def test_tag_only_whitespace_inside(self):
assert strip_think("before<thought> </thought>after") == "beforeafter"
def test_self_closing_tag_not_matched(self):
assert strip_think("<thought/>some text") == "<thought/>some text"
def test_normal_text_unchanged(self):
assert strip_think("Just normal text") == "Just normal text"
def test_empty_string(self):
assert strip_think("") == ""
class TestStripThinkFalsePositive:
"""Ensure mid-content <think>/<thought> tags are NOT stripped (#3004)."""
def test_backtick_think_tag_preserved(self):
text = "*Think Stripping:* A new utility to strip `<think>` tags from output."
assert strip_think(text) == text
def test_prose_think_tag_preserved(self):
text = "The model emits <think> at the start of its response."
assert strip_think(text) == text
def test_code_block_think_tag_preserved(self):
text = "Example:\n```\ntext = re.sub(r\"<think>[\\s\\S]*\", \"\", text)\n```\nDone."
assert strip_think(text) == text
def test_backtick_thought_tag_preserved(self):
text = "Gemma 4 uses `<thought>` blocks for reasoning."
assert strip_think(text) == text
def test_prefix_unclosed_think_still_stripped(self):
assert strip_think("<think>reasoning without closing") == ""
def test_prefix_unclosed_think_with_whitespace(self):
assert strip_think(" <think>reasoning...") == ""
def test_prefix_unclosed_thought_still_stripped(self):
assert strip_think("<thought>reasoning without closing") == ""