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

This commit is contained in:
chengyongru 2026-04-30 15:11:22 +08:00
commit 7988ce5b74
29 changed files with 821 additions and 82 deletions

View File

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

View File

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

View File

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

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.post2"
return _read_pyproject_version() or "0.1.5.post3"
__version__ = _resolve_version()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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