mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
Merge remote-tracking branch 'origin/main' into nightly
This commit is contained in:
commit
9bf7f3b420
25
README.md
25
README.md
@ -23,20 +23,27 @@
|
||||
|
||||
## 📢 News
|
||||
|
||||
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
|
||||
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
|
||||
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
||||
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
|
||||
- **2026-04-17** 🪟 Windows & Python 3.14 CI, Dream line-age memory, email self-loop guard.
|
||||
- **2026-04-16** 📡 SSE streaming for OpenAI-compatible API, Discord channel allow-list.
|
||||
- **2026-04-15** 🎛️ LM Studio & nullable API keys, MiniMax thinking endpoint, runtime SelfTool.
|
||||
- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details.
|
||||
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
|
||||
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
|
||||
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
||||
|
||||
<details>
|
||||
<summary>Earlier news</summary>
|
||||
|
||||
- **2026-04-10** 📓 Notebook editing tool, multiple MCP servers, Feishu streaming & done-emoji.
|
||||
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
|
||||
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
||||
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
||||
- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments.
|
||||
- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details.
|
||||
|
||||
<details>
|
||||
<summary>Earlier news</summary>
|
||||
|
||||
- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling.
|
||||
- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish.
|
||||
- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening.
|
||||
@ -194,13 +201,19 @@ nanobot agent
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
</p>
|
||||
|
||||
**1. Start the gateway**
|
||||
**1. Enable the WebSocket channel in `~/.nanobot/config.json`**
|
||||
|
||||
```json
|
||||
{ "channels": { "websocket": { "enabled": true } } }
|
||||
```
|
||||
|
||||
**2. Start the gateway**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
**2. Start the webui dev server**
|
||||
**3. Start the webui dev server**
|
||||
|
||||
```bash
|
||||
cd webui
|
||||
|
||||
@ -4,7 +4,7 @@ These commands work inside chat channels and interactive agent sessions:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/new` | Start a new conversation |
|
||||
| `/new` | Stop current task and start a new conversation |
|
||||
| `/stop` | Stop the current task |
|
||||
| `/restart` | Restart the bot |
|
||||
| `/status` | Show bot status |
|
||||
|
||||
@ -420,7 +420,7 @@ That's it! Environment variables, model routing, config matching, and `nanobot s
|
||||
|-------|-------------|---------|
|
||||
| `default_api_base` | OpenAI-compatible base URL | `"https://api.deepseek.com"` |
|
||||
| `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` |
|
||||
| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}),)` |
|
||||
| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}), ("kimi-k2.6", {"temperature": 1.0}),)` |
|
||||
| `is_gateway` | Can route any model (like OpenRouter) | `True` |
|
||||
| `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` |
|
||||
| `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` |
|
||||
|
||||
@ -21,7 +21,7 @@ def _resolve_version() -> str:
|
||||
return _pkg_version("nanobot-ai")
|
||||
except PackageNotFoundError:
|
||||
# Source checkouts often import nanobot without installed dist-info.
|
||||
return _read_pyproject_version() or "0.1.5.post1"
|
||||
return _read_pyproject_version() or "0.1.5.post2"
|
||||
|
||||
|
||||
__version__ = _resolve_version()
|
||||
|
||||
@ -345,6 +345,36 @@ class AgentLoop:
|
||||
|
||||
return format_tool_hints(tool_calls)
|
||||
|
||||
async def _dispatch_command_inline(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
key: str,
|
||||
raw: str,
|
||||
dispatch_fn: Callable[[CommandContext], Awaitable[OutboundMessage | None]],
|
||||
) -> None:
|
||||
"""Dispatch a command directly from the run() loop and publish the result."""
|
||||
ctx = CommandContext(msg=msg, session=None, key=key, raw=raw, loop=self)
|
||||
result = await dispatch_fn(ctx)
|
||||
if result:
|
||||
await self.bus.publish_outbound(result)
|
||||
else:
|
||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
||||
|
||||
async def _cancel_active_tasks(self, key: str) -> int:
|
||||
"""Cancel and await all active tasks and subagents for *key*.
|
||||
|
||||
Returns the total number of cancelled tasks + subagents.
|
||||
"""
|
||||
tasks = self._active_tasks.pop(key, [])
|
||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||
return cancelled + sub_cancelled
|
||||
|
||||
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:
|
||||
@ -478,16 +508,24 @@ class AgentLoop:
|
||||
|
||||
raw = msg.content.strip()
|
||||
if self.commands.is_priority(raw):
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, loop=self)
|
||||
result = await self.commands.dispatch_priority(ctx)
|
||||
if result:
|
||||
await self.bus.publish_outbound(result)
|
||||
await self._dispatch_command_inline(
|
||||
msg, msg.session_key, raw,
|
||||
self.commands.dispatch_priority,
|
||||
)
|
||||
continue
|
||||
effective_key = self._effective_session_key(msg)
|
||||
# If this session already has an active pending queue (i.e. a task
|
||||
# is processing this session), route the message there for mid-turn
|
||||
# injection instead of creating a competing task.
|
||||
if effective_key in self._pending_queues:
|
||||
# Non-priority commands must not be queued for injection;
|
||||
# dispatch them directly (same pattern as priority commands).
|
||||
if self.commands.is_dispatchable_command(raw):
|
||||
await self._dispatch_command_inline(
|
||||
msg, effective_key, raw,
|
||||
self.commands.dispatch,
|
||||
)
|
||||
continue
|
||||
pending_msg = msg
|
||||
if effective_key != msg.session_key:
|
||||
pending_msg = dataclasses.replace(
|
||||
@ -579,6 +617,29 @@ class AgentLoop:
|
||||
))
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Task cancelled for session {}", session_key)
|
||||
# Preserve partial context from the interrupted turn so
|
||||
# the user does not lose tool results and assistant
|
||||
# messages accumulated before /stop. The checkpoint was
|
||||
# already persisted to session metadata by
|
||||
# _emit_checkpoint during tool execution; materializing
|
||||
# it into session history now makes it visible in the
|
||||
# next conversation turn.
|
||||
try:
|
||||
key = self._effective_session_key(msg)
|
||||
session = self.sessions.get_or_create(key)
|
||||
if self._restore_runtime_checkpoint(session):
|
||||
self._clear_pending_user_turn(session)
|
||||
self.sessions.save(session)
|
||||
logger.info(
|
||||
"Restored partial context for cancelled session {}",
|
||||
key,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not restore checkpoint for cancelled session {}",
|
||||
session_key,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Error processing message for session {}", session_key)
|
||||
|
||||
@ -8,7 +8,7 @@ import re
|
||||
import weakref
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@ -49,6 +49,7 @@ class MemoryStore:
|
||||
self.user_file = workspace / "USER.md"
|
||||
self._cursor_file = self.memory_dir / ".cursor"
|
||||
self._dream_cursor_file = self.memory_dir / ".dream_cursor"
|
||||
self._corruption_logged = False # rate-limit non-int cursor warning
|
||||
self._git = GitStore(workspace, tracked_files=[
|
||||
"SOUL.md", "USER.md", "memory/MEMORY.md",
|
||||
])
|
||||
@ -246,22 +247,52 @@ class MemoryStore:
|
||||
self._cursor_file.write_text(str(cursor), encoding="utf-8")
|
||||
return cursor
|
||||
|
||||
@staticmethod
|
||||
def _valid_cursor(value: Any) -> int | None:
|
||||
"""Int cursors only — reject bool (``isinstance(True, int)`` is True)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value
|
||||
|
||||
def _iter_valid_entries(self) -> Iterator[tuple[dict[str, Any], int]]:
|
||||
"""Yield ``(entry, cursor)`` for entries with int cursors; warn once on corruption."""
|
||||
poisoned: Any = None
|
||||
for entry in self._read_entries():
|
||||
raw = entry.get("cursor")
|
||||
if raw is None:
|
||||
continue
|
||||
cursor = self._valid_cursor(raw)
|
||||
if cursor is None:
|
||||
poisoned = raw
|
||||
continue
|
||||
yield entry, cursor
|
||||
if poisoned is not None and not self._corruption_logged:
|
||||
self._corruption_logged = True
|
||||
logger.warning(
|
||||
"history.jsonl contains a non-int cursor ({!r}); dropping it. "
|
||||
"Usually caused by an external writer; further occurrences suppressed.",
|
||||
poisoned,
|
||||
)
|
||||
|
||||
def _next_cursor(self) -> int:
|
||||
"""Read the current cursor counter and return next value."""
|
||||
"""Read the current cursor counter and return the next value."""
|
||||
if self._cursor_file.exists():
|
||||
try:
|
||||
return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
# Fallback: read last line's cursor from the JSONL file.
|
||||
last = self._read_last_entry()
|
||||
if last and last.get("cursor"):
|
||||
return last["cursor"] + 1
|
||||
return 1
|
||||
# Fast path: trust the tail when intact. Otherwise scan the whole
|
||||
# file and take ``max`` — that stays correct even if the monotonic
|
||||
# invariant was broken by external writes.
|
||||
last = self._read_last_entry() or {}
|
||||
cursor = self._valid_cursor(last.get("cursor"))
|
||||
if cursor is not None:
|
||||
return cursor + 1
|
||||
return max((c for _, c in self._iter_valid_entries()), default=0) + 1
|
||||
|
||||
def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]:
|
||||
"""Return history entries with cursor > *since_cursor*."""
|
||||
return [e for e in self._read_entries() if e.get("cursor", 0) > since_cursor]
|
||||
"""Return history entries with a valid cursor > *since_cursor*."""
|
||||
return [e for e, c in self._iter_valid_entries() if c > since_cursor]
|
||||
|
||||
def compact_history(self) -> None:
|
||||
"""Drop oldest entries if the file exceeds *max_history_entries*."""
|
||||
|
||||
@ -137,10 +137,11 @@ class ReadFileTool(_FsTool):
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Read a file (text or image). Text output format: LINE_NUM|CONTENT. "
|
||||
"Read a file (text, image, or document). "
|
||||
"Text output format: LINE_NUM|CONTENT. "
|
||||
"Images return visual content for analysis. "
|
||||
"Use offset and limit for large files. "
|
||||
"Cannot read non-image binary files. "
|
||||
"Supports PDF, DOCX, XLSX, PPTX documents. "
|
||||
"Use offset and limit for large text files. "
|
||||
"Reads exceeding ~128K chars are truncated."
|
||||
)
|
||||
|
||||
@ -169,6 +170,10 @@ class ReadFileTool(_FsTool):
|
||||
if fp.suffix.lower() == ".pdf":
|
||||
return self._read_pdf(fp, pages)
|
||||
|
||||
# Office document support
|
||||
if fp.suffix.lower() in {".docx", ".xlsx", ".pptx"}:
|
||||
return self._read_office_doc(fp)
|
||||
|
||||
raw = fp.read_bytes()
|
||||
if not raw:
|
||||
return f"(Empty file: {path})"
|
||||
@ -304,6 +309,25 @@ class ReadFileTool(_FsTool):
|
||||
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
|
||||
return result
|
||||
|
||||
def _read_office_doc(self, fp: Path) -> str:
|
||||
from nanobot.utils.document import extract_text
|
||||
|
||||
result = extract_text(fp)
|
||||
|
||||
if result is None:
|
||||
return f"Error: Unsupported file format: {fp.suffix}"
|
||||
|
||||
if result.startswith("[error:"):
|
||||
return f"Error reading {fp.suffix.upper()} file: {result}"
|
||||
|
||||
if not result:
|
||||
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
|
||||
|
||||
if len(result) > self._MAX_CHARS:
|
||||
result = result[:self._MAX_CHARS] + "\n\n(Document text truncated at ~128K chars)"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_file
|
||||
|
||||
@ -10,6 +10,25 @@ from loguru import logger
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
# Transient connection errors that warrant a single retry.
|
||||
# These typically happen when an MCP server restarts or a network
|
||||
# connection is interrupted between calls.
|
||||
_TRANSIENT_EXC_NAMES: frozenset[str] = frozenset((
|
||||
"ClosedResourceError",
|
||||
"BrokenResourceError",
|
||||
"EndOfStream",
|
||||
"BrokenPipeError",
|
||||
"ConnectionResetError",
|
||||
"ConnectionRefusedError",
|
||||
"ConnectionAbortedError",
|
||||
"ConnectionError",
|
||||
))
|
||||
|
||||
|
||||
def _is_transient(exc: BaseException) -> bool:
|
||||
"""Check if an exception looks like a transient connection error."""
|
||||
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
|
||||
|
||||
|
||||
def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None:
|
||||
"""Return the single non-null branch for nullable unions."""
|
||||
@ -99,38 +118,61 @@ class MCPToolWrapper(Tool):
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
from mcp import types
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self._session.call_tool(self._original_name, arguments=kwargs),
|
||||
timeout=self._tool_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
|
||||
return f"(MCP tool call timed out after {self._tool_timeout}s)"
|
||||
except asyncio.CancelledError:
|
||||
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
|
||||
# Re-raise only if our task was externally cancelled (e.g. /stop).
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling() > 0:
|
||||
raise
|
||||
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
|
||||
return "(MCP tool call was cancelled)"
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"MCP tool '{}' failed: {}: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP tool call failed: {type(exc).__name__})"
|
||||
|
||||
parts = []
|
||||
for block in result.content:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
for attempt in range(2): # At most 1 retry
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self._session.call_tool(self._original_name, arguments=kwargs),
|
||||
timeout=self._tool_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"MCP tool '{}' timed out after {}s", self._name, self._tool_timeout
|
||||
)
|
||||
return f"(MCP tool call timed out after {self._tool_timeout}s)"
|
||||
except asyncio.CancelledError:
|
||||
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
|
||||
# Re-raise only if our task was externally cancelled (e.g. /stop).
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling() > 0:
|
||||
raise
|
||||
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
|
||||
return "(MCP tool call was cancelled)"
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
if attempt == 0:
|
||||
logger.warning(
|
||||
"MCP tool '{}' hit transient error ({}), retrying once...",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
)
|
||||
await asyncio.sleep(1) # Brief backoff before retry
|
||||
continue
|
||||
# Second transient failure — give up with retry-specific message
|
||||
logger.error(
|
||||
"MCP tool '{}' failed after retry: {}: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP tool call failed after retry: {type(exc).__name__})"
|
||||
logger.exception(
|
||||
"MCP tool '{}' failed: {}: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP tool call failed: {type(exc).__name__})"
|
||||
else:
|
||||
parts.append(str(block))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
# Success — extract result
|
||||
parts = []
|
||||
for block in result.content:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
else:
|
||||
parts.append(str(block))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
|
||||
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
|
||||
|
||||
|
||||
class MCPResourceWrapper(Tool):
|
||||
@ -168,40 +210,59 @@ class MCPResourceWrapper(Tool):
|
||||
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]")
|
||||
for attempt in range(2):
|
||||
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:
|
||||
if _is_transient(exc):
|
||||
if attempt == 0:
|
||||
logger.warning(
|
||||
"MCP resource '{}' hit transient error ({}), retrying once...",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
logger.error(
|
||||
"MCP resource '{}' failed after retry: {}: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP resource read failed after retry: {type(exc).__name__})"
|
||||
logger.exception(
|
||||
"MCP resource '{}' failed: {}: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP resource read failed: {type(exc).__name__})"
|
||||
else:
|
||||
parts.append(str(block))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
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)"
|
||||
|
||||
return "(MCP resource read failed)" # Unreachable
|
||||
|
||||
|
||||
class MCPPromptWrapper(Tool):
|
||||
@ -254,52 +315,72 @@ class MCPPromptWrapper(Tool):
|
||||
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))
|
||||
for attempt in range(2):
|
||||
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:
|
||||
if _is_transient(exc):
|
||||
if attempt == 0:
|
||||
logger.warning(
|
||||
"MCP prompt '{}' hit transient error ({}), retrying once...",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
logger.error(
|
||||
"MCP prompt '{}' failed after retry: {}: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP prompt call failed after retry: {type(exc).__name__})"
|
||||
logger.exception(
|
||||
"MCP prompt '{}' failed: {}: {}",
|
||||
self._name,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
return f"(MCP prompt call failed: {type(exc).__name__})"
|
||||
else:
|
||||
parts.append(str(content))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
parts: list[str] = []
|
||||
for message in result.messages:
|
||||
content = message.content
|
||||
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)"
|
||||
|
||||
return "(MCP prompt call failed)" # Unreachable
|
||||
|
||||
|
||||
async def connect_mcp_servers(
|
||||
|
||||
@ -135,7 +135,7 @@ if DISCORD_AVAILABLE:
|
||||
|
||||
def _register_app_commands(self) -> None:
|
||||
commands = (
|
||||
("new", "Start a new conversation", "/new"),
|
||||
("new", "Stop current task and start a new conversation", "/new"),
|
||||
("stop", "Stop the current task", "/stop"),
|
||||
("restart", "Restart the bot", "/restart"),
|
||||
("status", "Show bot status", "/status"),
|
||||
|
||||
@ -53,6 +53,34 @@ def _strip_md(s: str) -> str:
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _strip_md_block(text: str) -> str:
|
||||
"""Strip block-level and inline markdown for readable plain-text preview.
|
||||
|
||||
Used during streaming mid-edits so users see clean text instead of raw
|
||||
markdown syntax while the response is still being generated.
|
||||
"""
|
||||
# Code blocks -> just the code
|
||||
text = re.sub(r'```[\w]*\n?([\s\S]*?)```', r'\1', text)
|
||||
# Headers -> plain text
|
||||
text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE)
|
||||
# Blockquotes
|
||||
text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
|
||||
# Bold / italic / strikethrough
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||
text = re.sub(r'__(.+?)__', r'\1', text)
|
||||
text = re.sub(r'(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])', r'\1', text)
|
||||
text = re.sub(r'~~(.+?)~~', r'\1', text)
|
||||
# Inline code
|
||||
text = re.sub(r'`([^`]+)`', r'\1', text)
|
||||
# Links [text](url) -> text
|
||||
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
|
||||
# Bullet lists
|
||||
text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE)
|
||||
# Numbered lists (normalize spacing)
|
||||
text = re.sub(r'^(\d+)\.\s+', r'\1. ', text, flags=re.MULTILINE)
|
||||
return text
|
||||
|
||||
|
||||
def _render_table_box(table_lines: list[str]) -> str:
|
||||
"""Convert markdown pipe-table to compact aligned text for <pre> display."""
|
||||
|
||||
@ -129,8 +157,8 @@ def _markdown_to_telegram_html(text: str) -> str:
|
||||
|
||||
text = re.sub(r'`([^`]+)`', save_inline_code, text)
|
||||
|
||||
# 3. Headers # Title -> just the title text
|
||||
text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE)
|
||||
# 3. Headers # Title -> <b>Title</b> (preserve visual hierarchy)
|
||||
text = re.sub(r'^#{1,6}\s+(.+)$', r'⟪B⟫\1⟪/B⟫', text, flags=re.MULTILINE)
|
||||
|
||||
# 4. Blockquotes > text -> just the text (before HTML escaping)
|
||||
text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
|
||||
@ -154,6 +182,9 @@ def _markdown_to_telegram_html(text: str) -> str:
|
||||
# 10. Bullet lists - item -> • item
|
||||
text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE)
|
||||
|
||||
# 10.5. Numbered lists 1. item -> 1. item (keep number, normalize indent)
|
||||
text = re.sub(r'^(\d+)\.\s+', r'\1. ', text, flags=re.MULTILINE)
|
||||
|
||||
# 11. Restore inline code with HTML tags
|
||||
for i, code in enumerate(inline_codes):
|
||||
# Escape HTML in code content
|
||||
@ -166,6 +197,9 @@ def _markdown_to_telegram_html(text: str) -> str:
|
||||
escaped = _escape_telegram_html(code)
|
||||
text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{escaped}</code></pre>")
|
||||
|
||||
# 13. Restore header bold markers (inserted in step 3, after HTML escaping)
|
||||
text = text.replace('⟪B⟫', '<b>').replace('⟪/B⟫', '</b>')
|
||||
|
||||
return text
|
||||
|
||||
|
||||
@ -637,10 +671,11 @@ class TelegramChannel(BaseChannel):
|
||||
if message_thread_id := meta.get("message_thread_id"):
|
||||
thread_kwargs["message_thread_id"] = message_thread_id
|
||||
if buf.message_id is None:
|
||||
preview = _strip_md_block(buf.text)
|
||||
try:
|
||||
sent = await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
chat_id=int_chat_id, text=buf.text,
|
||||
chat_id=int_chat_id, text=preview,
|
||||
**thread_kwargs,
|
||||
)
|
||||
buf.message_id = sent.message_id
|
||||
@ -653,11 +688,12 @@ class TelegramChannel(BaseChannel):
|
||||
await self._flush_stream_overflow(int_chat_id, buf, thread_kwargs)
|
||||
buf.last_edit = now
|
||||
return
|
||||
preview = _strip_md_block(buf.text)
|
||||
try:
|
||||
await self._call_with_retry(
|
||||
self._app.bot.edit_message_text,
|
||||
chat_id=int_chat_id, message_id=buf.message_id,
|
||||
text=buf.text,
|
||||
text=preview,
|
||||
)
|
||||
buf.last_edit = now
|
||||
except Exception as e:
|
||||
|
||||
@ -145,7 +145,7 @@ def _make_console() -> Console:
|
||||
def _render_interactive_ansi(render_fn) -> str:
|
||||
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
|
||||
ansi_console = Console(
|
||||
force_terminal=True,
|
||||
force_terminal=sys.stdout.isatty(),
|
||||
color_system=console.color_system or "standard",
|
||||
width=console.width,
|
||||
)
|
||||
@ -946,72 +946,16 @@ def _run_gateway(
|
||||
cron.stop()
|
||||
agent.stop()
|
||||
await channels.stop_all()
|
||||
# Flush all cached sessions to durable storage before exit.
|
||||
# This prevents data loss on filesystems with write-back
|
||||
# caching (rclone VFS, NFS, FUSE mounts, etc.).
|
||||
flushed = agent.sessions.flush_all()
|
||||
if flushed:
|
||||
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@app.command()
|
||||
def web(
|
||||
port: int | None = typer.Option(None, "--port", "-p", help="WebSocket port for the webui"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the browser when ready"),
|
||||
):
|
||||
"""Start the gateway with the embedded webui and (by default) open a browser."""
|
||||
if verbose:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
cfg = _load_runtime_config(config, workspace)
|
||||
|
||||
# Force the websocket channel on with token-gated auth so the webui is functional.
|
||||
# ``--port`` applies to the webui's websocket/HTTP port, not the gateway's
|
||||
# management port, since that's the only surface users visit.
|
||||
ws_section = cfg.channels.websocket
|
||||
if isinstance(ws_section, dict):
|
||||
ws_section.setdefault("host", "127.0.0.1")
|
||||
ws_section["enabled"] = True
|
||||
ws_section["websocketRequiresToken"] = True
|
||||
if port is not None:
|
||||
ws_section["port"] = port
|
||||
ws_host = ws_section.get("host", "127.0.0.1")
|
||||
ws_port = ws_section.get("port", 8765)
|
||||
ws_path = ws_section.get("path", "/")
|
||||
else:
|
||||
ws_section.enabled = True
|
||||
if hasattr(ws_section, "websocket_requires_token"):
|
||||
ws_section.websocket_requires_token = True
|
||||
if port is not None:
|
||||
ws_section.port = port
|
||||
ws_host = getattr(ws_section, "host", "127.0.0.1") or "127.0.0.1"
|
||||
ws_port = getattr(ws_section, "port", 8765)
|
||||
ws_path = getattr(ws_section, "path", "/") or "/"
|
||||
|
||||
# Confirm the bundled SPA exists before promising the user a browser launch.
|
||||
from nanobot.channels.manager import _default_webui_dist
|
||||
|
||||
dist = _default_webui_dist()
|
||||
if dist is None:
|
||||
console.print(
|
||||
"[yellow]Warning: webui assets not found at nanobot/web/dist/. "
|
||||
"Run `cd webui && bun install && bun run build` from a source checkout.[/yellow]"
|
||||
)
|
||||
|
||||
scheme = "http"
|
||||
# Browsers refuse cookies/JS on 0.0.0.0 — collapse to loopback for the visit URL.
|
||||
visit_host = "127.0.0.1" if ws_host in {"0.0.0.0", "::"} else ws_host
|
||||
open_url = f"{scheme}://{visit_host}:{ws_port}{ws_path if ws_path != '/' else ''}/"
|
||||
|
||||
# The gateway's management port is separate from the webui port; leave it
|
||||
# on its configured default so --port only moves the visible surface.
|
||||
_run_gateway(
|
||||
cfg,
|
||||
open_browser_url=open_url if open_browser else None,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Agent Commands
|
||||
# ============================================================================
|
||||
|
||||
@ -17,15 +17,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Cancel all active tasks and subagents for the session."""
|
||||
loop = ctx.loop
|
||||
msg = ctx.msg
|
||||
tasks = loop._active_tasks.pop(msg.session_key, [])
|
||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key)
|
||||
total = cancelled + sub_cancelled
|
||||
total = await loop._cancel_active_tasks(msg.session_key)
|
||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||
return OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||
@ -100,8 +92,9 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
|
||||
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Start a fresh session."""
|
||||
"""Stop active task and start a fresh session."""
|
||||
loop = ctx.loop
|
||||
await loop._cancel_active_tasks(ctx.key)
|
||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||
snapshot = session.messages[session.last_consolidated:]
|
||||
session.clear()
|
||||
@ -327,7 +320,7 @@ def build_help_text() -> str:
|
||||
"""Build canonical help text shared across channels."""
|
||||
lines = [
|
||||
"🐈 nanobot commands:",
|
||||
"/new — Start a new conversation",
|
||||
"/new — Stop current task and start a new conversation",
|
||||
"/stop — Stop the current task",
|
||||
"/restart — Restart the bot",
|
||||
"/status — Show bot status",
|
||||
|
||||
@ -57,6 +57,20 @@ class CommandRouter:
|
||||
def is_priority(self, text: str) -> bool:
|
||||
return text.strip().lower() in self._priority
|
||||
|
||||
def is_dispatchable_command(self, text: str) -> bool:
|
||||
"""Check whether *text* matches any non-priority command tier (exact or prefix).
|
||||
|
||||
Does NOT check priority or interceptor tiers.
|
||||
If this returns True, ``dispatch()`` is guaranteed to match a handler.
|
||||
"""
|
||||
cmd = text.strip().lower()
|
||||
if cmd in self._exact:
|
||||
return True
|
||||
for pfx, _ in self._prefix:
|
||||
if cmd.startswith(pfx):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
|
||||
"""Dispatch a priority command. Called from run() without the lock."""
|
||||
handler = self._priority.get(ctx.raw.lower())
|
||||
|
||||
@ -245,9 +245,41 @@ class AnthropicProvider(LLMProvider):
|
||||
"source": {"type": "url", "url": url},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _has_tool_use(msg: dict[str, Any]) -> bool:
|
||||
"""True if ``msg.content`` carries any ``tool_use`` block.
|
||||
|
||||
Anthropic forbids ``tool_use`` inside ``user`` turns, so messages that
|
||||
issued a tool call cannot be safely rerouted when we patch the role.
|
||||
"""
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
return False
|
||||
return any(
|
||||
isinstance(block, dict) and block.get("type") == "tool_use"
|
||||
for block in content
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Anthropic requires alternating user/assistant roles."""
|
||||
"""Normalize a message sequence for Anthropic's ``/messages`` endpoint.
|
||||
|
||||
Anthropic's contract is stricter than OpenAI's:
|
||||
|
||||
1. Consecutive same-role turns must be collapsed into one.
|
||||
2. The conversation cannot end with an ``assistant`` turn — Anthropic
|
||||
does not support assistant-message prefill and returns 400.
|
||||
3. The conversation cannot start with an ``assistant`` turn — the
|
||||
first message must be ``user``.
|
||||
|
||||
Rules 2 and 3 mirror ``LLMProvider._enforce_role_alternation`` in
|
||||
``base.py``, which applies the equivalent invariants to OpenAI-compat
|
||||
providers. The only Anthropic-specific wrinkle: ``tool_use`` blocks
|
||||
live inside ``content`` (not a separate ``tool_calls`` field) and are
|
||||
invalid inside ``user`` turns, so the recovery paths below must skip
|
||||
any message carrying them rather than silently producing a malformed
|
||||
request.
|
||||
"""
|
||||
merged: list[dict[str, Any]] = []
|
||||
for msg in msgs:
|
||||
if merged and merged[-1]["role"] == msg["role"]:
|
||||
@ -262,6 +294,36 @@ class AnthropicProvider(LLMProvider):
|
||||
merged[-1]["content"] = prev_c
|
||||
else:
|
||||
merged.append(msg)
|
||||
|
||||
# Rule 2: strip trailing assistant turns — Anthropic rejects prefill.
|
||||
last_popped: dict[str, Any] | None = None
|
||||
while merged and merged[-1].get("role") == "assistant":
|
||||
last_popped = merged.pop()
|
||||
|
||||
# Recovery for rule 2: if stripping removed every turn, reroute the
|
||||
# last popped assistant as a user turn so upstream code still gets a
|
||||
# valid request instead of a secondary "messages array empty" 400.
|
||||
# Skip when the message carried ``tool_use`` blocks (see _has_tool_use).
|
||||
if (
|
||||
not merged
|
||||
and last_popped is not None
|
||||
and not AnthropicProvider._has_tool_use(last_popped)
|
||||
):
|
||||
merged.append({"role": "user", "content": last_popped.get("content")})
|
||||
|
||||
# Rule 3: prepend a synthetic opener if the first surviving turn is an
|
||||
# assistant (e.g. upstream history truncation dropped the original
|
||||
# user request). ``tool_use``-carrying assistants are left alone —
|
||||
# that message will still fail validation, but injecting an opener
|
||||
# before it would orphan the tool_use/tool_result pair that follows,
|
||||
# turning a recoverable 400 into a harder-to-diagnose one.
|
||||
if (
|
||||
merged
|
||||
and merged[0].get("role") == "assistant"
|
||||
and not AnthropicProvider._has_tool_use(merged[0])
|
||||
):
|
||||
merged.insert(0, {"role": "user", "content": "(conversation continued)"})
|
||||
|
||||
return merged
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@ -54,6 +54,7 @@ _DEFAULT_OPENROUTER_HEADERS = {
|
||||
}
|
||||
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
|
||||
"kimi-k2.5",
|
||||
"kimi-k2.6",
|
||||
"k2.6-code-preview",
|
||||
})
|
||||
|
||||
@ -62,7 +63,7 @@ def _is_kimi_thinking_model(model_name: str) -> bool:
|
||||
"""Return True if model_name refers to a Kimi thinking-capable model.
|
||||
|
||||
Supports two forms:
|
||||
- Exact match: kimi-k2.5 in _KIMI_THINKING_MODELS
|
||||
- Exact match: e.g. kimi-k2.5 / kimi-k2.6 in _KIMI_THINKING_MODELS
|
||||
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
|
||||
is checked against _KIMI_THINKING_MODELS
|
||||
|
||||
@ -386,17 +387,33 @@ class OpenAICompatProvider(LLMProvider):
|
||||
kwargs.update(overrides)
|
||||
break
|
||||
|
||||
if reasoning_effort:
|
||||
kwargs["reasoning_effort"] = reasoning_effort
|
||||
# Normalize reasoning_effort into a semantic form (OpenAI vocab)
|
||||
# used for internal decisions, and a wire form actually sent out.
|
||||
# "minimum" is accepted as a DashScope-native alias for "minimal".
|
||||
semantic_effort: str | None = None
|
||||
if isinstance(reasoning_effort, str):
|
||||
semantic_effort = reasoning_effort.lower()
|
||||
if semantic_effort == "minimum":
|
||||
semantic_effort = "minimal"
|
||||
|
||||
wire_effort = reasoning_effort
|
||||
if spec and spec.name == "dashscope" and semantic_effort == "minimal":
|
||||
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
|
||||
wire_effort = "minimum"
|
||||
|
||||
if wire_effort:
|
||||
kwargs["reasoning_effort"] = wire_effort
|
||||
|
||||
# Provider-specific thinking parameters.
|
||||
# Only sent when reasoning_effort is explicitly configured so that
|
||||
# the provider default is preserved otherwise.
|
||||
if spec and reasoning_effort is not None:
|
||||
thinking_enabled = reasoning_effort.lower() != "minimal"
|
||||
thinking_enabled = semantic_effort != "minimal"
|
||||
extra: dict[str, Any] | None = None
|
||||
if spec.name == "dashscope":
|
||||
extra = {"enable_thinking": thinking_enabled}
|
||||
elif spec.name == "minimax":
|
||||
extra = {"reasoning_split": thinking_enabled}
|
||||
elif spec.name in (
|
||||
"volcengine", "volcengine_coding_plan",
|
||||
"byteplus", "byteplus_coding_plan",
|
||||
@ -412,7 +429,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
||||
# identically to bare names like "kimi-k2.5".
|
||||
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
|
||||
thinking_enabled = reasoning_effort.lower() != "minimal"
|
||||
thinking_enabled = semantic_effort != "minimal"
|
||||
kwargs.setdefault("extra_body", {}).update(
|
||||
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
||||
)
|
||||
|
||||
@ -261,7 +261,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
backend="openai_compat",
|
||||
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
# Moonshot (月之暗面): Kimi models. K2.5 enforces temperature >= 1.0.
|
||||
# Moonshot (月之暗面): Kimi K2.5 / K2.6 enforce temperature >= 1.0.
|
||||
ProviderSpec(
|
||||
name="moonshot",
|
||||
keywords=("moonshot", "kimi"),
|
||||
@ -269,7 +269,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
display_name="Moonshot",
|
||||
backend="openai_compat",
|
||||
default_api_base="https://api.moonshot.ai/v1",
|
||||
model_overrides=(("kimi-k2.5", {"temperature": 1.0}),),
|
||||
model_overrides=(
|
||||
("kimi-k2.5", {"temperature": 1.0}),
|
||||
("kimi-k2.6", {"temperature": 1.0}),
|
||||
),
|
||||
),
|
||||
# MiniMax: OpenAI-compatible API
|
||||
ProviderSpec(
|
||||
|
||||
@ -262,8 +262,16 @@ class SessionManager:
|
||||
"messages": session.messages,
|
||||
}
|
||||
|
||||
def save(self, session: Session) -> None:
|
||||
"""Save a session to disk atomically."""
|
||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||
"""Save a session to disk atomically.
|
||||
|
||||
When *fsync* is ``True`` the final file and its parent directory are
|
||||
explicitly flushed to durable storage. This is intentionally off by
|
||||
default (the OS page-cache is sufficient for normal operation) but
|
||||
should be enabled during graceful shutdown so that filesystems with
|
||||
write-back caching (e.g. rclone VFS, NFS, FUSE mounts) do not lose
|
||||
the most recent writes.
|
||||
"""
|
||||
path = self._get_session_path(session.key)
|
||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||
|
||||
@ -280,14 +288,47 @@ class SessionManager:
|
||||
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
||||
for msg in session.messages:
|
||||
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||
if fsync:
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
if fsync:
|
||||
# fsync the directory so the rename is durable.
|
||||
# On Windows, opening a directory with O_RDONLY raises
|
||||
# PermissionError — skip the dir sync there (NTFS
|
||||
# journals metadata synchronously).
|
||||
try:
|
||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except PermissionError:
|
||||
pass # Windows — directory fsync not supported
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
self._cache[session.key] = session
|
||||
|
||||
def flush_all(self) -> int:
|
||||
"""Re-save every cached session with fsync for durable shutdown.
|
||||
|
||||
Returns the number of sessions flushed. Errors on individual
|
||||
sessions are logged but do not prevent other sessions from being
|
||||
flushed.
|
||||
"""
|
||||
flushed = 0
|
||||
for key, session in list(self._cache.items()):
|
||||
try:
|
||||
self.save(session, fsync=True)
|
||||
flushed += 1
|
||||
except Exception:
|
||||
logger.warning("Failed to flush session {}", key, exc_info=True)
|
||||
return flushed
|
||||
|
||||
def invalidate(self, key: str) -> None:
|
||||
"""Remove a session from the in-memory cache."""
|
||||
self._cache.pop(key, None)
|
||||
|
||||
@ -134,18 +134,20 @@ def _extract_xlsx(path: Path) -> str:
|
||||
"""Extract text from XLSX using openpyxl."""
|
||||
try:
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
sheets: list[str] = []
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
rows: list[str] = []
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
|
||||
if row_text.strip():
|
||||
rows.append(row_text)
|
||||
if rows:
|
||||
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
|
||||
wb.close()
|
||||
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
|
||||
try:
|
||||
sheets: list[str] = []
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
rows: list[str] = []
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
|
||||
if row_text.strip():
|
||||
rows.append(row_text)
|
||||
if rows:
|
||||
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
|
||||
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
|
||||
finally:
|
||||
wb.close()
|
||||
except Exception as e:
|
||||
logger.error("Failed to extract XLSX {}: {}", path, e)
|
||||
return f"[error: failed to extract XLSX: {e!s}]"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nanobot-ai"
|
||||
version = "0.1.5.post1"
|
||||
version = "0.1.5.post2"
|
||||
description = "A lightweight personal AI assistant framework"
|
||||
readme = { file = "README.md", content-type = "text/markdown" }
|
||||
requires-python = ">=3.11"
|
||||
@ -118,13 +118,6 @@ include = [
|
||||
"nanobot/skills/**/*.md",
|
||||
"nanobot/skills/**/*.sh",
|
||||
]
|
||||
# Build-time generated assets that live under .gitignore'd paths but must ship
|
||||
# in the wheel/sdist. `artifacts` bypasses the VCS filter (unlike `include`).
|
||||
# The webui is compiled via `bun run build` into nanobot/web/dist/ right before
|
||||
# `python -m build` runs.
|
||||
artifacts = [
|
||||
"nanobot/web/dist/**/*",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["nanobot"]
|
||||
|
||||
188
tests/agent/test_cursor_recovery.py
Normal file
188
tests/agent/test_cursor_recovery.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""Regression tests for cursor recovery after non-integer cursor corruption.
|
||||
|
||||
Root cause: cron jobs and other callers occasionally wrote string cursors to
|
||||
history.jsonl (e.g. ``"cursor": "abc"``). The original ``_next_cursor`` and
|
||||
``read_unprocessed_history`` assumed integer cursors and crashed with
|
||||
``TypeError`` / ``ValueError``, blocking all subsequent history appends.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return MemoryStore(tmp_path)
|
||||
|
||||
|
||||
class TestNextCursorRecovery:
|
||||
"""``_next_cursor`` must recover a valid int even when the last entry's
|
||||
cursor is corrupted (non-int)."""
|
||||
|
||||
def test_string_cursor_falls_back_to_scan(self, store):
|
||||
"""Last entry has a string cursor — scan backwards to find a valid int."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 5, "timestamp": "2026-04-01 10:00", "content": "good"}\n'
|
||||
'{"cursor": 6, "timestamp": "2026-04-01 10:01", "content": "also good"}\n'
|
||||
'{"cursor": "bad", "timestamp": "2026-04-01 10:02", "content": "corrupted"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Delete .cursor file so _next_cursor falls back to reading JSONL
|
||||
store._cursor_file.unlink(missing_ok=True)
|
||||
cursor = store.append_history("recovered event")
|
||||
assert cursor == 7
|
||||
|
||||
def test_all_corrupted_cursors_return_one(self, store):
|
||||
"""Every entry has a non-int cursor — should restart at 1."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": "a", "timestamp": "2026-04-01 10:00", "content": "bad1"}\n'
|
||||
'{"cursor": "b", "timestamp": "2026-04-01 10:01", "content": "bad2"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
store._cursor_file.unlink(missing_ok=True)
|
||||
cursor = store.append_history("fresh start")
|
||||
assert cursor == 1
|
||||
|
||||
def test_non_int_cursor_types(self, store):
|
||||
"""Float, None, list — all non-int types handled gracefully."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 3, "timestamp": "2026-04-01 10:00", "content": "valid"}\n'
|
||||
'{"cursor": 3.5, "timestamp": "2026-04-01 10:01", "content": "float"}\n'
|
||||
'{"cursor": null, "timestamp": "2026-04-01 10:02", "content": "null"}\n'
|
||||
'{"cursor": [1,2], "timestamp": "2026-04-01 10:03", "content": "list"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
store._cursor_file.unlink(missing_ok=True)
|
||||
cursor = store.append_history("handles weird types")
|
||||
assert cursor == 4
|
||||
|
||||
def test_cursor_file_with_string_content(self, store):
|
||||
"""Cursor file contains a non-numeric string — should fall back."""
|
||||
store._cursor_file.write_text("not_a_number", encoding="utf-8")
|
||||
# Also add valid JSONL so the fallback scan finds something
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 10, "timestamp": "2026-04-01 10:00", "content": "valid"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
cursor = store.append_history("after bad cursor file")
|
||||
assert cursor == 11
|
||||
|
||||
|
||||
class TestReadUnprocessedWithCorruption:
|
||||
"""``read_unprocessed_history`` must skip entries with non-int cursors
|
||||
instead of crashing on comparison."""
|
||||
|
||||
def test_skips_string_cursor_entries(self, store):
|
||||
"""Entries with string cursors are silently skipped."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 1, "timestamp": "2026-04-01 10:00", "content": "valid1"}\n'
|
||||
'{"cursor": "bad", "timestamp": "2026-04-01 10:01", "content": "corrupted"}\n'
|
||||
'{"cursor": 3, "timestamp": "2026-04-01 10:02", "content": "valid3"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 2
|
||||
assert [e["cursor"] for e in entries] == [1, 3]
|
||||
|
||||
def test_mixed_corruption_preserves_order(self, store):
|
||||
"""Valid entries maintain correct order despite corrupt neighbors."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": "x", "timestamp": "2026-04-01 10:00", "content": "bad"}\n'
|
||||
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "good2"}\n'
|
||||
'{"cursor": null, "timestamp": "2026-04-01 10:02", "content": "also bad"}\n'
|
||||
'{"cursor": 4, "timestamp": "2026-04-01 10:03", "content": "good4"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert [e["cursor"] for e in entries] == [2, 4]
|
||||
|
||||
def test_all_valid_still_works(self, store):
|
||||
"""Normal operation unaffected — baseline regression check."""
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
store.append_history("event 3")
|
||||
entries = store.read_unprocessed_history(since_cursor=1)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["cursor"] == 2
|
||||
assert entries[1]["cursor"] == 3
|
||||
|
||||
|
||||
class TestCursorValidationInvariant:
|
||||
"""First-principles checks: the cursor validity rules and the
|
||||
observability we layer on top of them."""
|
||||
|
||||
def test_bool_cursor_rejected(self, store):
|
||||
"""``isinstance(True, int) is True`` in Python; the guard must
|
||||
still treat ``{"cursor": true}`` as corruption, otherwise a
|
||||
boolean silently becomes cursor ``1`` / ``0`` downstream.
|
||||
"""
|
||||
assert MemoryStore._valid_cursor(True) is None
|
||||
assert MemoryStore._valid_cursor(False) is None
|
||||
assert MemoryStore._valid_cursor(5) == 5
|
||||
assert MemoryStore._valid_cursor(0) == 0
|
||||
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 4, "timestamp": "2026-04-01 10:00", "content": "real"}\n'
|
||||
'{"cursor": true, "timestamp": "2026-04-01 10:01", "content": "bool"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
store._cursor_file.unlink(missing_ok=True)
|
||||
assert store.append_history("next") == 5
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert [e["cursor"] for e in entries] == [4, 5]
|
||||
|
||||
def test_next_cursor_returns_max_not_just_last_int(self, store):
|
||||
"""Under adversarial corruption, file order ≠ numeric order. The
|
||||
recovery scan must return ``max(valid cursors) + 1``, not the
|
||||
first int seen from the tail, so the returned cursor is strictly
|
||||
greater than every legitimate cursor already on disk.
|
||||
"""
|
||||
# Tail is corrupt → recovery scan runs. Valid cursors are 100
|
||||
# and 5, in that order on disk; a naive "first int from the tail"
|
||||
# recovery would return 6, which would then silently collide with
|
||||
# the existing cursor 100. ``max`` is the only safe choice.
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 100, "timestamp": "2026-04-01 10:00", "content": "high"}\n'
|
||||
'{"cursor": 5, "timestamp": "2026-04-01 10:01", "content": "out of order"}\n'
|
||||
'{"cursor": "poison", "timestamp": "2026-04-01 10:02", "content": "tail corrupt"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
store._cursor_file.unlink(missing_ok=True)
|
||||
assert store.append_history("safe next") == 101
|
||||
|
||||
def test_corruption_is_logged_exactly_once_per_store(self, store, caplog):
|
||||
"""Observability without spam: the first non-int cursor emits one
|
||||
warning, subsequent reads on the same store stay quiet. Without
|
||||
this, a poisoned file produces one warning per agent turn."""
|
||||
import logging
|
||||
from loguru import logger as loguru_logger
|
||||
|
||||
store.history_file.write_text(
|
||||
'{"cursor": "bad1", "timestamp": "2026-04-01 10:00", "content": "x"}\n'
|
||||
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "y"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
store._cursor_file.unlink(missing_ok=True)
|
||||
|
||||
handler_id = loguru_logger.add(
|
||||
caplog.handler, format="{message}", level="WARNING"
|
||||
)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
store.read_unprocessed_history(since_cursor=0)
|
||||
store.read_unprocessed_history(since_cursor=0)
|
||||
store.append_history("another")
|
||||
finally:
|
||||
loguru_logger.remove(handler_id)
|
||||
|
||||
corruption_warnings = [
|
||||
r for r in caplog.records if "non-int cursor" in r.getMessage()
|
||||
]
|
||||
assert len(corruption_warnings) == 1, (
|
||||
"Expected exactly one corruption warning per store instance; "
|
||||
f"got {len(corruption_warnings)}: {[r.getMessage() for r in corruption_warnings]}"
|
||||
)
|
||||
368
tests/agent/test_mcp_transient_retry.py
Normal file
368
tests/agent/test_mcp_transient_retry.py
Normal file
@ -0,0 +1,368 @@
|
||||
"""Tests for MCP tool/resource/prompt transient error retry."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from mcp import types as mcp_types
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nanobot.agent.tools.mcp import (
|
||||
MCPPromptWrapper,
|
||||
MCPResourceWrapper,
|
||||
MCPToolWrapper,
|
||||
_is_transient,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_transient helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeClosedResourceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_FakeClosedResourceError.__name__ = "ClosedResourceError"
|
||||
|
||||
|
||||
class _FakeEndOfStreamError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_FakeEndOfStreamError.__name__ = "EndOfStream"
|
||||
|
||||
|
||||
def test_is_transient_recognizes_closed_resource():
|
||||
assert _is_transient(_FakeClosedResourceError("gone"))
|
||||
|
||||
|
||||
def test_is_transient_recognizes_broken_pipe():
|
||||
assert _is_transient(BrokenPipeError("pipe"))
|
||||
|
||||
|
||||
def test_is_transient_recognizes_connection_reset():
|
||||
assert _is_transient(ConnectionResetError("reset"))
|
||||
|
||||
|
||||
def test_is_transient_recognizes_connection_refused():
|
||||
assert _is_transient(ConnectionRefusedError("refused"))
|
||||
|
||||
|
||||
def test_is_transient_recognizes_end_of_stream():
|
||||
assert _is_transient(_FakeEndOfStreamError("eof"))
|
||||
|
||||
|
||||
def test_is_transient_rejects_value_error():
|
||||
assert not _is_transient(ValueError("nope"))
|
||||
|
||||
|
||||
def test_is_transient_rejects_runtime_error():
|
||||
assert not _is_transient(RuntimeError("nope"))
|
||||
|
||||
|
||||
def test_is_transient_rejects_timeout():
|
||||
assert not _is_transient(TimeoutError("timeout"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPToolWrapper retry behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_tool_def(name="test_tool"):
|
||||
return SimpleNamespace(
|
||||
name=name,
|
||||
description="A test tool",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
)
|
||||
|
||||
|
||||
def _make_tool_result(text):
|
||||
"""Build a mock tool result with proper MCP TextContent."""
|
||||
return SimpleNamespace(content=[mcp_types.TextContent(type="text", text=text)])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_retries_on_transient_error():
|
||||
"""Tool should retry once when a transient error occurs, then succeed."""
|
||||
session = AsyncMock()
|
||||
result = _make_tool_result("ok")
|
||||
exc = _FakeClosedResourceError("connection lost")
|
||||
session.call_tool = AsyncMock(side_effect=[exc, result])
|
||||
|
||||
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
|
||||
output = await wrapper.execute(foo="bar")
|
||||
|
||||
assert output == "ok"
|
||||
assert session.call_tool.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_fails_after_retry_exhausted():
|
||||
"""Tool should fail with retry message when both attempts hit transient errors."""
|
||||
session = AsyncMock()
|
||||
exc1 = _FakeClosedResourceError("still dead")
|
||||
exc2 = _FakeClosedResourceError("still dead again")
|
||||
session.call_tool = AsyncMock(side_effect=[exc1, exc2])
|
||||
|
||||
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "failed after retry" in output
|
||||
assert "ClosedResourceError" in output
|
||||
assert session.call_tool.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_no_retry_on_non_transient_error():
|
||||
"""Tool should NOT retry on non-transient errors like ValueError."""
|
||||
session = AsyncMock()
|
||||
session.call_tool = AsyncMock(side_effect=ValueError("bad input"))
|
||||
|
||||
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "ValueError" in output
|
||||
assert "retry" not in output
|
||||
assert session.call_tool.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_no_retry_on_timeout():
|
||||
"""Timeouts should not trigger retry (they have their own handling)."""
|
||||
session = AsyncMock()
|
||||
session.call_tool = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
|
||||
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "timed out" in output
|
||||
assert session.call_tool.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_success_on_first_try_no_retry():
|
||||
"""Normal success path — no retry logic involved."""
|
||||
session = AsyncMock()
|
||||
result = _make_tool_result("hello")
|
||||
session.call_tool = AsyncMock(return_value=result)
|
||||
|
||||
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert output == "hello"
|
||||
assert session.call_tool.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_does_not_retry_on_cancelled_error():
|
||||
"""`asyncio.CancelledError` must short-circuit the retry loop.
|
||||
|
||||
Regression guard: the retry branch lives under ``except Exception``,
|
||||
but ``CancelledError`` inherits from ``BaseException``, not
|
||||
``Exception``, so it naturally bypasses the retry branch today. If a
|
||||
future refactor ever widens the retry branch to ``BaseException`` (or
|
||||
re-orders the handlers), ``/stop`` would start retrying instead of
|
||||
cancelling — this test pins that invariant.
|
||||
"""
|
||||
session = AsyncMock()
|
||||
session.call_tool = AsyncMock(side_effect=asyncio.CancelledError())
|
||||
|
||||
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "cancelled" in output
|
||||
assert session.call_tool.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_retry_on_connection_reset():
|
||||
"""ConnectionResetError (a stdlib exception) should also trigger retry."""
|
||||
session = AsyncMock()
|
||||
result = _make_tool_result("recovered")
|
||||
session.call_tool = AsyncMock(
|
||||
side_effect=[ConnectionResetError("reset by peer"), result]
|
||||
)
|
||||
|
||||
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert output == "recovered"
|
||||
assert session.call_tool.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_retry_on_end_of_stream():
|
||||
"""EndOfStream (anyio) should trigger retry."""
|
||||
session = AsyncMock()
|
||||
result = _make_tool_result("back")
|
||||
session.call_tool = AsyncMock(side_effect=[_FakeEndOfStreamError("eof"), result])
|
||||
|
||||
wrapper = MCPToolWrapper(session, "test_server", _make_tool_def(), tool_timeout=5)
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert output == "back"
|
||||
assert session.call_tool.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPResourceWrapper retry behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_resource_def(name="test_resource"):
|
||||
return SimpleNamespace(
|
||||
name=name,
|
||||
uri="file:///test",
|
||||
description="A test resource",
|
||||
)
|
||||
|
||||
|
||||
def _make_resource_result(text):
|
||||
return SimpleNamespace(
|
||||
contents=[mcp_types.TextResourceContents(uri="file:///test", text=text)]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_retries_on_transient_error():
|
||||
"""Resource should retry once on transient connection error."""
|
||||
session = AsyncMock()
|
||||
result = _make_resource_result("data")
|
||||
exc = _FakeClosedResourceError("gone")
|
||||
session.read_resource = AsyncMock(side_effect=[exc, result])
|
||||
|
||||
wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert output == "data"
|
||||
assert session.read_resource.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_fails_after_retry_exhausted():
|
||||
"""Resource should fail with retry message when both attempts fail."""
|
||||
session = AsyncMock()
|
||||
exc = _FakeClosedResourceError("dead")
|
||||
session.read_resource = AsyncMock(side_effect=[exc, exc])
|
||||
|
||||
wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "failed after retry" in output
|
||||
assert session.read_resource.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_no_retry_on_non_transient():
|
||||
"""Resource should not retry on non-transient errors."""
|
||||
session = AsyncMock()
|
||||
session.read_resource = AsyncMock(side_effect=RuntimeError("bad"))
|
||||
|
||||
wrapper = MCPResourceWrapper(session, "test_server", _make_resource_def())
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "RuntimeError" in output
|
||||
assert session.read_resource.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPPromptWrapper retry behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_prompt_def(name="test_prompt"):
|
||||
return SimpleNamespace(
|
||||
name=name,
|
||||
description="A test prompt",
|
||||
arguments=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_prompt_result(text):
|
||||
return SimpleNamespace(
|
||||
messages=[
|
||||
SimpleNamespace(
|
||||
content=mcp_types.TextContent(type="text", text=text),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_retries_on_transient_error():
|
||||
"""Prompt should retry once on transient connection error."""
|
||||
session = AsyncMock()
|
||||
result = _make_prompt_result("prompt text")
|
||||
exc = _FakeClosedResourceError("gone")
|
||||
session.get_prompt = AsyncMock(side_effect=[exc, result])
|
||||
|
||||
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert output == "prompt text"
|
||||
assert session.get_prompt.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_fails_after_retry_exhausted():
|
||||
"""Prompt should fail with retry message when both attempts fail."""
|
||||
session = AsyncMock()
|
||||
exc = _FakeClosedResourceError("dead")
|
||||
session.get_prompt = AsyncMock(side_effect=[exc, exc])
|
||||
|
||||
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
|
||||
|
||||
with patch("nanobot.agent.tools.mcp.asyncio.sleep", new_callable=AsyncMock):
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "failed after retry" in output
|
||||
assert session.get_prompt.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_no_retry_on_mcp_error():
|
||||
"""McpError (application-level) should NOT trigger retry."""
|
||||
session = AsyncMock()
|
||||
session.get_prompt = AsyncMock(
|
||||
side_effect=McpError(ErrorData(code=-1, message="not found"))
|
||||
)
|
||||
|
||||
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "not found" in output
|
||||
assert session.get_prompt.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_no_retry_on_non_transient():
|
||||
"""Non-transient errors should not trigger retry for prompts."""
|
||||
session = AsyncMock()
|
||||
session.get_prompt = AsyncMock(side_effect=RuntimeError("bad"))
|
||||
|
||||
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
|
||||
output = await wrapper.execute()
|
||||
|
||||
assert "RuntimeError" in output
|
||||
assert session.get_prompt.call_count == 1
|
||||
162
tests/agent/test_stop_preserves_context.py
Normal file
162
tests/agent/test_stop_preserves_context.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""Tests for /stop preserving partial context from interrupted turns.
|
||||
|
||||
When /stop cancels an active task, the runtime checkpoint (tool results,
|
||||
assistant messages accumulated so far) should be materialized into session
|
||||
history rather than silently discarded.
|
||||
|
||||
See: https://github.com/HKUDS/nanobot/issues/2966
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_loop():
|
||||
"""Create a minimal AgentLoop with mocked dependencies."""
|
||||
with patch.object(AgentLoop, "__init__", lambda self: None):
|
||||
loop = AgentLoop()
|
||||
loop.sessions = MagicMock()
|
||||
loop._pending_queues = {}
|
||||
loop._session_locks = {}
|
||||
loop._active_tasks = {}
|
||||
loop._concurrency_gate = None
|
||||
loop._RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||
loop._PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||
loop.bus = MagicMock()
|
||||
loop.bus.publish_outbound = AsyncMock()
|
||||
loop.bus.publish_inbound = AsyncMock()
|
||||
loop.commands = MagicMock()
|
||||
loop.commands.dispatch_priority = AsyncMock(return_value=None)
|
||||
return loop
|
||||
|
||||
|
||||
class TestStopPreservesContext:
|
||||
"""Verify that /stop restores partial context via checkpoint."""
|
||||
|
||||
def test_restore_checkpoint_method_exists(self, mock_loop):
|
||||
"""AgentLoop should have _restore_runtime_checkpoint."""
|
||||
assert hasattr(mock_loop, "_restore_runtime_checkpoint")
|
||||
|
||||
def test_checkpoint_key_constant(self, mock_loop):
|
||||
"""The runtime checkpoint key should be defined."""
|
||||
assert mock_loop._RUNTIME_CHECKPOINT_KEY == "runtime_checkpoint"
|
||||
|
||||
def test_cancel_dispatch_restores_checkpoint(self, mock_loop):
|
||||
"""When a task is cancelled, the checkpoint should be restored."""
|
||||
# Create a mock session with a checkpoint
|
||||
session = MagicMock()
|
||||
session.metadata = {
|
||||
"runtime_checkpoint": {
|
||||
"phase": "awaiting_tools",
|
||||
"iteration": 0,
|
||||
"assistant_message": {
|
||||
"role": "assistant",
|
||||
"content": "Let me search for that.",
|
||||
"tool_calls": [{"id": "tc_1", "type": "function",
|
||||
"function": {"name": "web_search", "arguments": "{}"}}],
|
||||
},
|
||||
"completed_tool_results": [
|
||||
{"role": "tool", "tool_call_id": "tc_1",
|
||||
"content": "Search results: ..."},
|
||||
],
|
||||
"pending_tool_calls": [],
|
||||
}
|
||||
}
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Search for something"},
|
||||
]
|
||||
mock_loop.sessions.get_or_create.return_value = session
|
||||
|
||||
# The restore method should add checkpoint messages to session history
|
||||
restored = mock_loop._restore_runtime_checkpoint(session)
|
||||
assert restored is True
|
||||
# After restore, session should have more messages
|
||||
assert len(session.messages) > 1
|
||||
# The checkpoint should be cleared
|
||||
assert "runtime_checkpoint" not in session.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_cancellation_restores_checkpoint():
|
||||
"""Regression for #2966: /stop interrupting _dispatch must materialize the
|
||||
in-flight runtime checkpoint into session.messages before the cancellation
|
||||
unwinds, so the next turn can see the partial work.
|
||||
|
||||
This exercises the real _dispatch path (locks, pending queues, the
|
||||
CancelledError handler) rather than poking _restore_runtime_checkpoint in
|
||||
isolation, so a future refactor that drops the cancel-time restore is
|
||||
caught by CI instead of silently regressing.
|
||||
"""
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
workspace = MagicMock()
|
||||
workspace.__truediv__ = MagicMock(return_value=MagicMock())
|
||||
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||
|
||||
checkpoint_key = loop._RUNTIME_CHECKPOINT_KEY
|
||||
session = SimpleNamespace(
|
||||
key="test:c1",
|
||||
metadata={
|
||||
checkpoint_key: {
|
||||
"phase": "awaiting_tools",
|
||||
"iteration": 0,
|
||||
"assistant_message": {
|
||||
"role": "assistant",
|
||||
"content": "Let me search.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc_1",
|
||||
"type": "function",
|
||||
"function": {"name": "web_search", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
"completed_tool_results": [
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "Search hit."},
|
||||
],
|
||||
"pending_tool_calls": [],
|
||||
}
|
||||
},
|
||||
messages=[{"role": "user", "content": "Search for something"}],
|
||||
)
|
||||
|
||||
loop.sessions.get_or_create = MagicMock(return_value=session)
|
||||
loop.sessions.save = MagicMock()
|
||||
|
||||
async def _cancel(*_args, **_kwargs):
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
loop._process_message = _cancel
|
||||
|
||||
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="work")
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await loop._dispatch(msg)
|
||||
|
||||
roles = [m.get("role") for m in session.messages]
|
||||
assert roles == ["user", "assistant", "tool"], (
|
||||
"Expected the assistant message and completed tool result from the "
|
||||
f"interrupted turn to be materialized into session.messages; got {roles}"
|
||||
)
|
||||
assert checkpoint_key not in session.metadata, \
|
||||
"Checkpoint metadata should be cleared after restore"
|
||||
assert loop.sessions.save.called, \
|
||||
"Session should be persisted so the restored state survives process restart"
|
||||
@ -241,6 +241,7 @@ class TestCmdNewUnifiedSession:
|
||||
loop = SimpleNamespace(
|
||||
sessions=sessions,
|
||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||
_cancel_active_tasks=AsyncMock(return_value=0),
|
||||
)
|
||||
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
|
||||
|
||||
@ -274,6 +275,7 @@ class TestCmdNewUnifiedSession:
|
||||
loop = SimpleNamespace(
|
||||
sessions=sessions,
|
||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||
_cancel_active_tasks=AsyncMock(return_value=0),
|
||||
)
|
||||
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
|
||||
|
||||
|
||||
@ -1471,3 +1471,123 @@ async def test_send_text_bad_request_plain_fallback_exhausted() -> None:
|
||||
# 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}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _markdown_to_telegram_html formatting tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_markdown_to_html_headers_become_bold() -> None:
|
||||
from nanobot.channels.telegram import _markdown_to_telegram_html
|
||||
|
||||
assert _markdown_to_telegram_html("# Title") == "<b>Title</b>"
|
||||
assert _markdown_to_telegram_html("## Subtitle") == "<b>Subtitle</b>"
|
||||
assert _markdown_to_telegram_html("### Deep") == "<b>Deep</b>"
|
||||
|
||||
|
||||
def test_markdown_to_html_numbered_lists_preserved() -> None:
|
||||
from nanobot.channels.telegram import _markdown_to_telegram_html
|
||||
|
||||
text = "1. First\n2. Second\n3. Third"
|
||||
result = _markdown_to_telegram_html(text)
|
||||
assert "1. First" in result
|
||||
assert "2. Second" in result
|
||||
assert "3. Third" in result
|
||||
|
||||
|
||||
def test_markdown_to_html_numbered_list_normalizes_whitespace() -> None:
|
||||
from nanobot.channels.telegram import _markdown_to_telegram_html
|
||||
|
||||
# Extra spaces after dot should be normalized
|
||||
text = "1. Lots of space\n2. Two spaces"
|
||||
result = _markdown_to_telegram_html(text)
|
||||
assert "1. Lots of space" in result
|
||||
assert "2. Two spaces" in result
|
||||
|
||||
|
||||
def test_markdown_to_html_headers_survive_html_escaping() -> None:
|
||||
"""Headers containing special HTML chars should still render as bold."""
|
||||
from nanobot.channels.telegram import _markdown_to_telegram_html
|
||||
|
||||
result = _markdown_to_telegram_html("# A < B & C > D")
|
||||
assert "<b>A < B & C > D</b>" == result
|
||||
|
||||
|
||||
def test_markdown_to_html_mixed_formatting() -> None:
|
||||
"""Headers, bullets, numbered lists, and bold coexist correctly."""
|
||||
from nanobot.channels.telegram import _markdown_to_telegram_html
|
||||
|
||||
text = "# Overview\n\n- bullet one\n- bullet two\n\n1. step one\n2. step two\n\n**bold text**"
|
||||
result = _markdown_to_telegram_html(text)
|
||||
assert "<b>Overview</b>" in result
|
||||
assert "\u2022 bullet one" in result
|
||||
assert "1. step one" in result
|
||||
assert "<b>bold text</b>" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_md_block tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_strip_md_block_removes_inline_formatting() -> None:
|
||||
from nanobot.channels.telegram import _strip_md_block
|
||||
|
||||
text = "**bold** and _italic_ and ~~struck~~"
|
||||
result = _strip_md_block(text)
|
||||
assert result == "bold and italic and struck"
|
||||
|
||||
|
||||
def test_strip_md_block_strips_headers() -> None:
|
||||
from nanobot.channels.telegram import _strip_md_block
|
||||
|
||||
assert _strip_md_block("## Title\nBody") == "Title\nBody"
|
||||
|
||||
|
||||
def test_strip_md_block_converts_bullets_and_numbers() -> None:
|
||||
from nanobot.channels.telegram import _strip_md_block
|
||||
|
||||
text = "- item a\n1. item b\n2. item c"
|
||||
result = _strip_md_block(text)
|
||||
assert "\u2022 item a" in result
|
||||
assert "1. item b" in result
|
||||
assert "2. item c" in result
|
||||
|
||||
|
||||
def test_strip_md_block_strips_links() -> None:
|
||||
from nanobot.channels.telegram import _strip_md_block
|
||||
|
||||
assert _strip_md_block("[click here](https://example.com)") == "click here"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming mid-edit uses _strip_md_block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_mid_stream_strips_markdown() -> None:
|
||||
"""Mid-stream edits should strip markdown so users see clean text."""
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=42))
|
||||
channel._app.bot.edit_message_text = AsyncMock()
|
||||
|
||||
# Initial send with markdown
|
||||
await channel.send_delta("999", "**hello** world")
|
||||
sent_text = channel._app.bot.send_message.call_args.kwargs.get("text", "")
|
||||
# Should NOT contain raw markdown asterisks
|
||||
assert "**" not in sent_text
|
||||
assert "hello world" in sent_text
|
||||
|
||||
# Mid-stream edit
|
||||
import time
|
||||
buf = channel._stream_bufs["999"]
|
||||
buf.last_edit = time.monotonic() - 10 # force edit interval
|
||||
await channel.send_delta("999", "\n### Title\n1. step")
|
||||
edited_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
|
||||
assert "###" not in edited_text
|
||||
assert "**" not in edited_text
|
||||
assert "Title" in edited_text
|
||||
assert "1. step" in edited_text
|
||||
|
||||
@ -183,3 +183,22 @@ def test_make_console_force_terminal_false_when_stdout_is_not_tty():
|
||||
with patch.object(sys.stdout, "isatty", return_value=False):
|
||||
console = stream_mod._make_console()
|
||||
assert console._force_terminal is False
|
||||
|
||||
|
||||
def test_render_interactive_ansi_force_terminal_follows_isatty():
|
||||
"""Mirror of _make_console: the capture console used to produce ANSI for
|
||||
prompt_toolkit must also defer to sys.stdout.isatty(), otherwise cursor
|
||||
escapes and spinner frames leak into piped output (#3265, #3370)."""
|
||||
import sys
|
||||
captured: dict = {}
|
||||
|
||||
def render_fn(c):
|
||||
captured["console"] = c
|
||||
|
||||
with patch.object(sys.stdout, "isatty", return_value=True):
|
||||
commands._render_interactive_ansi(render_fn)
|
||||
assert captured["console"]._force_terminal is True
|
||||
|
||||
with patch.object(sys.stdout, "isatty", return_value=False):
|
||||
commands._render_interactive_ansi(render_fn)
|
||||
assert captured["console"]._force_terminal is False
|
||||
|
||||
@ -1288,10 +1288,15 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
async def run(self) -> None:
|
||||
return None
|
||||
|
||||
class _FakeSessionManager:
|
||||
def flush_all(self) -> int:
|
||||
return 0
|
||||
|
||||
class _FakeAgentLoop:
|
||||
def __init__(self, **_kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.dream = _FakeDream()
|
||||
self.sessions = _FakeSessionManager()
|
||||
|
||||
async def run(self) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
143
tests/command/test_router_dispatchable.py
Normal file
143
tests/command/test_router_dispatchable.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""Tests for CommandRouter.is_dispatchable_command and mid-turn command interception."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.command.builtin import register_builtin_commands
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
|
||||
|
||||
class TestIsDispatchableCommand:
|
||||
"""Unit tests for the is_dispatchable_command() predicate."""
|
||||
|
||||
@pytest.fixture()
|
||||
def router(self) -> CommandRouter:
|
||||
r = CommandRouter()
|
||||
register_builtin_commands(r)
|
||||
return r
|
||||
|
||||
def test_exact_commands_match(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/new")
|
||||
assert router.is_dispatchable_command("/help")
|
||||
assert router.is_dispatchable_command("/dream")
|
||||
assert router.is_dispatchable_command("/dream-log")
|
||||
assert router.is_dispatchable_command("/dream-restore")
|
||||
|
||||
def test_prefix_commands_match(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/dream-log abc123")
|
||||
assert router.is_dispatchable_command("/dream-restore def456")
|
||||
|
||||
def test_priority_commands_not_matched(self, router: CommandRouter) -> None:
|
||||
# Priority commands are NOT in the dispatchable tiers — they are
|
||||
# handled by is_priority() separately.
|
||||
assert not router.is_dispatchable_command("/stop")
|
||||
assert not router.is_dispatchable_command("/restart")
|
||||
|
||||
def test_regular_text_not_matched(self, router: CommandRouter) -> None:
|
||||
assert not router.is_dispatchable_command("hello")
|
||||
assert not router.is_dispatchable_command("what is 2+2?")
|
||||
assert not router.is_dispatchable_command("")
|
||||
|
||||
def test_case_insensitive(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/NEW")
|
||||
assert router.is_dispatchable_command("/Help")
|
||||
|
||||
def test_strips_whitespace(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command(" /new ")
|
||||
|
||||
def test_unknown_slash_command_not_matched(self, router: CommandRouter) -> None:
|
||||
assert not router.is_dispatchable_command("/unknown")
|
||||
assert not router.is_dispatchable_command("/foo bar")
|
||||
|
||||
|
||||
class TestMidTurnCommandDispatchedDirectly:
|
||||
"""Verify that commands matching is_dispatchable_command() are dispatched
|
||||
correctly when session=None (the mid-turn path)."""
|
||||
|
||||
@pytest.fixture()
|
||||
def router(self) -> CommandRouter:
|
||||
r = CommandRouter()
|
||||
register_builtin_commands(r)
|
||||
return r
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_loop(self) -> MagicMock:
|
||||
loop = MagicMock()
|
||||
loop.sessions = MagicMock()
|
||||
loop.sessions.get_or_create = MagicMock(return_value=MagicMock(
|
||||
messages=[], last_consolidated=0, clear=MagicMock(),
|
||||
))
|
||||
loop.sessions.save = MagicMock()
|
||||
loop.sessions.invalidate = MagicMock()
|
||||
loop._schedule_background = MagicMock()
|
||||
loop._cancel_active_tasks = AsyncMock(return_value=0)
|
||||
return loop
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_msg(self) -> MagicMock:
|
||||
msg = MagicMock()
|
||||
msg.channel = "test"
|
||||
msg.chat_id = "chat1"
|
||||
msg.content = "/new"
|
||||
msg.metadata = {}
|
||||
return msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_dispatched_with_session_none(
|
||||
self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
|
||||
) -> None:
|
||||
"""cmd_new works when session=None (mid-turn dispatch path)."""
|
||||
ctx = CommandContext(
|
||||
msg=fake_msg, session=None,
|
||||
key="test:chat1", raw="/new", loop=fake_loop,
|
||||
)
|
||||
result = await router.dispatch(ctx)
|
||||
assert result is not None
|
||||
assert "New session" in result.content
|
||||
fake_loop.sessions.get_or_create.assert_called_once_with("test:chat1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_dispatched_with_session_none(
|
||||
self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
|
||||
) -> None:
|
||||
ctx = CommandContext(
|
||||
msg=fake_msg, session=None,
|
||||
key="test:chat1", raw="/help", loop=fake_loop,
|
||||
)
|
||||
result = await router.dispatch(ctx)
|
||||
assert result is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefix_command_args_populated(self, router: CommandRouter) -> None:
|
||||
"""Prefix commands have args populated correctly in mid-turn path."""
|
||||
# Use a custom prefix handler to avoid needing full mock setup.
|
||||
custom = CommandRouter()
|
||||
captured_args = []
|
||||
|
||||
async def fake_handler(ctx: CommandContext) -> None:
|
||||
captured_args.append(ctx.args)
|
||||
return None
|
||||
|
||||
custom.prefix("/test ", fake_handler)
|
||||
|
||||
ctx = CommandContext(
|
||||
msg=MagicMock(channel="test", chat_id="c1", metadata={}),
|
||||
session=None, key="test:c1", raw="/test hello world", loop=MagicMock(),
|
||||
)
|
||||
await custom.dispatch(ctx)
|
||||
assert captured_args == ["hello world"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_command_returns_none(
|
||||
self, router: CommandRouter, fake_loop: MagicMock, fake_msg: MagicMock,
|
||||
) -> None:
|
||||
"""Regular text returns None from dispatch (not a command)."""
|
||||
ctx = CommandContext(
|
||||
msg=fake_msg, session=None,
|
||||
key="test:chat1", raw="hello world", loop=fake_loop,
|
||||
)
|
||||
result = await router.dispatch(ctx)
|
||||
assert result is None
|
||||
139
tests/providers/test_anthropic_merge_consecutive.py
Normal file
139
tests/providers/test_anthropic_merge_consecutive.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""Tests for AnthropicProvider._merge_consecutive."""
|
||||
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
|
||||
class TestMergeConsecutive:
|
||||
"""Verify role alternation and trailing-assistant stripping."""
|
||||
|
||||
def test_basic_alternation(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
{"role": "user", "content": "bye"},
|
||||
]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
assert len(result) == 3
|
||||
assert [m["role"] for m in result] == ["user", "assistant", "user"]
|
||||
|
||||
def test_consecutive_same_role_merged(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "a"},
|
||||
{"role": "user", "content": "b"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
# Two user messages merged into one, trailing assistant stripped
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
|
||||
def test_trailing_assistant_stripped(self):
|
||||
"""Anthropic rejects prefill — trailing assistant must be removed."""
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["content"] == "hello"
|
||||
|
||||
def test_multiple_trailing_assistant_stripped(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "a"},
|
||||
{"role": "user", "content": "ok"},
|
||||
{"role": "assistant", "content": "b"},
|
||||
{"role": "assistant", "content": "c"},
|
||||
]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
# b+c merged into one assistant, then stripped as trailing
|
||||
assert len(result) == 3
|
||||
assert result[-1]["role"] == "user"
|
||||
assert result[-1]["content"] == "ok"
|
||||
|
||||
def test_empty_messages(self):
|
||||
assert AnthropicProvider._merge_consecutive([]) == []
|
||||
|
||||
def test_single_user_message(self):
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_single_assistant_rerouted_to_user(self):
|
||||
"""When stripping leaves nothing, the last assistant is rerouted to
|
||||
``user`` so we don't produce an empty messages array."""
|
||||
msgs = [{"role": "assistant", "content": "hi"}]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["content"] == "hi"
|
||||
|
||||
def test_all_assistants_collapse_then_rerouted(self):
|
||||
"""Consecutive trailing assistants merge into one, which is then
|
||||
rerouted as a user turn carrying the merged content."""
|
||||
msgs = [
|
||||
{"role": "assistant", "content": "a"},
|
||||
{"role": "assistant", "content": "b"},
|
||||
]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
# "b" was merged into "a"'s block list during the merge pass.
|
||||
assert result[0]["content"] == [
|
||||
{"type": "text", "text": "a"},
|
||||
{"type": "text", "text": "b"},
|
||||
]
|
||||
|
||||
def test_assistant_with_tool_use_not_rerouted(self):
|
||||
"""A trailing assistant carrying ``tool_use`` blocks cannot become a
|
||||
user turn (Anthropic rejects ``tool_use`` inside user messages), so
|
||||
the method returns an empty list rather than forging a bad request."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "let me search"},
|
||||
{"type": "tool_use", "id": "t1", "name": "search", "input": {}},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
assert result == []
|
||||
|
||||
def test_leading_assistant_gets_synthetic_user(self):
|
||||
"""If the first turn is a bare assistant (e.g. history truncation
|
||||
dropped the original user request), prepend a synthetic opener so
|
||||
the conversation still starts with ``user``."""
|
||||
msgs = [
|
||||
{"role": "assistant", "content": "hi"},
|
||||
{"role": "user", "content": "ok"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
assert [m["role"] for m in result] == ["user", "assistant", "user"]
|
||||
assert result[0]["content"] == "(conversation continued)"
|
||||
assert result[1]["content"] == "hi"
|
||||
assert result[2]["content"] == "ok"
|
||||
|
||||
def test_leading_assistant_with_tool_use_left_alone(self):
|
||||
"""Don't prepend a synthetic opener before an assistant carrying
|
||||
``tool_use``; doing so would orphan the paired ``tool_result`` that
|
||||
follows. The caller will see the original 400 rather than a
|
||||
harder-to-diagnose tool-pair mismatch."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "t1", "name": "search", "input": {}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = AnthropicProvider._merge_consecutive(msgs)
|
||||
assert [m["role"] for m in result] == ["assistant", "user"]
|
||||
@ -731,15 +731,45 @@ def test_dashscope_thinking_enabled_with_reasoning_effort() -> None:
|
||||
|
||||
|
||||
def test_dashscope_thinking_disabled_for_minimal() -> None:
|
||||
"""'minimal' → wire 'minimum' + thinking off on DashScope."""
|
||||
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal")
|
||||
assert kw["reasoning_effort"] == "minimum"
|
||||
assert kw["extra_body"] == {"enable_thinking": False}
|
||||
|
||||
|
||||
def test_dashscope_thinking_disabled_for_minimum_alias() -> None:
|
||||
"""Native 'minimum' spelling must also disable thinking, not enable it."""
|
||||
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimum")
|
||||
assert kw["reasoning_effort"] == "minimum"
|
||||
assert kw["extra_body"] == {"enable_thinking": False}
|
||||
|
||||
|
||||
def test_non_dashscope_minimal_not_retranslated() -> None:
|
||||
"""DashScope-specific translation must not leak to other providers."""
|
||||
kw = _build_kwargs_for("openai", "gpt-5", reasoning_effort="minimal")
|
||||
assert kw["reasoning_effort"] == "minimal"
|
||||
|
||||
|
||||
def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None:
|
||||
kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None)
|
||||
assert "extra_body" not in kw
|
||||
|
||||
|
||||
def test_minimax_reasoning_split_enabled_with_reasoning_effort() -> None:
|
||||
kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort="medium")
|
||||
assert kw["extra_body"] == {"reasoning_split": True}
|
||||
|
||||
|
||||
def test_minimax_reasoning_split_disabled_for_minimal() -> None:
|
||||
kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort="minimal")
|
||||
assert kw["extra_body"] == {"reasoning_split": False}
|
||||
|
||||
|
||||
def test_minimax_no_extra_body_when_reasoning_effort_none() -> None:
|
||||
kw = _build_kwargs_for("minimax", "MiniMax-M2.7", reasoning_effort=None)
|
||||
assert "extra_body" not in kw
|
||||
|
||||
|
||||
def test_volcengine_thinking_enabled() -> None:
|
||||
kw = _build_kwargs_for("volcengine", "doubao-seed-2-0-pro", reasoning_effort="high")
|
||||
assert kw["extra_body"] == {"thinking": {"type": "enabled"}}
|
||||
@ -784,6 +814,25 @@ def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
|
||||
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium")
|
||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||
|
||||
|
||||
def test_kimi_k26_thinking_enabled() -> None:
|
||||
"""kimi-k2.6 with reasoning_effort set should opt in to thinking."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort="medium")
|
||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||
|
||||
|
||||
def test_kimi_k26_thinking_enabled_with_openrouter_prefix() -> None:
|
||||
"""OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking."""
|
||||
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.6", reasoning_effort="medium")
|
||||
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
|
||||
|
||||
|
||||
def test_moonshot_kimi_k26_temperature_override() -> None:
|
||||
"""Moonshot registry forces temperature 1.0 for kimi-k2.6 (API requirement)."""
|
||||
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort=None)
|
||||
assert kw["temperature"] == 1.0
|
||||
|
||||
|
||||
def test_kimi_k25_thinking_disabled_with_openrouter_prefix() -> None:
|
||||
"""OpenRouter names must NOT trigger thinking without reasoning_effort."""
|
||||
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort=None)
|
||||
|
||||
0
tests/session/__init__.py
Normal file
0
tests/session/__init__.py
Normal file
130
tests/session/test_session_fsync.py
Normal file
130
tests/session/test_session_fsync.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""Tests for session fsync and flush_all on graceful shutdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions_dir(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "sessions"
|
||||
d.mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(sessions_dir: Path) -> SessionManager:
|
||||
return SessionManager(workspace=sessions_dir)
|
||||
|
||||
|
||||
class TestSaveFsync:
|
||||
"""Verify that save(fsync=True) calls os.fsync."""
|
||||
|
||||
def test_save_without_fsync_does_not_call_fsync(self, manager: SessionManager):
|
||||
session = manager.get_or_create("test:no-fsync")
|
||||
session.add_message("user", "hello")
|
||||
|
||||
with patch("os.fsync") as mock_fsync:
|
||||
manager.save(session, fsync=False)
|
||||
mock_fsync.assert_not_called()
|
||||
|
||||
def test_save_with_fsync_calls_fsync(self, manager: SessionManager):
|
||||
session = manager.get_or_create("test:with-fsync")
|
||||
session.add_message("user", "hello")
|
||||
|
||||
with patch("os.fsync") as mock_fsync:
|
||||
manager.save(session, fsync=True)
|
||||
# File fsync always runs; directory fsync only on non-Windows.
|
||||
expected = 1 if _IS_WINDOWS else 2
|
||||
assert mock_fsync.call_count == expected
|
||||
|
||||
def test_save_default_no_fsync(self, manager: SessionManager):
|
||||
"""Default save() should not fsync (backward compat)."""
|
||||
session = manager.get_or_create("test:default")
|
||||
session.add_message("user", "hello")
|
||||
|
||||
with patch("os.fsync") as mock_fsync:
|
||||
manager.save(session)
|
||||
mock_fsync.assert_not_called()
|
||||
|
||||
|
||||
class TestFlushAll:
|
||||
"""Verify flush_all re-saves all cached sessions with fsync."""
|
||||
|
||||
def test_flush_all_empty_cache(self, manager: SessionManager):
|
||||
assert manager.flush_all() == 0
|
||||
|
||||
def test_flush_all_saves_cached_sessions(self, manager: SessionManager):
|
||||
s1 = manager.get_or_create("test:session-1")
|
||||
s1.add_message("user", "msg 1")
|
||||
manager.save(s1)
|
||||
|
||||
s2 = manager.get_or_create("test:session-2")
|
||||
s2.add_message("user", "msg 2")
|
||||
manager.save(s2)
|
||||
|
||||
flushed = manager.flush_all()
|
||||
assert flushed == 2
|
||||
|
||||
def test_flush_all_uses_fsync(self, manager: SessionManager):
|
||||
session = manager.get_or_create("test:fsync-check")
|
||||
session.add_message("user", "important")
|
||||
manager.save(session)
|
||||
|
||||
with patch("os.fsync") as mock_fsync:
|
||||
manager.flush_all()
|
||||
# file fsync always; directory fsync only on non-Windows
|
||||
expected = 1 if _IS_WINDOWS else 2
|
||||
assert mock_fsync.call_count == expected
|
||||
|
||||
def test_flush_all_continues_on_error(self, manager: SessionManager):
|
||||
"""One broken session should not prevent others from flushing."""
|
||||
s1 = manager.get_or_create("test:good")
|
||||
s1.add_message("user", "ok")
|
||||
manager.save(s1)
|
||||
|
||||
s2 = manager.get_or_create("test:bad")
|
||||
s2.add_message("user", "ok")
|
||||
manager.save(s2)
|
||||
|
||||
original_save = manager.save
|
||||
call_count = {"n": 0}
|
||||
|
||||
def patched_save(session, *, fsync=False):
|
||||
call_count["n"] += 1
|
||||
if session.key == "test:bad":
|
||||
raise OSError("disk on fire")
|
||||
original_save(session, fsync=fsync)
|
||||
|
||||
manager.save = patched_save
|
||||
flushed = manager.flush_all()
|
||||
|
||||
# One succeeded, one failed — flush_all returns successful count
|
||||
assert flushed == 1
|
||||
assert call_count["n"] == 2
|
||||
|
||||
def test_flush_all_data_survives_reload(self, sessions_dir: Path):
|
||||
"""Data flushed by flush_all should survive a fresh SessionManager load."""
|
||||
mgr1 = SessionManager(workspace=sessions_dir)
|
||||
session = mgr1.get_or_create("test:persist")
|
||||
session.add_message("user", "remember this")
|
||||
session.add_message("assistant", "noted")
|
||||
mgr1.save(session)
|
||||
mgr1.flush_all()
|
||||
|
||||
# Simulate process restart — new manager, cold cache
|
||||
mgr2 = SessionManager(workspace=sessions_dir)
|
||||
reloaded = mgr2.get_or_create("test:persist")
|
||||
history = reloaded.get_history(max_messages=100)
|
||||
|
||||
assert len(history) == 2
|
||||
assert history[0]["content"] == "remember this"
|
||||
assert history[1]["content"] == "noted"
|
||||
@ -1,7 +1,8 @@
|
||||
"""Tests for ReadFileTool enhancements: description fix, read dedup, PDF support, device blacklist."""
|
||||
"""Tests for ReadFileTool enhancements: description fix, read dedup, PDF support, device blacklist, office docs."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@ -246,3 +247,123 @@ class TestReadFileLineEndingNormalization:
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "\r" not in result
|
||||
assert "alpha" in result and "beta" in result and "gamma" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Office document support (DOCX, XLSX, PPTX)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadOfficeDocuments:
|
||||
|
||||
@pytest.fixture()
|
||||
def tool(self, tmp_path):
|
||||
return ReadFileTool(workspace=tmp_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docx_returns_extracted_text(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value="Title\n\nParagraph 1"):
|
||||
f = tmp_path / "test.docx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "Title" in result
|
||||
assert "Paragraph 1" in result
|
||||
assert "Error" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_xlsx_returns_extracted_text(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value="--- Sheet: Sheet1 ---\nName\tAge\nAlice\t30"):
|
||||
f = tmp_path / "test.xlsx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "Sheet1" in result
|
||||
assert "Alice" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pptx_returns_extracted_text(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value="--- Slide 1 ---\nWelcome\n--- Slide 2 ---\nContent"):
|
||||
f = tmp_path / "test.pptx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "Welcome" in result
|
||||
assert "Content" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docx_missing_library(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value="[error: python-docx not installed]"):
|
||||
f = tmp_path / "test.docx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "Error" in result
|
||||
assert "python-docx not installed" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docx_corrupt_file(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value="[error: failed to extract DOCX: bad zip]"):
|
||||
f = tmp_path / "test.docx"
|
||||
f.write_bytes(b"not-a-zip")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "Error" in result
|
||||
assert "failed to extract DOCX" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_extension(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value=None):
|
||||
f = tmp_path / "test.docx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "Error" in result
|
||||
assert "Unsupported" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_document_returns_descriptive_message(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value=""):
|
||||
f = tmp_path / "empty.docx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "no extractable text" in result
|
||||
|
||||
|
||||
class TestOfficeDocTruncation:
|
||||
|
||||
@pytest.fixture()
|
||||
def tool(self, tmp_path):
|
||||
return ReadFileTool(workspace=tmp_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_document_truncated(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value="x" * 200_000):
|
||||
f = tmp_path / "large.docx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert len(result) <= ReadFileTool._MAX_CHARS + 100
|
||||
assert "truncated at ~128K chars" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_document_not_truncated(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value="Hello world"):
|
||||
f = tmp_path / "small.docx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "truncated" not in result
|
||||
assert "Hello world" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_response_not_truncated(self, tool, tmp_path):
|
||||
with patch("nanobot.utils.document.extract_text", return_value="[error: failed to extract DOCX: something went wrong]"):
|
||||
f = tmp_path / "bad.docx"
|
||||
f.write_bytes(b"PK")
|
||||
result = await tool.execute(path=str(f))
|
||||
assert "Error" in result
|
||||
assert "truncated" not in result
|
||||
|
||||
|
||||
class TestReadDescriptionUpdate:
|
||||
|
||||
def test_description_mentions_documents(self):
|
||||
tool = ReadFileTool()
|
||||
desc = tool.description.lower()
|
||||
assert "document" in desc or "docx" in desc or "xlsx" in desc or "pptx" in desc
|
||||
|
||||
def test_description_no_longer_says_cannot_read(self):
|
||||
tool = ReadFileTool()
|
||||
assert "cannot read" not in tool.description.lower()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# nanobot webui
|
||||
|
||||
The browser front-end for `nanobot web`. It is built with Vite + React 18 +
|
||||
The browser front-end for the nanobot gateway. It is built with Vite + React 18 +
|
||||
TypeScript + Tailwind 3 + shadcn/ui, talks to the gateway over the WebSocket
|
||||
multiplex protocol, and reads session metadata from the embedded REST surface
|
||||
on the same port.
|
||||
@ -22,7 +22,7 @@ For the project overview, install guide, and general docs map, see the root
|
||||
|
||||
```text
|
||||
webui/ source tree (this directory)
|
||||
nanobot/web/dist/ build output consumed by `nanobot web`
|
||||
nanobot/web/dist/ build output served by the gateway
|
||||
```
|
||||
|
||||
## Develop from source
|
||||
@ -35,7 +35,15 @@ From the repository root:
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 2. Start the gateway
|
||||
### 2. Enable the WebSocket channel
|
||||
|
||||
In `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{ "channels": { "websocket": { "enabled": true } } }
|
||||
```
|
||||
|
||||
### 3. Start the gateway
|
||||
|
||||
In one terminal:
|
||||
|
||||
@ -43,7 +51,7 @@ In one terminal:
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
### 3. Start the WebUI dev server
|
||||
### 4. Start the WebUI dev server
|
||||
|
||||
In another terminal:
|
||||
|
||||
@ -72,7 +80,7 @@ bun run build
|
||||
```
|
||||
|
||||
This writes the production assets to `../nanobot/web/dist`, which is the
|
||||
directory served by `nanobot web` and bundled into the Python wheel.
|
||||
directory served by `nanobot gateway` and bundled into the Python wheel.
|
||||
|
||||
If you are cutting a release, run the build before packaging so the published
|
||||
wheel contains the current WebUI assets.
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "Couldn't reach nanobot",
|
||||
"gatewayHint": "Make sure the gateway is running (`nanobot web`) and that this page is open on the same machine."
|
||||
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "No se pudo conectar con nanobot",
|
||||
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot web`) y de que esta página esté abierta en la misma máquina."
|
||||
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "Impossible de joindre nanobot",
|
||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot web`) et que cette page est ouverte sur la même machine."
|
||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "Tidak dapat menjangkau nanobot",
|
||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot web`) dan halaman ini dibuka pada mesin yang sama."
|
||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "nanobot に接続できませんでした",
|
||||
"gatewayHint": "gateway(`nanobot web`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "nanobot에 연결할 수 없습니다",
|
||||
"gatewayHint": "gateway(`nanobot web`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "Không thể kết nối tới nanobot",
|
||||
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot web`) và trang này được mở trên cùng máy."
|
||||
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "无法连接到 nanobot",
|
||||
"gatewayHint": "请确认 gateway 已启动(`nanobot web`),并且当前页面与 gateway 运行在同一台机器上。"
|
||||
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "無法連線到 nanobot",
|
||||
"gatewayHint": "請確認 gateway 已啟動(`nanobot web`),並且目前頁面與 gateway 在同一台機器上開啟。"
|
||||
"gatewayHint": "請確認 gateway 已啟動(`nanobot gateway`),並且目前頁面與 gateway 在同一台機器上開啟。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user