Merge remote-tracking branch 'origin/main' into nightly

This commit is contained in:
chengyongru 2026-04-22 13:29:10 +08:00
commit 9bf7f3b420
42 changed files with 2027 additions and 258 deletions

View File

@ -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

View File

@ -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 |

View File

@ -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"` |

View File

@ -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()

View File

@ -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)

View File

@ -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*."""

View File

@ -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

View 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(

View File

@ -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"),

View File

@ -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:

View File

@ -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
# ============================================================================

View File

@ -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",

View File

@ -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())

View File

@ -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
# ------------------------------------------------------------------

View File

@ -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"}}
)

View File

@ -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(

View File

@ -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)

View File

@ -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}]"

View File

@ -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"]

View 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]}"
)

View 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

View 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"

View File

@ -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)

View File

@ -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 &lt; B &amp; C &gt; 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

View File

@ -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

View File

@ -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()

View 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

View 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"]

View File

@ -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)

View File

View 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"

View File

@ -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()

View File

@ -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.

View File

@ -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",

View File

@ -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",

View File

@ -7,7 +7,7 @@
},
"error": {
"title": "Impossible de joindre nanobot",
"gatewayHint": "Assurez-vous que la gateway est en cours dexécution (`nanobot web`) et que cette page est ouverte sur la même machine."
"gatewayHint": "Assurez-vous que la gateway est en cours dexécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
},
"documentTitle": {
"base": "nanobot",

View File

@ -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",

View File

@ -7,7 +7,7 @@
},
"error": {
"title": "nanobot に接続できませんでした",
"gatewayHint": "gateway`nanobot web`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
"gatewayHint": "gateway`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
},
"documentTitle": {
"base": "nanobot",

View File

@ -7,7 +7,7 @@
},
"error": {
"title": "nanobot에 연결할 수 없습니다",
"gatewayHint": "gateway(`nanobot web`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
},
"documentTitle": {
"base": "nanobot",

View File

@ -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",

View File

@ -7,7 +7,7 @@
},
"error": {
"title": "无法连接到 nanobot",
"gatewayHint": "请确认 gateway 已启动(`nanobot web`),并且当前页面与 gateway 运行在同一台机器上。"
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
},
"documentTitle": {
"base": "nanobot",

View File

@ -7,7 +7,7 @@
},
"error": {
"title": "無法連線到 nanobot",
"gatewayHint": "請確認 gateway 已啟動(`nanobot web`),並且目前頁面與 gateway 在同一台機器上開啟。"
"gatewayHint": "請確認 gateway 已啟動(`nanobot gateway`),並且目前頁面與 gateway 在同一台機器上開啟。"
},
"documentTitle": {
"base": "nanobot",