Merge remote-tracking branch 'origin/main'
Some checks failed
Test Suite / test (3.11) (push) Failing after 1m0s
Test Suite / test (3.12) (push) Failing after 19s
Test Suite / test (3.13) (push) Failing after 18s

# Conflicts:
#	nanobot/agent/tools/shell.py
#	tests/agent/test_evaluator.py
#	tests/channels/test_feishu_tool_hint_code_block.py
#	tests/providers/test_litellm_kwargs.py
#	tests/tools/test_web_search_tool.py
This commit is contained in:
Hua
2026-03-24 16:38:50 +08:00
49 changed files with 263 additions and 34 deletions

View File

@@ -21,13 +21,14 @@ jobs:
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install system dependencies - name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential
- name: Install dependencies - name: Install all dependencies
run: | run: uv sync --all-extras
python -m pip install --upgrade pip
pip install .[dev]
- name: Run tests - name: Run tests
run: python -m pytest tests/ -v run: uv run pytest tests/

View File

@@ -3,11 +3,12 @@
import asyncio import asyncio
import os import os
import re import re
import subprocess import sys
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from loguru import logger
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
@@ -93,31 +94,32 @@ class ExecTool(Tool):
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
try: try:
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file: process = await asyncio.create_subprocess_shell(
process = subprocess.Popen( command,
command, stdout=asyncio.subprocess.PIPE,
stdout=stdout_file, stderr=asyncio.subprocess.PIPE,
stderr=stderr_file, cwd=cwd,
cwd=cwd, env=env,
env=env, )
shell=True,
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=effective_timeout,
) )
except asyncio.TimeoutError:
deadline = asyncio.get_running_loop().time() + effective_timeout process.kill()
while process.poll() is None: try:
if asyncio.get_running_loop().time() >= deadline: await asyncio.wait_for(process.wait(), timeout=5.0)
process.kill() except asyncio.TimeoutError:
pass
finally:
if sys.platform != "win32":
try: try:
process.wait(timeout=5.0) os.waitpid(process.pid, os.WNOHANG)
except subprocess.TimeoutExpired: except (ProcessLookupError, ChildProcessError) as e:
pass logger.debug("Process already reaped or not found: {}", e)
return f"Error: Command timed out after {effective_timeout} seconds" return f"Error: Command timed out after {effective_timeout} seconds"
await asyncio.sleep(0.05)
stdout_file.seek(0)
stderr_file.seek(0)
stdout = stdout_file.read()
stderr = stderr_file.read()
output_parts = [] output_parts = []

View File

