diff --git a/README.md b/README.md index c9d89e88f..99c5ea2c4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ ## ๐Ÿ“ข News +- **2026-04-29** ๐Ÿš€ Released **v0.1.5.post3** โ€” Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details. +- **2026-04-28** ๐ŸŒ Olostep web search, Hugging Face provider, safer workspace-tool interruptions. +- **2026-04-27** ๐Ÿ’ฌ `/history` command, smarter session replay caps, smoother Discord / Slack threads. +- **2026-04-26** ๐Ÿงญ Natural cron reminders, thread-aware restarts, safer local provider and shell behavior. +- **2026-04-25** ๐Ÿงฉ `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup. +- **2026-04-24** ๐ŸŽฅ Video attachments for channels, DeepSeek thinking control, faster document startup. +- **2026-04-23** ๐Ÿงต Discord thread sessions, Telegram inline buttons, structured tool progress updates. +- **2026-04-22** ๐Ÿ”Ž GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads. - **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. diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index a98f3a882..55d3a85b6 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -17,7 +17,7 @@ import { Boom } from '@hapi/boom'; import qrcode from 'qrcode-terminal'; import pino from 'pino'; import { readFile, writeFile, mkdir } from 'fs/promises'; -import { join, basename } from 'path'; +import { join, basename, resolve, sep } from 'path'; import { randomBytes } from 'crypto'; const VERSION = '0.1.0'; @@ -196,17 +196,18 @@ export class WhatsAppClient { let outFilename: string; if (fileName) { - // Documents have a filename โ€” use it with a unique prefix to avoid collisions - const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`; - outFilename = prefix + fileName; + const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_'); + outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`; } else { const mime = mimetype || 'application/octet-stream'; - // Derive extension from mimetype subtype (e.g. "image/png" โ†’ ".png", "application/pdf" โ†’ ".pdf") const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin'); outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`; } - const filepath = join(mediaDir, outFilename); + const filepath = resolve(mediaDir, outFilename); + if (!filepath.startsWith(resolve(mediaDir) + sep)) { + throw new Error(`Path traversal blocked: ${outFilename}`); + } await writeFile(filepath, buffer); return filepath; diff --git a/docs/configuration.md b/docs/configuration.md index 427b64d4c..f295b50c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -474,6 +474,26 @@ Global settings that apply to all channels. Configure under the `channels` secti | `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. | | `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. | +`sendProgress` and `sendToolHints` can also be overridden per channel. The +global values stay as defaults for channels that do not set their own value: + +```json +{ + "channels": { + "sendProgress": true, + "sendToolHints": false, + "telegram": { + "enabled": true, + "sendProgress": false + }, + "websocket": { + "enabled": true, + "sendToolHints": true + } + } +} +``` + ### Retry Behavior Retry is intentionally simple. diff --git a/nanobot/__init__.py b/nanobot/__init__.py index e2428d2d7..e6fdbf0ba 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -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.post2" + return _read_pyproject_version() or "0.1.5.post3" __version__ = _resolve_version() diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c03d80651..cbddfc286 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -258,6 +258,7 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, disabled_skills=disabled_skills, + max_iterations=self.max_iterations, ) self._unified_session = unified_session self._max_messages = max_messages if max_messages > 0 else 120 @@ -307,6 +308,10 @@ class AgentLoop: self.commands = CommandRouter() register_builtin_commands(self.commands) + def _sync_subagent_runtime_limits(self) -> None: + """Keep subagent runtime limits aligned with mutable loop settings.""" + self.subagents.max_iterations = self.max_iterations + def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None: """Swap model/provider for future turns without disturbing an active one.""" provider = snapshot.provider @@ -531,6 +536,8 @@ class AgentLoop: Returns (final_content, tools_used, messages, stop_reason, had_injections). """ + self._sync_subagent_runtime_limits() + loop_hook = _LoopHook( self, on_progress=on_progress, diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index f11bd6af5..4bf79c356 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import json +import os import re import weakref import tiktoken @@ -359,10 +360,31 @@ class MemoryStore: return None def _write_entries(self, entries: list[dict[str, Any]]) -> None: - """Overwrite history.jsonl with the given entries.""" - with open(self.history_file, "w", encoding="utf-8") as f: - for entry in entries: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") + """Overwrite history.jsonl with the given entries (atomic write).""" + tmp_path = self.history_file.with_suffix(self.history_file.suffix + ".tmp") + try: + with open(tmp_path, "w", encoding="utf-8") as f: + for entry in entries: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.history_file) + + # 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(self.history_file.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 # -- dream cursor -------------------------------------------------------- diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index c100d205b..e64dc8f97 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -20,7 +20,7 @@ from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus -from nanobot.config.schema import ExecToolConfig, WebToolsConfig +from nanobot.config.schema import AgentDefaults, ExecToolConfig, WebToolsConfig from nanobot.providers.base import LLMProvider from nanobot.utils.prompt_templates import render_template @@ -81,6 +81,7 @@ class SubagentManager: exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, disabled_skills: list[str] | None = None, + max_iterations: int | None = None, ): self.provider = provider self.workspace = workspace @@ -91,6 +92,11 @@ class SubagentManager: self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace self.disabled_skills = set(disabled_skills or []) + self.max_iterations = ( + max_iterations + if max_iterations is not None + else AgentDefaults().max_tool_iterations + ) self.runner = AgentRunner(provider) self._running_tasks: dict[str, asyncio.Task[None]] = {} self._task_statuses: dict[str, SubagentStatus] = {} @@ -202,7 +208,7 @@ class SubagentManager: initial_messages=messages, tools=tools, model=self.model, - max_iterations=15, + max_iterations=self.max_iterations, max_tool_result_chars=self.max_tool_result_chars, hook=_SubagentHook(task_id, status), max_iterations_message="Task completed but no final response was generated.", diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index f05dbf217..59ece04e7 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -394,6 +394,8 @@ class MyTool(Tool): if "min_len" in spec and len(str(value)) < spec["min_len"]: return f"Error: '{key}' must be at least {spec['min_len']} characters" setattr(self._loop, key, value) + if key == "max_iterations" and hasattr(self._loop, "_sync_subagent_runtime_limits"): + self._loop._sync_subagent_runtime_limits() self._audit("modify", f"{key}: {old!r} -> {value!r}") return f"Set {key} = {value!r} (was {old!r})" diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 62bcd45c1..6097b420f 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -26,6 +26,8 @@ class BaseChannel(ABC): transcription_api_key: str = "" transcription_api_base: str = "" transcription_language: str | None = None + send_progress: bool = True + send_tool_hints: bool = False def __init__(self, config: Any, bus: MessageBus): """ diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0071ef418..7ddb8506d 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1170,6 +1170,21 @@ class FeishuChannel(BaseChannel): logger.error("Error replying to Feishu message {}: {}", parent_message_id, e) return False + def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool: + """Return whether a group reply should create a Feishu thread/topic.""" + return metadata.get("chat_type", "group") == "group" and self.config.reply_to_message + + def _thread_reply_target(self, metadata: dict[str, Any]) -> str | None: + """Return the message_id that should receive a Reply API response.""" + if metadata.get("chat_type", "group") != "group": + return None + message_id = metadata.get("message_id") + if not message_id: + return None + if metadata.get("thread_id") or self.config.reply_to_message: + return message_id + return None + def _send_message_sync( self, receive_id_type: str, receive_id: str, msg_type: str, content: str ) -> str | None: @@ -1211,13 +1226,15 @@ class FeishuChannel(BaseChannel): receive_id_type: str, chat_id: str, reply_message_id: str | None = None, + *, + reply_in_thread: bool = False, ) -> str | None: """Create a CardKit streaming card, send it to chat, return card_id. When *reply_message_id* is provided the card is delivered via the - reply API (with reply_in_thread=True) so it lands inside the - originating thread / topic. Otherwise the plain create-message - API is used. + reply API. *reply_in_thread* controls whether Feishu creates a + thread/topic for that reply. Otherwise the plain create-message API is + used. """ from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody @@ -1253,7 +1270,7 @@ class FeishuChannel(BaseChannel): if reply_message_id: sent = self._reply_message_sync( reply_message_id, "interactive", card_content, - reply_in_thread=True, + reply_in_thread=reply_in_thread, ) else: sent = self._send_message_sync( @@ -1409,16 +1426,14 @@ class FeishuChannel(BaseChannel): {"config": {"wide_screen_mode": True}, "elements": chunk}, ensure_ascii=False, ) - # Fallback: reply via the Reply API for group chats. - # Target message_id โ€” the Feishu API keeps the reply in - # the same topic automatically. - _f_msg = meta.get("message_id") - fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None + # Fallback replies stay in existing topics, but only create a + # new topic when reply-to-message is enabled. + fallback_msg_id = self._thread_reply_target(meta) if fallback_msg_id: await loop.run_in_executor( None, lambda: self._reply_message_sync( fallback_msg_id, "interactive", card, - reply_in_thread=True, + reply_in_thread=self._should_use_reply_in_thread(meta), ), ) else: @@ -1438,16 +1453,18 @@ class FeishuChannel(BaseChannel): now = time.monotonic() if buf.card_id is None: - # Send the streaming card as a reply for group chats so it - # lands inside the originating topic/thread. Always target - # message_id (the actual inbound message) โ€” the Feishu Reply - # API keeps the response in the same topic automatically. - is_group = meta.get("chat_type", "group") == "group" - reply_msg_id = meta.get("message_id") if is_group else None + # Use the Reply API for existing topics, and only create new topics + # when reply-to-message is enabled. + use_reply_in_thread = self._should_use_reply_in_thread(meta) + reply_msg_id = self._thread_reply_target(meta) card_id = await loop.run_in_executor( None, - self._create_streaming_card_sync, - rid_type, chat_id, reply_msg_id, + lambda: self._create_streaming_card_sync( + rid_type, + chat_id, + reply_msg_id, + reply_in_thread=use_reply_in_thread, + ), ) if card_id: buf.card_id = card_id @@ -1489,22 +1506,21 @@ class FeishuChannel(BaseChannel): "\n\n" + self._format_tool_hint_delta(hint) + "\n\n", ) return - # No active streaming card โ€” send as a regular - # interactive card with the same ๐Ÿ”ง prefix style. - # Use reply API for group chats so the hint stays in topic. + # No active streaming card โ€” send as a regular interactive card + # with the same ๐Ÿ”ง prefix style. Existing topics stay threaded; + # new topics are created only when reply-to-message is enabled. card = json.dumps( {"config": {"wide_screen_mode": True}, "elements": [ {"tag": "markdown", "content": self._format_tool_hint_delta(hint)}, ]}, ensure_ascii=False, ) - _th_msg_id = msg.metadata.get("message_id") - _th_chat_type = msg.metadata.get("chat_type", "group") - if _th_msg_id and _th_chat_type == "group": + _th_msg_id = self._thread_reply_target(msg.metadata) + if _th_msg_id: await loop.run_in_executor( None, lambda: self._reply_message_sync( _th_msg_id, "interactive", card, - reply_in_thread=True, + reply_in_thread=self._should_use_reply_in_thread(msg.metadata), ), ) else: @@ -1531,18 +1547,16 @@ class FeishuChannel(BaseChannel): def _do_send(m_type: str, content: str) -> None: """Send via reply (first message) or create (subsequent). - For group chats the reply API always uses reply_in_thread=True. - The Feishu API automatically keeps replies inside existing - topics โ€” reply_in_thread only creates a *new* topic when the - target message is a plain (non-topic) message. + Group chats only set reply_in_thread=True when + reply_to_message is enabled; otherwise a Reply API call for an + existing topic must not create a new topic. """ nonlocal first_send if reply_message_id and first_send: first_send = False - chat_type = msg.metadata.get("chat_type", "group") ok = self._reply_message_sync( reply_message_id, m_type, content, - reply_in_thread=chat_type == "group", + reply_in_thread=self._should_use_reply_in_thread(msg.metadata), ) if ok: return diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index ccac6306b..14a6b2a5e 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -27,9 +27,15 @@ def _default_webui_dist() -> Path | None: candidate = Path(web_pkg.__file__).resolve().parent / "dist" return candidate if candidate.is_dir() else None + # Retry delays for message sending (exponential backoff: 1s, 2s, 4s) _SEND_RETRY_DELAYS = (1, 2, 4) +_BOOL_CAMEL_ALIASES: dict[str, str] = { + "send_progress": "sendProgress", + "send_tool_hints": "sendToolHints", +} + class ChannelManager: """ @@ -90,6 +96,12 @@ class ChannelManager: channel.transcription_api_key = transcription_key channel.transcription_api_base = transcription_base channel.transcription_language = transcription_language + channel.send_progress = self._resolve_bool_override( + section, "send_progress", self.config.channels.send_progress, + ) + channel.send_tool_hints = self._resolve_bool_override( + section, "send_tool_hints", self.config.channels.send_tool_hints, + ) self.channels[name] = channel logger.info("{} channel enabled", cls.display_name) except Exception as e: @@ -131,6 +143,31 @@ class ChannelManager: f'Set ["*"] to allow everyone, or add specific user IDs.' ) + def _should_send_progress(self, channel_name: str, *, tool_hint: bool = False) -> bool: + """Return whether progress (or tool-hints) may be sent to *channel_name*.""" + ch = self.channels.get(channel_name) + if ch is None: + logger.warning("Progress check for unknown channel: {}", channel_name) + return False + return ch.send_tool_hints if tool_hint else ch.send_progress + + def _resolve_bool_override(self, section: Any, key: str, default: bool) -> bool: + """Return *key* from *section* if it is a bool, otherwise *default*. + + For dict configs also checks the camelCase alias (e.g. ``sendProgress`` + for ``send_progress``) so raw JSON/TOML configs work alongside + Pydantic models. + """ + if isinstance(section, dict): + value = section.get(key) + if value is None: + camel = _BOOL_CAMEL_ALIASES.get(key) + if camel: + value = section.get(camel) + return value if isinstance(value, bool) else default + value = getattr(section, key, None) + return value if isinstance(value, bool) else default + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -216,9 +253,13 @@ class ChannelManager: ) if msg.metadata.get("_progress"): - if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: + if msg.metadata.get("_tool_hint") and not self._should_send_progress( + msg.channel, tool_hint=True, + ): continue - if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: + if not msg.metadata.get("_tool_hint") and not self._should_send_progress( + msg.channel, tool_hint=False, + ): continue if msg.metadata.get("_retry_wait"): diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 85a167a3a..8ce08ae61 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -262,10 +262,18 @@ class MatrixChannel(BaseChannel): self.store_path.mkdir(parents=True, exist_ok=True) self.session_path = self.store_path / "session.json" + # Replace ':' with '_' to produce a Windows-safe filename + safe_store_name = self.config.user_id.replace(":", "_") + f"_{self.config.device_id}.db" + self.client = AsyncClient( - homeserver=self.config.homeserver, user=self.config.user_id, + homeserver=self.config.homeserver, + user=self.config.user_id, store_path=self.store_path, - config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled), + config=AsyncClientConfig( + store_sync_tokens=True, + encryption_enabled=self.config.e2ee_enabled, + store_name=safe_store_name, + ), ) self._register_event_callbacks() diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index a7fd82654..e2485da72 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -1,6 +1,7 @@ """WhatsApp channel implementation using Node.js bridge.""" import asyncio +import hashlib import json import mimetypes import os @@ -316,13 +317,7 @@ def _ensure_bridge_setup() -> Path: from nanobot.config.paths import get_bridge_install_dir user_bridge = get_bridge_install_dir() - - if (user_bridge / "dist" / "index.js").exists(): - return user_bridge - - npm_path = shutil.which("npm") - if not npm_path: - raise RuntimeError("npm not found. Please install Node.js >= 18.") + stamp_file = user_bridge / ".nanobot-bridge-source-hash" # Find source bridge current_file = Path(__file__) @@ -341,6 +336,33 @@ def _ensure_bridge_setup() -> Path: "Try reinstalling: pip install --force-reinstall nanobot" ) + def source_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if rel.parts and rel.parts[0] in {"node_modules", "dist"}: + continue + digest.update(rel.as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + expected_hash = source_hash(source) + current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None + + if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash: + return user_bridge + + if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash: + logger.info("WhatsApp bridge source changed; rebuilding bridge...") + + npm_path = shutil.which("npm") + if not npm_path: + raise RuntimeError("npm not found. Please install Node.js >= 18.") + logger.info("Setting up WhatsApp bridge...") user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): @@ -352,6 +374,7 @@ def _ensure_bridge_setup() -> Path: logger.info(" Building...") subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True) + stamp_file.write_text(expected_hash + "\n") logger.info("Bridge ready") return user_bridge diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2fe397469..903555b47 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1271,6 +1271,7 @@ def channels_status( def _get_bridge_dir() -> Path: """Get the bridge directory, setting it up if needed.""" + import hashlib import shutil import subprocess @@ -1278,16 +1279,7 @@ def _get_bridge_dir() -> Path: from nanobot.config.paths import get_bridge_install_dir user_bridge = get_bridge_install_dir() - - # Check if already built - if (user_bridge / "dist" / "index.js").exists(): - return user_bridge - - # Check for npm - npm_path = shutil.which("npm") - if not npm_path: - console.print("[red]npm not found. Please install Node.js >= 18.[/red]") - raise typer.Exit(1) + stamp_file = user_bridge / ".nanobot-bridge-source-hash" # Find source bridge: first check package data, then source dir pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) @@ -1304,6 +1296,36 @@ def _get_bridge_dir() -> Path: console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) + def source_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if rel.parts and rel.parts[0] in {"node_modules", "dist"}: + continue + digest.update(rel.as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + expected_hash = source_hash(source) + current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None + + # Reuse only a bridge built from the currently installed source. + if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash: + return user_bridge + + if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash: + console.print(f"{__logo__} WhatsApp bridge source changed; rebuilding bridge...") + + # Check for npm + npm_path = shutil.which("npm") + if not npm_path: + console.print("[red]npm not found. Please install Node.js >= 18.[/red]") + raise typer.Exit(1) + console.print(f"{__logo__} Setting up bridge...") # Copy to user directory @@ -1319,6 +1341,7 @@ def _get_bridge_dir() -> Path: console.print(" Building...") subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True) + stamp_file.write_text(expected_hash + "\n") console.print("[green]โœ“[/green] Bridge ready\n") except subprocess.CalledProcessError as e: diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 5d6d36b79..a46cc32e9 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -434,7 +434,7 @@ class AnthropicProvider(LLMProvider): ) max_tokens = max(1, max_tokens) - thinking_enabled = bool(reasoning_effort) + thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none" # claude-opus-4-7 deprecated the `temperature` parameter entirely โ€” the # API returns 400 if it is present, on any code path. diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 9fd18e1f9..bc2a9d045 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -71,7 +71,7 @@ class AzureOpenAIProvider(LLMProvider): reasoning_effort: str | None = None, ) -> bool: """Return True when temperature is likely supported for this deployment.""" - if reasoning_effort: + if reasoning_effort and reasoning_effort.lower() != "none": return False name = deployment_name.lower() return not any(token in name for token in ("gpt-5", "o1", "o3", "o4")) @@ -102,7 +102,7 @@ class AzureOpenAIProvider(LLMProvider): if self._supports_temperature(deployment, reasoning_effort): body["temperature"] = temperature - if reasoning_effort: + if reasoning_effort and reasoning_effort.lower() != "none": body["reasoning"] = {"effort": reasoning_effort} body["include"] = ["reasoning.encrypted_content"] diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 0cc8e5ca2..945cae9ba 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -60,7 +60,7 @@ class OpenAICodexProvider(LLMProvider): "tool_choice": tool_choice or "auto", "parallel_tool_calls": True, } - if reasoning_effort: + if reasoning_effort and reasoning_effort.lower() != "none": body["reasoning"] = {"effort": reasoning_effort} if tools: body["tools"] = convert_tools(tools) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 363a2e142..6712b68bc 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -570,7 +570,7 @@ class OpenAICompatProvider(LLMProvider): # DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s. wire_effort = "minimum" - if wire_effort: + if wire_effort and semantic_effort != "none": kwargs["reasoning_effort"] = wire_effort # Provider-specific thinking parameters. @@ -579,7 +579,7 @@ class OpenAICompatProvider(LLMProvider): # The mapping is driven by ProviderSpec.thinking_style so that adding # a new provider never requires touching this function. if spec and spec.thinking_style and reasoning_effort is not None: - thinking_enabled = semantic_effort != "minimal" + thinking_enabled = semantic_effort not in ("none", "minimal") extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled) if extra: kwargs.setdefault("extra_body", {}).update(extra) @@ -589,7 +589,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 = semantic_effort != "minimal" + thinking_enabled = semantic_effort not in ("none", "minimal") kwargs.setdefault("extra_body", {}).update( {"thinking": {"type": "enabled" if thinking_enabled else "disabled"}} ) @@ -609,9 +609,9 @@ class OpenAICompatProvider(LLMProvider): # thinking happened on that turn"). thinking_active = ( (spec and spec.thinking_style and reasoning_effort is not None - and semantic_effort != "minimal") + and semantic_effort not in ("none", "minimal")) or (reasoning_effort is not None and _is_kimi_thinking_model(model_name) - and semantic_effort != "minimal") + and semantic_effort not in ("none", "minimal")) ) if thinking_active: for msg in kwargs["messages"]: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 807742077..6f947bab0 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -267,7 +267,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( # Gemini: Google's OpenAI-compatible endpoint ProviderSpec( name="gemini", - keywords=("gemini",), + keywords=("gemini", "gemma"), env_key="GEMINI_API_KEY", display_name="Gemini", backend="openai_compat", diff --git a/pyproject.toml b/pyproject.toml index 0ac2c775d..36185f39f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ [project] name = "nanobot-ai" -version = "0.1.5.post2" +version = "0.1.5.post3" description = "A lightweight personal AI assistant framework" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" license = {text = "MIT"} authors = [ - {name = "nanobot contributors"} + {name = "Xubin Ren"}, + {name = "the nanobot contributors"} ] keywords = ["ai", "agent", "chatbot"] classifiers = [ diff --git a/tests/agent/test_memory_store.py b/tests/agent/test_memory_store.py index 8f3220450..9113437fd 100644 --- a/tests/agent/test_memory_store.py +++ b/tests/agent/test_memory_store.py @@ -141,6 +141,49 @@ class TestHistoryWithCursor: assert len(entries) == 2 assert entries[0]["cursor"] in {4, 5} + def test_write_entries_uses_atomic_write(self, tmp_path): + """_write_entries uses temp file + os.replace for atomicity.""" + store = MemoryStore(tmp_path) + store.append_history("event 1") + store.append_history("event 2") + store.append_history("event 3") + entries = store.read_unprocessed_history(since_cursor=0) + + # Monitor temp file existence + tmp_path_obj = store.history_file.with_suffix(".jsonl.tmp") + assert not tmp_path_obj.exists() # Should not exist initially + + # Call _write_entries + store._write_entries(entries) + + # Temp file should be cleaned up + assert not tmp_path_obj.exists() + # Original file should exist + assert store.history_file.exists() + + def test_write_entries_cleans_up_tmp_on_exception(self, tmp_path, monkeypatch): + """Exception during _write_entries cleans up the temp file.""" + store = MemoryStore(tmp_path) + store.append_history("event 1") + entries = store.read_unprocessed_history(since_cursor=0) + + tmp_path_obj = store.history_file.with_suffix(".jsonl.tmp") + + # Mock os.replace to raise an exception + def failing_replace(*args, **kwargs): + raise RuntimeError("Simulated failure") + + monkeypatch.setattr('os.replace', failing_replace) + + with pytest.raises(RuntimeError): + store._write_entries(entries) + + # Temp file should be cleaned up + assert not tmp_path_obj.exists() + + # Original file should still exist (because replace failed) + assert store.history_file.exists() + class TestAppendHistoryHardCap: """append_history has a defensive cap that catches new callers who forgot diff --git a/tests/agent/tools/test_self_tool_runtime_sync.py b/tests/agent/tools/test_self_tool_runtime_sync.py new file mode 100644 index 000000000..8f65023ff --- /dev/null +++ b/tests/agent/tools/test_self_tool_runtime_sync.py @@ -0,0 +1,29 @@ +"""Focused tests for MyTool runtime sync side effects.""" + +from unittest.mock import MagicMock + +import pytest + +from nanobot.agent.tools.self import MyTool + + +@pytest.mark.asyncio +async def test_my_tool_max_iterations_syncs_subagent_limit() -> None: + loop = MagicMock() + loop.max_iterations = 40 + loop._runtime_vars = {} + loop.subagents = MagicMock() + loop.subagents.max_iterations = loop.max_iterations + + def _sync_subagent_runtime_limits() -> None: + loop.subagents.max_iterations = loop.max_iterations + + loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits + + tool = MyTool(loop=loop) + + result = await tool.execute(action="set", key="max_iterations", value=80) + + assert "Set max_iterations = 80" in result + assert loop.max_iterations == 80 + assert loop.subagents.max_iterations == 80 diff --git a/tests/agent/tools/test_subagent_tools.py b/tests/agent/tools/test_subagent_tools.py index bfe845395..a050a4271 100644 --- a/tests/agent/tools/test_subagent_tools.py +++ b/tests/agent/tools/test_subagent_tools.py @@ -54,11 +54,129 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path): mgr.runner.run.assert_awaited_once() +@pytest.mark.asyncio +async def test_subagent_uses_configured_max_iterations(tmp_path): + """Subagents should honor the configured tool-iteration limit.""" + from nanobot.agent.subagent import SubagentManager, SubagentStatus + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + max_iterations=37, + ) + mgr._announce_result = AsyncMock() + + async def fake_run(spec): + assert spec.max_iterations == 37 + return SimpleNamespace( + stop_reason="done", + final_content="done", + error=None, + tool_events=[], + ) + + mgr.runner.run = AsyncMock(side_effect=fake_run) + + status = SubagentStatus( + task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic() + ) + await mgr._run_subagent( + "sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status + ) + + mgr.runner.run.assert_awaited_once() + + +def test_subagent_default_max_iterations_matches_agent_defaults(tmp_path): + """Direct SubagentManager construction should use the agent default limit.""" + from nanobot.agent.subagent import SubagentManager + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) + + assert mgr.max_iterations == AgentDefaults().max_tool_iterations + + +def test_agent_loop_passes_max_iterations_to_subagents(tmp_path): + """AgentLoop's configured limit should be shared with spawned subagents.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + loop = AgentLoop( + bus=bus, + provider=provider, + workspace=tmp_path, + model="test-model", + max_iterations=42, + ) + + assert loop.subagents.max_iterations == 42 + + +@pytest.mark.asyncio +async def test_agent_loop_syncs_updated_max_iterations_before_run(tmp_path): + """Runtime max_iterations changes should be reflected before tool execution.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + loop = AgentLoop( + bus=bus, + provider=provider, + workspace=tmp_path, + model="test-model", + max_iterations=42, + ) + loop.tools.get_definitions = MagicMock(return_value=[]) + + async def fake_run(spec): + assert spec.max_iterations == 55 + assert loop.subagents.max_iterations == 55 + return SimpleNamespace( + stop_reason="done", + final_content="done", + error=None, + tool_events=[], + messages=[], + usage={}, + had_injections=False, + tools_used=[], + ) + + loop.runner.run = AsyncMock(side_effect=fake_run) + loop.max_iterations = 55 + + await loop._run_agent_loop([]) + + loop.runner.run.assert_awaited_once() + + @pytest.mark.asyncio async def test_drain_pending_blocks_while_subagents_running(tmp_path): """_drain_pending should block when no messages are available but sub-agents are still running.""" from nanobot.agent.loop import AgentLoop - from nanobot.agent.subagent import SubagentManager from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.session.manager import Session @@ -74,8 +192,6 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path): injection_callback = None # Capture the injection_callback that _run_agent_loop creates - original_run = loop.runner.run - async def fake_runner_run(spec): nonlocal injection_callback injection_callback = spec.injection_callback diff --git a/tests/channels/test_channel_manager_delta_coalescing.py b/tests/channels/test_channel_manager_delta_coalescing.py index 3c150f903..adec72e75 100644 --- a/tests/channels/test_channel_manager_delta_coalescing.py +++ b/tests/channels/test_channel_manager_delta_coalescing.py @@ -1,6 +1,6 @@ """Tests for ChannelManager delta coalescing to reduce streaming latency.""" import asyncio -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest @@ -298,6 +298,101 @@ class TestDispatchOutboundWithCoalescing: assert pending[0].content == "Final" +class TestProgressFiltering: + """Progress filtering should honor per-channel settings.""" + + def test_progress_visibility_uses_global_defaults(self, manager): + assert manager._should_send_progress("mock", tool_hint=False) is True + assert manager._should_send_progress("mock", tool_hint=True) is False + + def test_progress_visibility_uses_channel_overrides(self, manager): + manager.channels["mock"].send_progress = False + manager.channels["mock"].send_tool_hints = True + + assert manager._should_send_progress("mock", tool_hint=False) is False + assert manager._should_send_progress("mock", tool_hint=True) is True + + def test_progress_visibility_returns_false_for_missing_channel(self, manager): + assert manager._should_send_progress("nonexistent", tool_hint=False) is False + assert manager._should_send_progress("nonexistent", tool_hint=True) is False + + def test_resolve_bool_override_dict(self, manager): + assert manager._resolve_bool_override({}, "send_progress", True) is True + assert manager._resolve_bool_override({"send_progress": False}, "send_progress", True) is False + assert manager._resolve_bool_override({"sendProgress": False}, "send_progress", True) is False + assert manager._resolve_bool_override({"send_progress": "false"}, "send_progress", True) is True + + def test_resolve_bool_override_model(self, manager): + class FakeSection: + send_progress = False + send_tool_hints = True + + assert manager._resolve_bool_override(FakeSection(), "send_progress", True) is False + assert manager._resolve_bool_override(FakeSection(), "send_tool_hints", False) is True + # Missing attribute falls back to default + assert manager._resolve_bool_override(FakeSection(), "unknown_key", True) is True + + @pytest.mark.asyncio + async def test_channel_override_can_drop_progress_message(self, manager, bus): + manager.channels["mock"].send_progress = False + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="thinking", + metadata={"_progress": True}, + )) + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="final answer", + metadata={}, + )) + + task = asyncio.create_task(manager._dispatch_outbound()) + try: + for _ in range(30): + if manager.channels["mock"]._send_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + send_mock = manager.channels["mock"]._send_mock + assert send_mock.await_count == 1 + assert send_mock.await_args_list[0].args[0].content == "final answer" + + @pytest.mark.asyncio + async def test_channel_override_can_enable_tool_hints(self, manager, bus): + manager.channels["mock"].send_tool_hints = True + await bus.publish_outbound(OutboundMessage( + channel="mock", + chat_id="chat1", + content="read_file(foo.py)", + metadata={"_progress": True, "_tool_hint": True}, + )) + + task = asyncio.create_task(manager._dispatch_outbound()) + try: + for _ in range(30): + if manager.channels["mock"]._send_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + send_mock = manager.channels["mock"]._send_mock + assert send_mock.await_count == 1 + assert send_mock.await_args_list[0].args[0].content == "read_file(foo.py)" + + class TestRetryWaitFiltering: """Internal provider retry heartbeats must never reach channels.""" diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index f7dc39e5d..430e5abea 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -580,6 +580,32 @@ async def test_reply_without_reply_in_thread_when_disabled() -> None: channel._client.im.v1.message.create.assert_called_once() +@pytest.mark.asyncio +async def test_topic_reply_does_not_force_reply_in_thread_when_disabled() -> None: + """Topic replies must not create new Feishu topics when reply_to_message is False.""" + channel = _make_feishu_channel(reply_to_message=False) + + reply_resp = MagicMock() + reply_resp.success.return_value = True + channel._client.im.v1.message.reply.return_value = reply_resp + + await channel.send(OutboundMessage( + channel="feishu", + chat_id="oc_abc", + content="hello", + metadata={ + "message_id": "om_child456", + "chat_type": "group", + "thread_id": "om_root123", + }, + )) + + channel._client.im.v1.message.reply.assert_called_once() + call_args = channel._client.im.v1.message.reply.call_args + request = call_args[0][0] + assert request.request_body.reply_in_thread is not True + + @pytest.mark.asyncio async def test_reply_keeps_fallback_when_reply_fails() -> None: """Even with reply_to_message=True, fallback to create on reply failure.""" diff --git a/tests/channels/test_feishu_streaming.py b/tests/channels/test_feishu_streaming.py index 4bef83548..68232cb42 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/tests/channels/test_feishu_streaming.py @@ -10,13 +10,14 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf -def _make_channel(streaming: bool = True) -> FeishuChannel: +def _make_channel(streaming: bool = True, reply_to_message: bool = False) -> FeishuChannel: config = FeishuConfig( enabled=True, app_id="cli_test", app_secret="secret", allow_from=["*"], streaming=streaming, + reply_to_message=reply_to_message, ) ch = FeishuChannel(config, MessageBus()) ch._client = MagicMock() @@ -148,6 +149,62 @@ class TestSendDelta: ch._client.im.v1.message.create.assert_called_once() ch._client.cardkit.v1.card_element.content.assert_called_once() + @pytest.mark.asyncio + async def test_group_delta_uses_create_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new") + ch._client.im.v1.message.create.return_value = _mock_send_response("om_new") + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + + await ch.send_delta( + "oc_chat1", + "Hello ", + metadata={"message_id": "om_001", "chat_type": "group"}, + ) + + ch._client.im.v1.message.create.assert_called_once() + ch._client.im.v1.message.reply.assert_not_called() + + @pytest.mark.asyncio + async def test_group_delta_keeps_existing_topic_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new") + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + + await ch.send_delta( + "oc_chat1", + "Hello ", + metadata={"message_id": "om_001", "chat_type": "group", "thread_id": "ot_001"}, + ) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is not True + + @pytest.mark.asyncio + async def test_group_delta_replies_in_thread_when_reply_enabled(self): + ch = _make_channel(reply_to_message=True) + ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new") + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + + await ch.send_delta( + "oc_chat1", + "Hello ", + metadata={"message_id": "om_001", "chat_type": "group"}, + ) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is True + @pytest.mark.asyncio async def test_second_delta_within_interval_skips_update(self): ch = _make_channel() @@ -204,6 +261,70 @@ class TestSendDelta: ch._client.cardkit.v1.card_element.content.assert_not_called() ch._client.im.v1.message.create.assert_called_once() + @pytest.mark.asyncio + async def test_stream_end_fallback_group_uses_create_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._stream_bufs["om_001"] = _FeishuStreamBuf( + text="Fallback content", card_id=None, sequence=0, last_edit=0.0, + ) + ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb") + + await ch.send_delta( + "oc_chat1", + "", + metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"}, + ) + + ch._client.im.v1.message.create.assert_called_once() + ch._client.im.v1.message.reply.assert_not_called() + + @pytest.mark.asyncio + async def test_stream_end_fallback_keeps_existing_topic_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._stream_bufs["om_001"] = _FeishuStreamBuf( + text="Fallback content", card_id=None, sequence=0, last_edit=0.0, + ) + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + + await ch.send_delta( + "oc_chat1", + "", + metadata={ + "_stream_end": True, + "message_id": "om_001", + "chat_type": "group", + "thread_id": "ot_001", + }, + ) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is not True + + @pytest.mark.asyncio + async def test_stream_end_fallback_group_replies_when_reply_enabled(self): + ch = _make_channel(reply_to_message=True) + ch._stream_bufs["om_001"] = _FeishuStreamBuf( + text="Fallback content", card_id=None, sequence=0, last_edit=0.0, + ) + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + + await ch.send_delta( + "oc_chat1", + "", + metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"}, + ) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is True + @pytest.mark.asyncio async def test_stream_end_fallback_when_final_update_fails(self): """If streaming mode was closed (e.g. Feishu timeout), fall back to a regular card.""" @@ -316,6 +437,64 @@ class TestToolHintInlineStreaming: assert "oc_chat1" not in ch._stream_bufs ch._client.im.v1.message.create.assert_called_once() + @pytest.mark.asyncio + async def test_tool_hint_group_uses_create_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + ch._client.im.v1.message.create.return_value = _mock_send_response("om_hint") + + msg = OutboundMessage( + channel="feishu", chat_id="oc_chat1", + content='read_file("path")', + metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"}, + ) + await ch.send(msg) + + ch._client.im.v1.message.create.assert_called_once() + ch._client.im.v1.message.reply.assert_not_called() + + @pytest.mark.asyncio + async def test_tool_hint_keeps_existing_topic_when_reply_disabled(self): + ch = _make_channel(reply_to_message=False) + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + + msg = OutboundMessage( + channel="feishu", chat_id="oc_chat1", + content='read_file("path")', + metadata={ + "_tool_hint": True, + "message_id": "om_001", + "chat_type": "group", + "thread_id": "ot_001", + }, + ) + await ch.send(msg) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is not True + + @pytest.mark.asyncio + async def test_tool_hint_group_replies_when_reply_enabled(self): + ch = _make_channel(reply_to_message=True) + reply_resp = MagicMock() + reply_resp.success.return_value = True + ch._client.im.v1.message.reply.return_value = reply_resp + + msg = OutboundMessage( + channel="feishu", chat_id="oc_chat1", + content='read_file("path")', + metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"}, + ) + await ch.send(msg) + + ch._client.im.v1.message.reply.assert_called_once() + ch._client.im.v1.message.create.assert_not_called() + request = ch._client.im.v1.message.reply.call_args[0][0] + assert request.request_body.reply_in_thread is True + @pytest.mark.asyncio async def test_consecutive_tool_hints_append(self): """When multiple tool hints arrive consecutively, each appends to the card.""" diff --git a/tests/providers/test_anthropic_thinking.py b/tests/providers/test_anthropic_thinking.py index d0f72b32c..9fb22e2e5 100644 --- a/tests/providers/test_anthropic_thinking.py +++ b/tests/providers/test_anthropic_thinking.py @@ -83,3 +83,10 @@ def test_opus_4_7_omits_temperature_none() -> None: kw = _build(_make_provider("claude-opus-4-7"), None) assert "temperature" not in kw assert "thinking" not in kw + + +def test_reasoning_effort_string_none_does_not_enable_thinking() -> None: + """reasoning_effort='none' must not enable thinking โ€” treated same as disabled.""" + kw = _build(_make_provider(), "none") + assert "thinking" not in kw + assert kw["temperature"] == 0.7 diff --git a/tests/providers/test_azure_openai_provider.py b/tests/providers/test_azure_openai_provider.py index 89cea64f0..7ce74ee9f 100644 --- a/tests/providers/test_azure_openai_provider.py +++ b/tests/providers/test_azure_openai_provider.py @@ -78,6 +78,11 @@ def test_supports_temperature_with_reasoning_effort(): assert AzureOpenAIProvider._supports_temperature("gpt-4o", reasoning_effort="medium") is False +def test_supports_temperature_with_reasoning_effort_none_string(): + """reasoning_effort='none' must NOT suppress temperature โ€” it means thinking is off.""" + assert AzureOpenAIProvider._supports_temperature("gpt-4o", reasoning_effort="none") is True + + # --------------------------------------------------------------------------- # _build_body โ€” Responses API body construction # --------------------------------------------------------------------------- @@ -131,6 +136,16 @@ def test_build_body_with_reasoning(): assert "temperature" not in body +def test_build_body_reasoning_effort_none_string_omits_reasoning(): + """reasoning_effort='none' must not inject a reasoning body and must allow temperature.""" + provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") + body = provider._build_body( + [{"role": "user", "content": "hi"}], None, "gpt-4o", 4096, 0.7, "none", None, + ) + assert "reasoning" not in body + assert body["temperature"] == 0.7 + + def test_build_body_image_conversion(): """image_url content blocks should be converted to input_image.""" provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o") diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index e91d1caef..d3b03c60d 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -121,6 +121,14 @@ def test_openrouter_spec_is_gateway() -> None: assert spec.default_api_base == "https://openrouter.ai/api/v1" +def test_gemma_routes_to_gemini_provider() -> None: + """gemma models (e.g. gemma-3-27b-it) must auto-route to Gemini when GEMINI_API_KEY is set. + Users running gemma via the Gemini API endpoint expect automatic provider detection.""" + spec = find_by_name("gemini") + assert spec is not None + assert "gemma" in spec.keywords + + def test_openrouter_sets_default_attribution_headers() -> None: spec = find_by_name("openrouter") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: @@ -1050,3 +1058,46 @@ def test_kimi_k2_thinking_series_no_thinking_injection() -> None: """kimi-k2-thinking series models must NOT receive extra_body.thinking.""" kw = _build_kwargs_for("moonshot", "kimi-k2-thinking", reasoning_effort="high") assert "extra_body" not in kw + + +# --------------------------------------------------------------------------- +# reasoning_effort="none" โ€” treated as thinking disabled +# --------------------------------------------------------------------------- + +def test_deepseek_thinking_disabled_for_none_string() -> None: + """reasoning_effort='none' must send thinking.type=disabled and skip reasoning_effort field.""" + kw = _build_kwargs_for("deepseek", "deepseek-v4-pro", reasoning_effort="none") + assert kw.get("extra_body") == {"thinking": {"type": "disabled"}} + assert "reasoning_effort" not in kw + + +def test_kimi_k25_thinking_disabled_for_none_string() -> None: + """reasoning_effort='none' maps to thinking disabled for kimi-k2.5.""" + kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="none") + assert kw.get("extra_body") == {"thinking": {"type": "disabled"}} + + +def test_dashscope_thinking_disabled_for_none_string() -> None: + """reasoning_effort='none' disables thinking and must not emit reasoning_effort on DashScope.""" + kw = _build_kwargs_for("dashscope", "qwen3.6-plus", reasoning_effort="none") + assert kw.get("extra_body") == {"enable_thinking": False} + assert "reasoning_effort" not in kw + + +def test_deepseek_no_backfill_when_reasoning_effort_none_string() -> None: + """reasoning_effort='none' must NOT trigger reasoning_content backfill (thinking inactive).""" + spec = find_by_name("deepseek") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "continue"}, + ] + kw = p._build_kwargs( + messages=list(messages), tools=None, model="deepseek-v4-pro", + max_tokens=1024, temperature=0.7, + reasoning_effort="none", tool_choice=None, + ) + assistant = kw["messages"][1] + assert "reasoning_content" not in assistant