mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 08:32:25 +00:00
Merge remote-tracking branch 'origin/main' into nightly
This commit is contained in:
commit
7988ce5b74
@ -23,6 +23,14 @@
|
|||||||
|
|
||||||
## 📢 News
|
## 📢 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-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-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
|
||||||
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { Boom } from '@hapi/boom';
|
|||||||
import qrcode from 'qrcode-terminal';
|
import qrcode from 'qrcode-terminal';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||||
import { join, basename } from 'path';
|
import { join, basename, resolve, sep } from 'path';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
const VERSION = '0.1.0';
|
const VERSION = '0.1.0';
|
||||||
@ -196,17 +196,18 @@ export class WhatsAppClient {
|
|||||||
|
|
||||||
let outFilename: string;
|
let outFilename: string;
|
||||||
if (fileName) {
|
if (fileName) {
|
||||||
// Documents have a filename — use it with a unique prefix to avoid collisions
|
const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`;
|
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`;
|
||||||
outFilename = prefix + fileName;
|
|
||||||
} else {
|
} else {
|
||||||
const mime = mimetype || 'application/octet-stream';
|
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');
|
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
|
||||||
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
|
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);
|
await writeFile(filepath, buffer);
|
||||||
|
|
||||||
return filepath;
|
return filepath;
|
||||||
|
|||||||
@ -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. |
|
| `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"`. |
|
| `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 Behavior
|
||||||
|
|
||||||
Retry is intentionally simple.
|
Retry is intentionally simple.
|
||||||
|
|||||||
@ -21,7 +21,7 @@ def _resolve_version() -> str:
|
|||||||
return _pkg_version("nanobot-ai")
|
return _pkg_version("nanobot-ai")
|
||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
# Source checkouts often import nanobot without installed dist-info.
|
# 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()
|
__version__ = _resolve_version()
|
||||||
|
|||||||
@ -258,6 +258,7 @@ class AgentLoop:
|
|||||||
exec_config=self.exec_config,
|
exec_config=self.exec_config,
|
||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
|
max_iterations=self.max_iterations,
|
||||||
)
|
)
|
||||||
self._unified_session = unified_session
|
self._unified_session = unified_session
|
||||||
self._max_messages = max_messages if max_messages > 0 else 120
|
self._max_messages = max_messages if max_messages > 0 else 120
|
||||||
@ -307,6 +308,10 @@ class AgentLoop:
|
|||||||
self.commands = CommandRouter()
|
self.commands = CommandRouter()
|
||||||
register_builtin_commands(self.commands)
|
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:
|
def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None:
|
||||||
"""Swap model/provider for future turns without disturbing an active one."""
|
"""Swap model/provider for future turns without disturbing an active one."""
|
||||||
provider = snapshot.provider
|
provider = snapshot.provider
|
||||||
@ -531,6 +536,8 @@ class AgentLoop:
|
|||||||
|
|
||||||
Returns (final_content, tools_used, messages, stop_reason, had_injections).
|
Returns (final_content, tools_used, messages, stop_reason, had_injections).
|
||||||
"""
|
"""
|
||||||
|
self._sync_subagent_runtime_limits()
|
||||||
|
|
||||||
loop_hook = _LoopHook(
|
loop_hook = _LoopHook(
|
||||||
self,
|
self,
|
||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import weakref
|
import weakref
|
||||||
import tiktoken
|
import tiktoken
|
||||||
@ -359,10 +360,31 @@ class MemoryStore:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _write_entries(self, entries: list[dict[str, Any]]) -> None:
|
def _write_entries(self, entries: list[dict[str, Any]]) -> None:
|
||||||
"""Overwrite history.jsonl with the given entries."""
|
"""Overwrite history.jsonl with the given entries (atomic write)."""
|
||||||
with open(self.history_file, "w", encoding="utf-8") as f:
|
tmp_path = self.history_file.with_suffix(self.history_file.suffix + ".tmp")
|
||||||
for entry in entries:
|
try:
|
||||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
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 --------------------------------------------------------
|
# -- dream cursor --------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ from nanobot.agent.tools.shell import ExecTool
|
|||||||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
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.providers.base import LLMProvider
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
@ -81,6 +81,7 @@ class SubagentManager:
|
|||||||
exec_config: "ExecToolConfig | None" = None,
|
exec_config: "ExecToolConfig | None" = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
disabled_skills: list[str] | None = None,
|
disabled_skills: list[str] | None = None,
|
||||||
|
max_iterations: int | None = None,
|
||||||
):
|
):
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@ -91,6 +92,11 @@ class SubagentManager:
|
|||||||
self.exec_config = exec_config or ExecToolConfig()
|
self.exec_config = exec_config or ExecToolConfig()
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self.disabled_skills = set(disabled_skills or [])
|
self.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.runner = AgentRunner(provider)
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
@ -202,7 +208,7 @@ class SubagentManager:
|
|||||||
initial_messages=messages,
|
initial_messages=messages,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
max_iterations=15,
|
max_iterations=self.max_iterations,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
hook=_SubagentHook(task_id, status),
|
hook=_SubagentHook(task_id, status),
|
||||||
max_iterations_message="Task completed but no final response was generated.",
|
max_iterations_message="Task completed but no final response was generated.",
|
||||||
|
|||||||
@ -394,6 +394,8 @@ class MyTool(Tool):
|
|||||||
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
||||||
return f"Error: '{key}' must be at least {spec['min_len']} characters"
|
return f"Error: '{key}' must be at least {spec['min_len']} characters"
|
||||||
setattr(self._loop, key, value)
|
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}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,8 @@ class BaseChannel(ABC):
|
|||||||
transcription_api_key: str = ""
|
transcription_api_key: str = ""
|
||||||
transcription_api_base: str = ""
|
transcription_api_base: str = ""
|
||||||
transcription_language: str | None = None
|
transcription_language: str | None = None
|
||||||
|
send_progress: bool = True
|
||||||
|
send_tool_hints: bool = False
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1170,6 +1170,21 @@ class FeishuChannel(BaseChannel):
|
|||||||
logger.error("Error replying to Feishu message {}: {}", parent_message_id, e)
|
logger.error("Error replying to Feishu message {}: {}", parent_message_id, e)
|
||||||
return False
|
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(
|
def _send_message_sync(
|
||||||
self, receive_id_type: str, receive_id: str, msg_type: str, content: str
|
self, receive_id_type: str, receive_id: str, msg_type: str, content: str
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@ -1211,13 +1226,15 @@ class FeishuChannel(BaseChannel):
|
|||||||
receive_id_type: str,
|
receive_id_type: str,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
reply_message_id: str | None = None,
|
reply_message_id: str | None = None,
|
||||||
|
*,
|
||||||
|
reply_in_thread: bool = False,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Create a CardKit streaming card, send it to chat, return card_id.
|
"""Create a CardKit streaming card, send it to chat, return card_id.
|
||||||
|
|
||||||
When *reply_message_id* is provided the card is delivered via the
|
When *reply_message_id* is provided the card is delivered via the
|
||||||
reply API (with reply_in_thread=True) so it lands inside the
|
reply API. *reply_in_thread* controls whether Feishu creates a
|
||||||
originating thread / topic. Otherwise the plain create-message
|
thread/topic for that reply. Otherwise the plain create-message API is
|
||||||
API is used.
|
used.
|
||||||
"""
|
"""
|
||||||
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
||||||
|
|
||||||
@ -1253,7 +1270,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
if reply_message_id:
|
if reply_message_id:
|
||||||
sent = self._reply_message_sync(
|
sent = self._reply_message_sync(
|
||||||
reply_message_id, "interactive", card_content,
|
reply_message_id, "interactive", card_content,
|
||||||
reply_in_thread=True,
|
reply_in_thread=reply_in_thread,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sent = self._send_message_sync(
|
sent = self._send_message_sync(
|
||||||
@ -1409,16 +1426,14 @@ class FeishuChannel(BaseChannel):
|
|||||||
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
# Fallback: reply via the Reply API for group chats.
|
# Fallback replies stay in existing topics, but only create a
|
||||||
# Target message_id — the Feishu API keeps the reply in
|
# new topic when reply-to-message is enabled.
|
||||||
# the same topic automatically.
|
fallback_msg_id = self._thread_reply_target(meta)
|
||||||
_f_msg = meta.get("message_id")
|
|
||||||
fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None
|
|
||||||
if fallback_msg_id:
|
if fallback_msg_id:
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None, lambda: self._reply_message_sync(
|
None, lambda: self._reply_message_sync(
|
||||||
fallback_msg_id, "interactive", card,
|
fallback_msg_id, "interactive", card,
|
||||||
reply_in_thread=True,
|
reply_in_thread=self._should_use_reply_in_thread(meta),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -1438,16 +1453,18 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if buf.card_id is None:
|
if buf.card_id is None:
|
||||||
# Send the streaming card as a reply for group chats so it
|
# Use the Reply API for existing topics, and only create new topics
|
||||||
# lands inside the originating topic/thread. Always target
|
# when reply-to-message is enabled.
|
||||||
# message_id (the actual inbound message) — the Feishu Reply
|
use_reply_in_thread = self._should_use_reply_in_thread(meta)
|
||||||
# API keeps the response in the same topic automatically.
|
reply_msg_id = self._thread_reply_target(meta)
|
||||||
is_group = meta.get("chat_type", "group") == "group"
|
|
||||||
reply_msg_id = meta.get("message_id") if is_group else None
|
|
||||||
card_id = await loop.run_in_executor(
|
card_id = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
self._create_streaming_card_sync,
|
lambda: self._create_streaming_card_sync(
|
||||||
rid_type, chat_id, reply_msg_id,
|
rid_type,
|
||||||
|
chat_id,
|
||||||
|
reply_msg_id,
|
||||||
|
reply_in_thread=use_reply_in_thread,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if card_id:
|
if card_id:
|
||||||
buf.card_id = card_id
|
buf.card_id = card_id
|
||||||
@ -1489,22 +1506,21 @@ class FeishuChannel(BaseChannel):
|
|||||||
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
# No active streaming card — send as a regular
|
# No active streaming card — send as a regular interactive card
|
||||||
# interactive card with the same 🔧 prefix style.
|
# with the same 🔧 prefix style. Existing topics stay threaded;
|
||||||
# Use reply API for group chats so the hint stays in topic.
|
# new topics are created only when reply-to-message is enabled.
|
||||||
card = json.dumps(
|
card = json.dumps(
|
||||||
{"config": {"wide_screen_mode": True}, "elements": [
|
{"config": {"wide_screen_mode": True}, "elements": [
|
||||||
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
||||||
]},
|
]},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
_th_msg_id = msg.metadata.get("message_id")
|
_th_msg_id = self._thread_reply_target(msg.metadata)
|
||||||
_th_chat_type = msg.metadata.get("chat_type", "group")
|
if _th_msg_id:
|
||||||
if _th_msg_id and _th_chat_type == "group":
|
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None, lambda: self._reply_message_sync(
|
None, lambda: self._reply_message_sync(
|
||||||
_th_msg_id, "interactive", card,
|
_th_msg_id, "interactive", card,
|
||||||
reply_in_thread=True,
|
reply_in_thread=self._should_use_reply_in_thread(msg.metadata),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -1531,18 +1547,16 @@ class FeishuChannel(BaseChannel):
|
|||||||
def _do_send(m_type: str, content: str) -> None:
|
def _do_send(m_type: str, content: str) -> None:
|
||||||
"""Send via reply (first message) or create (subsequent).
|
"""Send via reply (first message) or create (subsequent).
|
||||||
|
|
||||||
For group chats the reply API always uses reply_in_thread=True.
|
Group chats only set reply_in_thread=True when
|
||||||
The Feishu API automatically keeps replies inside existing
|
reply_to_message is enabled; otherwise a Reply API call for an
|
||||||
topics — reply_in_thread only creates a *new* topic when the
|
existing topic must not create a new topic.
|
||||||
target message is a plain (non-topic) message.
|
|
||||||
"""
|
"""
|
||||||
nonlocal first_send
|
nonlocal first_send
|
||||||
if reply_message_id and first_send:
|
if reply_message_id and first_send:
|
||||||
first_send = False
|
first_send = False
|
||||||
chat_type = msg.metadata.get("chat_type", "group")
|
|
||||||
ok = self._reply_message_sync(
|
ok = self._reply_message_sync(
|
||||||
reply_message_id, m_type, content,
|
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:
|
if ok:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -27,9 +27,15 @@ def _default_webui_dist() -> Path | None:
|
|||||||
candidate = Path(web_pkg.__file__).resolve().parent / "dist"
|
candidate = Path(web_pkg.__file__).resolve().parent / "dist"
|
||||||
return candidate if candidate.is_dir() else None
|
return candidate if candidate.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
# Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
|
# Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
|
||||||
_SEND_RETRY_DELAYS = (1, 2, 4)
|
_SEND_RETRY_DELAYS = (1, 2, 4)
|
||||||
|
|
||||||
|
_BOOL_CAMEL_ALIASES: dict[str, str] = {
|
||||||
|
"send_progress": "sendProgress",
|
||||||
|
"send_tool_hints": "sendToolHints",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ChannelManager:
|
class ChannelManager:
|
||||||
"""
|
"""
|
||||||
@ -90,6 +96,12 @@ class ChannelManager:
|
|||||||
channel.transcription_api_key = transcription_key
|
channel.transcription_api_key = transcription_key
|
||||||
channel.transcription_api_base = transcription_base
|
channel.transcription_api_base = transcription_base
|
||||||
channel.transcription_language = transcription_language
|
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
|
self.channels[name] = channel
|
||||||
logger.info("{} channel enabled", cls.display_name)
|
logger.info("{} channel enabled", cls.display_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -131,6 +143,31 @@ class ChannelManager:
|
|||||||
f'Set ["*"] to allow everyone, or add specific user IDs.'
|
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:
|
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
|
||||||
"""Start a channel and log any exceptions."""
|
"""Start a channel and log any exceptions."""
|
||||||
try:
|
try:
|
||||||
@ -216,9 +253,13 @@ class ChannelManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if msg.metadata.get("_progress"):
|
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
|
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
|
continue
|
||||||
|
|
||||||
if msg.metadata.get("_retry_wait"):
|
if msg.metadata.get("_retry_wait"):
|
||||||
|
|||||||
@ -262,10 +262,18 @@ class MatrixChannel(BaseChannel):
|
|||||||
self.store_path.mkdir(parents=True, exist_ok=True)
|
self.store_path.mkdir(parents=True, exist_ok=True)
|
||||||
self.session_path = self.store_path / "session.json"
|
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(
|
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,
|
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()
|
self._register_event_callbacks()
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""WhatsApp channel implementation using Node.js bridge."""
|
"""WhatsApp channel implementation using Node.js bridge."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@ -316,13 +317,7 @@ def _ensure_bridge_setup() -> Path:
|
|||||||
from nanobot.config.paths import get_bridge_install_dir
|
from nanobot.config.paths import get_bridge_install_dir
|
||||||
|
|
||||||
user_bridge = get_bridge_install_dir()
|
user_bridge = get_bridge_install_dir()
|
||||||
|
stamp_file = user_bridge / ".nanobot-bridge-source-hash"
|
||||||
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.")
|
|
||||||
|
|
||||||
# Find source bridge
|
# Find source bridge
|
||||||
current_file = Path(__file__)
|
current_file = Path(__file__)
|
||||||
@ -341,6 +336,33 @@ def _ensure_bridge_setup() -> Path:
|
|||||||
"Try reinstalling: pip install --force-reinstall nanobot"
|
"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...")
|
logger.info("Setting up WhatsApp bridge...")
|
||||||
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if user_bridge.exists():
|
if user_bridge.exists():
|
||||||
@ -352,6 +374,7 @@ def _ensure_bridge_setup() -> Path:
|
|||||||
|
|
||||||
logger.info(" Building...")
|
logger.info(" Building...")
|
||||||
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
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")
|
logger.info("Bridge ready")
|
||||||
return user_bridge
|
return user_bridge
|
||||||
|
|||||||
@ -1271,6 +1271,7 @@ def channels_status(
|
|||||||
|
|
||||||
def _get_bridge_dir() -> Path:
|
def _get_bridge_dir() -> Path:
|
||||||
"""Get the bridge directory, setting it up if needed."""
|
"""Get the bridge directory, setting it up if needed."""
|
||||||
|
import hashlib
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@ -1278,16 +1279,7 @@ def _get_bridge_dir() -> Path:
|
|||||||
from nanobot.config.paths import get_bridge_install_dir
|
from nanobot.config.paths import get_bridge_install_dir
|
||||||
|
|
||||||
user_bridge = get_bridge_install_dir()
|
user_bridge = get_bridge_install_dir()
|
||||||
|
stamp_file = user_bridge / ".nanobot-bridge-source-hash"
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Find source bridge: first check package data, then source dir
|
# Find source bridge: first check package data, then source dir
|
||||||
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
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")
|
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
||||||
raise typer.Exit(1)
|
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...")
|
console.print(f"{__logo__} Setting up bridge...")
|
||||||
|
|
||||||
# Copy to user directory
|
# Copy to user directory
|
||||||
@ -1319,6 +1341,7 @@ def _get_bridge_dir() -> Path:
|
|||||||
|
|
||||||
console.print(" Building...")
|
console.print(" Building...")
|
||||||
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
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")
|
console.print("[green]✓[/green] Bridge ready\n")
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
|||||||
@ -434,7 +434,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
max_tokens = max(1, max_tokens)
|
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
|
# claude-opus-4-7 deprecated the `temperature` parameter entirely — the
|
||||||
# API returns 400 if it is present, on any code path.
|
# API returns 400 if it is present, on any code path.
|
||||||
|
|||||||
@ -71,7 +71,7 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True when temperature is likely supported for this deployment."""
|
"""Return True when temperature is likely supported for this deployment."""
|
||||||
if reasoning_effort:
|
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||||
return False
|
return False
|
||||||
name = deployment_name.lower()
|
name = deployment_name.lower()
|
||||||
return not any(token in name for token in ("gpt-5", "o1", "o3", "o4"))
|
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):
|
if self._supports_temperature(deployment, reasoning_effort):
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
|
|
||||||
if reasoning_effort:
|
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||||
body["reasoning"] = {"effort": reasoning_effort}
|
body["reasoning"] = {"effort": reasoning_effort}
|
||||||
body["include"] = ["reasoning.encrypted_content"]
|
body["include"] = ["reasoning.encrypted_content"]
|
||||||
|
|
||||||
|
|||||||
@ -60,7 +60,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
"tool_choice": tool_choice or "auto",
|
"tool_choice": tool_choice or "auto",
|
||||||
"parallel_tool_calls": True,
|
"parallel_tool_calls": True,
|
||||||
}
|
}
|
||||||
if reasoning_effort:
|
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||||
body["reasoning"] = {"effort": reasoning_effort}
|
body["reasoning"] = {"effort": reasoning_effort}
|
||||||
if tools:
|
if tools:
|
||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
|
|||||||
@ -570,7 +570,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
|
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
|
||||||
wire_effort = "minimum"
|
wire_effort = "minimum"
|
||||||
|
|
||||||
if wire_effort:
|
if wire_effort and semantic_effort != "none":
|
||||||
kwargs["reasoning_effort"] = wire_effort
|
kwargs["reasoning_effort"] = wire_effort
|
||||||
|
|
||||||
# Provider-specific thinking parameters.
|
# Provider-specific thinking parameters.
|
||||||
@ -579,7 +579,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# The mapping is driven by ProviderSpec.thinking_style so that adding
|
# The mapping is driven by ProviderSpec.thinking_style so that adding
|
||||||
# a new provider never requires touching this function.
|
# a new provider never requires touching this function.
|
||||||
if spec and spec.thinking_style and reasoning_effort is not None:
|
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)
|
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled)
|
||||||
if extra:
|
if extra:
|
||||||
kwargs.setdefault("extra_body", {}).update(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
|
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
||||||
# identically to bare names like "kimi-k2.5".
|
# identically to bare names like "kimi-k2.5".
|
||||||
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
|
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(
|
kwargs.setdefault("extra_body", {}).update(
|
||||||
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
||||||
)
|
)
|
||||||
@ -609,9 +609,9 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# thinking happened on that turn").
|
# thinking happened on that turn").
|
||||||
thinking_active = (
|
thinking_active = (
|
||||||
(spec and spec.thinking_style and reasoning_effort is not None
|
(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)
|
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:
|
if thinking_active:
|
||||||
for msg in kwargs["messages"]:
|
for msg in kwargs["messages"]:
|
||||||
|
|||||||
@ -267,7 +267,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
# Gemini: Google's OpenAI-compatible endpoint
|
# Gemini: Google's OpenAI-compatible endpoint
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
name="gemini",
|
name="gemini",
|
||||||
keywords=("gemini",),
|
keywords=("gemini", "gemma"),
|
||||||
env_key="GEMINI_API_KEY",
|
env_key="GEMINI_API_KEY",
|
||||||
display_name="Gemini",
|
display_name="Gemini",
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nanobot-ai"
|
name = "nanobot-ai"
|
||||||
version = "0.1.5.post2"
|
version = "0.1.5.post3"
|
||||||
description = "A lightweight personal AI assistant framework"
|
description = "A lightweight personal AI assistant framework"
|
||||||
readme = { file = "README.md", content-type = "text/markdown" }
|
readme = { file = "README.md", content-type = "text/markdown" }
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "nanobot contributors"}
|
{name = "Xubin Ren"},
|
||||||
|
{name = "the nanobot contributors"}
|
||||||
]
|
]
|
||||||
keywords = ["ai", "agent", "chatbot"]
|
keywords = ["ai", "agent", "chatbot"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
|||||||
@ -141,6 +141,49 @@ class TestHistoryWithCursor:
|
|||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
assert entries[0]["cursor"] in {4, 5}
|
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:
|
class TestAppendHistoryHardCap:
|
||||||
"""append_history has a defensive cap that catches new callers who forgot
|
"""append_history has a defensive cap that catches new callers who forgot
|
||||||
|
|||||||
29
tests/agent/tools/test_self_tool_runtime_sync.py
Normal file
29
tests/agent/tools/test_self_tool_runtime_sync.py
Normal 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
|
||||||
@ -54,11 +54,129 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
|
|||||||
mgr.runner.run.assert_awaited_once()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
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."""
|
"""_drain_pending should block when no messages are available but sub-agents are still running."""
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.subagent import SubagentManager
|
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
@ -74,8 +192,6 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
|||||||
injection_callback = None
|
injection_callback = None
|
||||||
|
|
||||||
# Capture the injection_callback that _run_agent_loop creates
|
# Capture the injection_callback that _run_agent_loop creates
|
||||||
original_run = loop.runner.run
|
|
||||||
|
|
||||||
async def fake_runner_run(spec):
|
async def fake_runner_run(spec):
|
||||||
nonlocal injection_callback
|
nonlocal injection_callback
|
||||||
injection_callback = spec.injection_callback
|
injection_callback = spec.injection_callback
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Tests for ChannelManager delta coalescing to reduce streaming latency."""
|
"""Tests for ChannelManager delta coalescing to reduce streaming latency."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -298,6 +298,101 @@ class TestDispatchOutboundWithCoalescing:
|
|||||||
assert pending[0].content == "Final"
|
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:
|
class TestRetryWaitFiltering:
|
||||||
"""Internal provider retry heartbeats must never reach channels."""
|
"""Internal provider retry heartbeats must never reach channels."""
|
||||||
|
|
||||||
|
|||||||
@ -580,6 +580,32 @@ async def test_reply_without_reply_in_thread_when_disabled() -> None:
|
|||||||
channel._client.im.v1.message.create.assert_called_once()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_reply_keeps_fallback_when_reply_fails() -> None:
|
async def test_reply_keeps_fallback_when_reply_fails() -> None:
|
||||||
"""Even with reply_to_message=True, fallback to create on reply failure."""
|
"""Even with reply_to_message=True, fallback to create on reply failure."""
|
||||||
|
|||||||
@ -10,13 +10,14 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
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(
|
config = FeishuConfig(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
app_id="cli_test",
|
app_id="cli_test",
|
||||||
app_secret="secret",
|
app_secret="secret",
|
||||||
allow_from=["*"],
|
allow_from=["*"],
|
||||||
streaming=streaming,
|
streaming=streaming,
|
||||||
|
reply_to_message=reply_to_message,
|
||||||
)
|
)
|
||||||
ch = FeishuChannel(config, MessageBus())
|
ch = FeishuChannel(config, MessageBus())
|
||||||
ch._client = MagicMock()
|
ch._client = MagicMock()
|
||||||
@ -148,6 +149,62 @@ class TestSendDelta:
|
|||||||
ch._client.im.v1.message.create.assert_called_once()
|
ch._client.im.v1.message.create.assert_called_once()
|
||||||
ch._client.cardkit.v1.card_element.content.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
|
@pytest.mark.asyncio
|
||||||
async def test_second_delta_within_interval_skips_update(self):
|
async def test_second_delta_within_interval_skips_update(self):
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
@ -204,6 +261,70 @@ class TestSendDelta:
|
|||||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||||
ch._client.im.v1.message.create.assert_called_once()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_stream_end_fallback_when_final_update_fails(self):
|
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."""
|
"""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
|
assert "oc_chat1" not in ch._stream_bufs
|
||||||
ch._client.im.v1.message.create.assert_called_once()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_consecutive_tool_hints_append(self):
|
async def test_consecutive_tool_hints_append(self):
|
||||||
"""When multiple tool hints arrive consecutively, each appends to the card."""
|
"""When multiple tool hints arrive consecutively, each appends to the card."""
|
||||||
|
|||||||
@ -83,3 +83,10 @@ def test_opus_4_7_omits_temperature_none() -> None:
|
|||||||
kw = _build(_make_provider("claude-opus-4-7"), None)
|
kw = _build(_make_provider("claude-opus-4-7"), None)
|
||||||
assert "temperature" not in kw
|
assert "temperature" not in kw
|
||||||
assert "thinking" 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
|
||||||
|
|||||||
@ -78,6 +78,11 @@ def test_supports_temperature_with_reasoning_effort():
|
|||||||
assert AzureOpenAIProvider._supports_temperature("gpt-4o", reasoning_effort="medium") is False
|
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
|
# _build_body — Responses API body construction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -131,6 +136,16 @@ def test_build_body_with_reasoning():
|
|||||||
assert "temperature" not in body
|
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():
|
def test_build_body_image_conversion():
|
||||||
"""image_url content blocks should be converted to input_image."""
|
"""image_url content blocks should be converted to input_image."""
|
||||||
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
|
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
|
||||||
|
|||||||
@ -121,6 +121,14 @@ def test_openrouter_spec_is_gateway() -> None:
|
|||||||
assert spec.default_api_base == "https://openrouter.ai/api/v1"
|
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:
|
def test_openrouter_sets_default_attribution_headers() -> None:
|
||||||
spec = find_by_name("openrouter")
|
spec = find_by_name("openrouter")
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
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."""
|
"""kimi-k2-thinking series models must NOT receive extra_body.thinking."""
|
||||||
kw = _build_kwargs_for("moonshot", "kimi-k2-thinking", reasoning_effort="high")
|
kw = _build_kwargs_for("moonshot", "kimi-k2-thinking", reasoning_effort="high")
|
||||||
assert "extra_body" not in kw
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user