@@ -66,11 +66,8 @@ matrix = [
dev = [ dev = [
"pytest>=9.0.0,<10.0.0", "pytest>=9.0.0,<10.0.0",
"pytest-asyncio>=1.3.0,<2.0.0", "pytest-asyncio>=1.3.0,<2.0.0",
"pytest-cov>=6.0.0,<7.0.0",
"ruff>=0.1.0", "ruff>=0.1.0",
"matrix-nio[e2e]>=0.25.2",
"mistune>=3.0.0,<4.0.0",
"nh3>=0.2.17,<1.0.0",
"mypy>=1.19.1",
] ]
[project.scripts] [project.scripts]

View File

@@ -3,6 +3,16 @@ from types import SimpleNamespace
import pytest import pytest
# Check optional dingtalk dependencies before running tests
try:
from nanobot.channels import dingtalk
DINGTALK_AVAILABLE = getattr(dingtalk, "DINGTALK_AVAILABLE", False)
except ImportError:
DINGTALK_AVAILABLE = False
if not DINGTALK_AVAILABLE:
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
import nanobot.channels.dingtalk as dingtalk_module import nanobot.channels.dingtalk as dingtalk_module
from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler

View File

@@ -1,3 +1,14 @@
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
import pytest
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.channels.feishu import FeishuChannel from nanobot.channels.feishu import FeishuChannel

View File

@@ -1,3 +1,14 @@
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
import pytest
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.channels.feishu import FeishuChannel, _extract_post_content from nanobot.channels.feishu import FeishuChannel, _extract_post_content

View File

@@ -7,6 +7,16 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig from nanobot.channels.feishu import FeishuChannel, FeishuConfig

View File

@@ -6,6 +6,17 @@ list of card elements into groups so that each group contains at most one
table, allowing nanobot to send multiple cards instead of failing. table, allowing nanobot to send multiple cards instead of failing.
""" """
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
import pytest
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.channels.feishu import FeishuChannel from nanobot.channels.feishu import FeishuChannel

View File

@@ -0,0 +1,148 @@
"""Tests for FeishuChannel tool hint code block formatting."""
import json
from unittest.mock import MagicMock, patch
import pytest
from pytest import mark
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.channels.feishu import FeishuChannel
@pytest.fixture
def mock_feishu_channel():
"""Create a FeishuChannel with mocked client."""
config = MagicMock()
config.app_id = "test_app_id"
config.app_secret = "test_app_secret"
config.encrypt_key = None
config.verification_token = None
bus = MagicMock()
channel = FeishuChannel(config, bus)
channel._client = MagicMock() # Simulate initialized client
return channel
@mark.asyncio
async def test_tool_hint_sends_code_message(mock_feishu_channel):
"""Tool hint messages should be sent as interactive cards with code blocks."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='web_search("test query")',
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
# Verify interactive message with card was sent
assert mock_send.call_count == 1
call_args = mock_send.call_args[0]
receive_id_type, receive_id, msg_type, content = call_args
assert receive_id_type == "chat_id"
assert receive_id == "oc_123456"
assert msg_type == "interactive"
# Parse content to verify card structure
card = json.loads(content)
assert card["config"]["wide_screen_mode"] is True
assert len(card["elements"]) == 1
assert card["elements"][0]["tag"] == "markdown"
# Check that code block is properly formatted with language hint
expected_md = "**Tool Calls**\n\n```text\nweb_search(\"test query\")\n```"
assert card["elements"][0]["content"] == expected_md
@mark.asyncio
async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
"""Empty tool hint messages should not be sent."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content=" ", # whitespace only
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
# Should not send any message
mock_send.assert_not_called()
@mark.asyncio
async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
"""Regular messages without _tool_hint should use normal formatting."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content="Hello, world!",
metadata={}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
# Should send as text message (detected format)
assert mock_send.call_count == 1
call_args = mock_send.call_args[0]
_, _, msg_type, content = call_args
assert msg_type == "text"
assert json.loads(content) == {"text": "Hello, world!"}
@mark.asyncio
async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
"""Multiple tool calls should be displayed each on its own line in a code block."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='web_search("query"), read_file("/path/to/file")',
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
call_args = mock_send.call_args[0]
msg_type = call_args[2]
content = json.loads(call_args[3])
assert msg_type == "interactive"
# Each tool call should be on its own line
expected_md = "**Tool Calls**\n\n```text\nweb_search(\"query\"),\nread_file(\"/path/to/file\")\n```"
assert content["elements"][0]["content"] == expected_md
@mark.asyncio
async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
"""Commas inside a single tool argument must not be split onto a new line."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='web_search("foo, bar"), read_file("/path/to/file")',
metadata={"_tool_hint": True}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
content = json.loads(mock_send.call_args[0][3])
expected_md = (
"**Tool Calls**\n\n```text\n"
"web_search(\"foo, bar\"),\n"
"read_file(\"/path/to/file\")\n```"
)
assert content["elements"][0]["content"] == expected_md

View File

@@ -4,6 +4,12 @@ from types import SimpleNamespace
import pytest import pytest
# Check optional matrix dependencies before importing
try:
import nh3 # noqa: F401
except ImportError:
pytest.skip("Matrix dependencies not installed (nh3)", allow_module_level=True)
import nanobot.channels.matrix as matrix_module import nanobot.channels.matrix as matrix_module
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus

View File

@@ -3,6 +3,16 @@ from types import SimpleNamespace
import pytest import pytest
# Check optional QQ dependencies before running tests
try:
from nanobot.channels import qq
QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False)
except ImportError:
QQ_AVAILABLE = False
if not QQ_AVAILABLE:
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.qq import QQChannel, _make_bot_class from nanobot.channels.qq import QQChannel, _make_bot_class

View File

@@ -2,6 +2,12 @@ from __future__ import annotations
import pytest import pytest
# Check optional Slack dependencies before running tests
try:
import slack_sdk # noqa: F401
except ImportError:
pytest.skip("Slack dependencies not installed (slack-sdk)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.slack import SlackChannel from nanobot.channels.slack import SlackChannel

View File

@@ -3,6 +3,12 @@ from unittest.mock import AsyncMock
import pytest import pytest
# Check optional Telegram dependencies before running tests
try:
import telegram # noqa: F401
except ImportError:
pytest.skip("Telegram dependencies not installed (python-telegram-bot)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel